diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 889218c67..e90e880b1 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -17,6 +17,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; import { useUser } from "~/features/auth/core/user"; +import { ScheduleNudge } from "~/features/availability/components/ScheduleNudge"; import { useChatContext } from "~/features/chat/ChatProvider"; import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; @@ -127,6 +128,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) { {activePanel === "tourneys" ? ( ["events"]; + showScheduleNudge: boolean; onClose: () => void; onTabPress: (panel: PanelType) => void; isLoggedIn: boolean; @@ -525,6 +529,7 @@ function TourneysPanel({ isLoggedIn={isLoggedIn} skipAnimation={skipAnimation} > + {showScheduleNudge ? : null} {t("front:sideNav.myCalendar")} + {showScheduleNudge ? : null} {events.length > 0 ? ( events.map((event) => ( { }); }); +describe("AvailabilityRepository.hasReportedWeek", () => { + beforeEach(async () => { + await users.create(1); + }); + + test("finds the week even when it was reported in another timezone", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: Availability.weekStartsAt( + new Date(WEEK_STARTS_AT * 1000 + 3 * 24 * 60 * 60 * 1000), + "Asia/Tokyo", + ), + timezone: "Asia/Tokyo", + }); + + expect( + await AvailabilityRepository.hasReportedWeek({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }), + ).toBe(true); + }); + + test("does not confuse a neighbouring week for the asked one", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: NEXT_WEEK_STARTS_AT, + }); + + expect( + await AvailabilityRepository.hasReportedWeek({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }), + ).toBe(false); + }); +}); + +describe("AvailabilityRepository.findWeekReminderUserIds", () => { + beforeEach(async () => { + await users.create(4); + }); + + const reminderUserIds = () => + AvailabilityRepository.findWeekReminderUserIds(WEEK_STARTS_AT); + + test("reminds the members whose teammate reported the week", async () => { + await TeamFactory.create({ + memberUserIds: [users.id(1), users.id(2), users.id(3)], + }); + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([users.id(2), users.id(3)]); + }); + + test("reminds nobody on a team where nobody reported the week", async () => { + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] }); + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: NEXT_WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([]); + }); + + test("reminds a user once even when several of their teams qualify", async () => { + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(3)] }); + await TeamFactory.create({ + memberUserIds: [users.id(2), users.id(3)], + isMainTeam: false, + }); + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + await AvailabilityWeekFactory.create({ + userId: users.id(2), + weekStartsAt: WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([users.id(3)]); + }); + + test("leaves users without a team out", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([]); + }); +}); + describe("AvailabilityRepository.findTeamEventsByTeamId", () => { beforeEach(async () => { await users.create(2); diff --git a/app/features/availability/AvailabilityRepository.server.ts b/app/features/availability/AvailabilityRepository.server.ts index 31b54eb08..4bee7568d 100644 --- a/app/features/availability/AvailabilityRepository.server.ts +++ b/app/features/availability/AvailabilityRepository.server.ts @@ -1,3 +1,4 @@ +import * as R from "remeda"; import { db } from "~/db/sql"; import type { TablesInsertable } from "~/db/tables"; import { actorId } from "~/features/auth/core/user.server"; @@ -6,6 +7,7 @@ import { concatUserSubmittedImagePrefix, jsonArrayFrom, } from "~/utils/kysely.server"; +import { AVAILABILITY } from "./availability-constants"; import type { TimeRange } from "./availability-types"; /** Longest a week can be, a DST week included. Weeks are indexed by their start, so finding the ones overlapping a window means looking this far back. */ @@ -65,6 +67,80 @@ export function findAllWeeksByUserIds({ .execute(); } +/** + * Whether the user has reported the week starting at `weekStartsAt`. The week + * is theirs to place, so a start within {@link AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS} + * of the asked one is the same week seen from another timezone. + */ +export async function hasReportedWeek({ + userId, + weekStartsAt, +}: { + userId: number; + weekStartsAt: number; +}) { + const week = await db + .selectFrom("AvailabilityWeek") + .select("AvailabilityWeek.id") + .where("AvailabilityWeek.userId", "=", userId) + .where( + "AvailabilityWeek.weekStartsAt", + ">", + weekStartsAt - AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ) + .where( + "AvailabilityWeek.weekStartsAt", + "<", + weekStartsAt + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ) + .executeTakeFirst(); + + return Boolean(week); +} + +/** + * Ids of the users who have not reported the week starting at `weekStartsAt` + * while at least one of their teammates has — the reminder is only worth + * sending when somebody else on the team already moved. + */ +export async function findWeekReminderUserIds(weekStartsAt: number) { + const memberships = await db + .selectFrom("TeamMemberWithSecondary") + .leftJoin("AvailabilityWeek", (join) => + join + .onRef("AvailabilityWeek.userId", "=", "TeamMemberWithSecondary.userId") + .on( + "AvailabilityWeek.weekStartsAt", + ">", + weekStartsAt - AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ) + .on( + "AvailabilityWeek.weekStartsAt", + "<", + weekStartsAt + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ), + ) + .select([ + "TeamMemberWithSecondary.userId", + "TeamMemberWithSecondary.teamId", + "AvailabilityWeek.id as reportedWeekId", + ]) + .execute(); + + const userIds = new Set(); + for (const team of Object.values( + R.groupBy(memberships, (membership) => membership.teamId), + )) { + if (!team.some((member) => member.reportedWeekId !== null)) continue; + + for (const member of team) { + if (member.reportedWeekId === null) userIds.add(member.userId); + } + } + + return Array.from(userIds); +} + /** * Team events of every team the given users are members of (secondary teams * included) that overlap the given window, one row per member. @@ -256,7 +332,7 @@ export function deleteWeeksStartedBefore(weekStartsAt: number) { return db .deleteFrom("AvailabilityWeek") .where("AvailabilityWeek.weekStartsAt", "<", weekStartsAt) - .execute(); + .executeTakeFirstOrThrow(); } /** diff --git a/app/features/availability/actions/events.server.ts b/app/features/availability/actions/events.server.ts index ff6aefd87..88833291b 100644 --- a/app/features/availability/actions/events.server.ts +++ b/app/features/availability/actions/events.server.ts @@ -2,69 +2,99 @@ import { addWeeks } from "date-fns"; import type { ActionFunction } from "react-router"; import * as R from "remeda"; import { requireUser } from "~/features/auth/core/user.server"; +import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; import * as AvailabilityRepository from "../AvailabilityRepository.server"; import { AVAILABILITY } from "../availability-constants"; -import { saveWeekSchema } from "../availability-schemas"; +import { eventsActionSchema } from "../availability-schemas"; import * as Availability from "../core/Availability"; const DAY_SECONDS = 24 * 60 * 60; export const action: ActionFunction = async ({ request }) => { - requireUser(); + const user = requireUser(); - const data = await parseRequestPayload({ request, schema: saveWeekSchema }); - const timezone = getViewerTimezone() ?? "UTC"; - - const weekStartsAt = Availability.localToTimestamp({ - date: data.days[0].date, - time: "00:00", - timezone, + const data = await parseRequestPayload({ + request, + schema: eventsActionSchema, }); - + const timezone = getViewerTimezone() ?? "UTC"; const now = new Date(); - errorToastIfFalsy( - R.range(0, AVAILABILITY.WEEK_HORIZON).some( - (weekOffset) => - Availability.weekStartsAt(addWeeks(now, weekOffset), timezone) === + + switch (data._action) { + case "SAVE_WEEK": { + const weekStartsAt = Availability.localToTimestamp({ + date: data.days[0].date, + time: "00:00", + timezone, + }); + + errorToastIfFalsy( + R.range(0, AVAILABILITY.WEEK_HORIZON).some( + (weekOffset) => + Availability.weekStartsAt(addWeeks(now, weekOffset), timezone) === + weekStartsAt, + ), + "Only the current and the next week can be saved", + ); + errorToastIfFalsy( + data.days.every( + (day, dayIndex) => + day.date === + Availability.dateInTimezone( + weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + timezone, + ), + ), + "Days do not form one week", + ); + + await AvailabilityRepository.upsertOwnWeek({ weekStartsAt, - ), - "Only the current and the next week can be saved", - ); - errorToastIfFalsy( - data.days.every( - (day, dayIndex) => - day.date === - Availability.dateInTimezone( - weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + timezone, + slots: data.days.flatMap((day) => + day.ranges.map((range) => ({ + startsAt: Availability.dayMinutesToTimestamp({ + date: day.date, + minutes: range.start, + timezone, + }), + endsAt: Availability.dayMinutesToTimestamp({ + date: day.date, + minutes: range.end, + timezone, + }), + })), + ), + dayNotes: data.days.flatMap((day) => + day.note ? [{ date: day.date, text: day.note }] : [], + ), + }); + + await resolveNotifications({ + userIds: [user.id], + type: "SCHEDULE_TEAM_REMINDER", + }); + + break; + } + case "DISMISS_SCHEDULE_NUDGE": { + await UserRepository.updateOwnPreferences({ + scheduleNudgeDismissedWeekStartsAt: Availability.weekStartsAt( + addWeeks(now, 1), timezone, ), - ), - "Days do not form one week", - ); + }); - await AvailabilityRepository.upsertOwnWeek({ - weekStartsAt, - timezone, - slots: data.days.flatMap((day) => - day.ranges.map((range) => ({ - startsAt: Availability.dayMinutesToTimestamp({ - date: day.date, - minutes: range.start, - timezone, - }), - endsAt: Availability.dayMinutesToTimestamp({ - date: day.date, - minutes: range.end, - timezone, - }), - })), - ), - dayNotes: data.days.flatMap((day) => - day.note ? [{ date: day.date, text: day.note }] : [], - ), - }); + break; + } + default: { + assertUnreachable(data); + } + } return null; }; diff --git a/app/features/availability/availability-schemas.ts b/app/features/availability/availability-schemas.ts index a967eb3c2..a4d835e5c 100644 --- a/app/features/availability/availability-schemas.ts +++ b/app/features/availability/availability-schemas.ts @@ -44,6 +44,16 @@ export const saveWeekSchema = v.object({ days: v.pipe(v.array(editorDaySchema), v.length(7)), }); +export const dismissScheduleNudgeSchema = v.object({ + _action: _action("DISMISS_SCHEDULE_NUDGE"), + revalidateRoot: v.optional(v.nullable(v.literal(true))), +}); + +export const eventsActionSchema = v.union([ + saveWeekSchema, + dismissScheduleNudgeSchema, +]); + const teamEventDurationItems = [ { label: "options.duration.30m" as const, value: "30" }, { label: "options.duration.1h" as const, value: "60" }, diff --git a/app/features/availability/components/ScheduleNudge.module.css b/app/features/availability/components/ScheduleNudge.module.css new file mode 100644 index 000000000..7ed4c490d --- /dev/null +++ b/app/features/availability/components/ScheduleNudge.module.css @@ -0,0 +1,37 @@ +.container { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-1-5) var(--s-2); + border-bottom: 1.5px solid var(--color-border); +} + +/** Flush against the events header, bleeding past the sidebar's own padding. */ +.sidebar { + margin-block-start: calc(-1 * var(--s-2)); + margin-inline: calc(-1 * var(--s-1-5)); +} + +/** Same, for the mobile events panel. */ +.panel { + margin-block-start: calc(-1 * var(--s-2)); + margin-inline: calc(-1 * var(--s-2)); +} + +.link { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + color: var(--color-text-accent); + + &:hover { + text-decoration: underline; + } +} + +.dismissButton { + margin-inline-start: auto; + color: var(--color-text-high); +} diff --git a/app/features/availability/components/ScheduleNudge.tsx b/app/features/availability/components/ScheduleNudge.tsx new file mode 100644 index 000000000..16f2f00a6 --- /dev/null +++ b/app/features/availability/components/ScheduleNudge.tsx @@ -0,0 +1,63 @@ +import clsx from "clsx"; +import { CalendarPlus, X } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; +import { EVENTS_PAGE } from "~/utils/urls"; +import { dismissScheduleNudgeSchema } from "../availability-schemas"; +import { scheduleWeekSearchParams } from "../availability-search-params"; +import styles from "./ScheduleNudge.module.css"; + +/** + * Prompt to report next week's availability, shown on the last day of the week + * while next week is still empty. Sits as a band right under the events header. + * Dismissing it is remembered for the week, so it can be waved away without + * filling anything in. + */ +export function ScheduleNudge({ + panel, + onNavigate, +}: { + /** Bleeds past the mobile events panel's padding rather than the sidebar's. */ + panel?: boolean; + onNavigate?: () => void; +}) { + const { t } = useTranslation(["front"]); + const [dismissed, setDismissed] = React.useState(false); + const { submit } = useActionSubmit(dismissScheduleNudgeSchema, { + action: EVENTS_PAGE, + encType: "application/json", + }); + + if (dismissed) return null; + + const dismiss = () => { + setDismissed(true); + submit("DISMISS_SCHEDULE_NUDGE", { revalidateRoot: true }); + }; + + return ( +
+ + + {t("front:sideNav.scheduleNudge")} + + } + variant="minimal" + size="miniscule" + className={styles.dismissButton} + aria-label={t("front:sideNav.scheduleNudge.dismiss")} + onPress={dismiss} + /> +
+ ); +} diff --git a/app/features/availability/core/Availability.test.ts b/app/features/availability/core/Availability.test.ts index 1c979e9d2..501feaf13 100644 --- a/app/features/availability/core/Availability.test.ts +++ b/app/features/availability/core/Availability.test.ts @@ -420,6 +420,47 @@ describe("Availability.isoWeekNumber", () => { }); }); +describe("Availability.isFirstDayOfWeek", () => { + test.each([ + { why: "a Monday", date: "2026-08-24", is: true }, + { why: "a Sunday", date: "2026-08-30", is: false }, + { why: "a Wednesday", date: "2026-08-26", is: false }, + ])("resolves $why to $is", ({ date, is }) => { + expect( + Availability.isFirstDayOfWeek( + new Date(at(date, "12:00") * 1000), + HELSINKI, + ), + ).toBe(is); + }); +}); + +describe("Availability.isLastDayOfWeek", () => { + test.each([ + { why: "a Sunday", date: "2026-08-30", is: true }, + { why: "a Monday", date: "2026-08-24", is: false }, + { why: "a Saturday", date: "2026-08-29", is: false }, + ])("resolves $why to $is", ({ date, is }) => { + expect( + Availability.isLastDayOfWeek( + new Date(at(date, "12:00") * 1000), + HELSINKI, + ), + ).toBe(is); + }); + + test("resolves an instant by the timezone's local day", () => { + const mondayEarlyHelsinki = new Date(at("2026-08-31", "01:00") * 1000); + + expect(Availability.isLastDayOfWeek(mondayEarlyHelsinki, HELSINKI)).toBe( + false, + ); + expect(Availability.isLastDayOfWeek(mondayEarlyHelsinki, LOS_ANGELES)).toBe( + true, + ); + }); +}); + describe("Availability.playableWindows", () => { const members = ( ranges: Array>, diff --git a/app/features/availability/core/Availability.ts b/app/features/availability/core/Availability.ts index d7254bc36..166e80da5 100644 --- a/app/features/availability/core/Availability.ts +++ b/app/features/availability/core/Availability.ts @@ -1,5 +1,12 @@ import { TZDate } from "@date-fns/tz"; -import { addWeeks, format, getISOWeek, startOfWeek } from "date-fns"; +import { + addWeeks, + format, + getISOWeek, + isMonday, + isSunday, + startOfWeek, +} from "date-fns"; import * as R from "remeda"; import { databaseTimestampToJavascriptTimestamp, @@ -43,6 +50,16 @@ export function weekRange(date: Date, timezone: string): TimeRange { }; } +/** Whether `date` falls on the first day of its week (Monday), as the week is seen in `timezone`. */ +export function isFirstDayOfWeek(date: Date, timezone: string) { + return isMonday(new TZDate(date.getTime(), timezone)); +} + +/** Whether `date` falls on the last day of its week (Sunday), as the week is seen in `timezone`. */ +export function isLastDayOfWeek(date: Date, timezone: string) { + return isSunday(new TZDate(date.getTime(), timezone)); +} + /** ISO week number of the week the timestamp falls in, as seen in `timezone`. */ export function isoWeekNumber(timestamp: number, timezone: string) { return getISOWeek(inTimezone(timestamp, timezone)); diff --git a/app/features/layout/core/layout.server.ts b/app/features/layout/core/layout.server.ts index 4bc7b0acc..b4dd08477 100644 --- a/app/features/layout/core/layout.server.ts +++ b/app/features/layout/core/layout.server.ts @@ -12,7 +12,7 @@ import { GIT_COMMIT } from "~/utils/git-commit"; export async function resolveLayoutData(user: AuthenticatedUser | undefined) { return { loggedInUserId: user?.id ?? null, - sidebar: await resolveSidebarData(user?.id ?? null), + sidebar: await resolveSidebarData(user), buildCommit: GIT_COMMIT, }; } diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index 1da04193d..a2886933c 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -40,6 +40,7 @@ const NOTIFICATION_URGENCY: Record = { COMMISSIONS_CLOSED: "normal", FRIEND_REQUEST_RECEIVED: "normal", TEAM_EVENT_ADDED: "normal", + SCHEDULE_TEAM_REMINDER: "normal", }; /** How long a push notification is held back before sending. Anything marking the notification as seen during this window (the user addressing what it is about, opening the notification list, `defaultSeenUserIds`) cancels the push for that user. */ diff --git a/app/features/notifications/core/resolve.server.ts b/app/features/notifications/core/resolve.server.ts index 345bdaa29..1a2ce4987 100644 --- a/app/features/notifications/core/resolve.server.ts +++ b/app/features/notifications/core/resolve.server.ts @@ -51,6 +51,7 @@ const RESOLUTION_TRIGGERS = { FRIEND_REQUEST_RECEIVED: "accepts or declines the request, or the sender cancels it", TEAM_EVENT_ADDED: "visits the team's schedule page", + SCHEDULE_TEAM_REMINDER: "saves any week of their own schedule", } as const satisfies Record; type ResolvableNotificationType = { diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts index 19afbf7a7..2955fcc8a 100644 --- a/app/features/notifications/notifications-types.ts +++ b/app/features/notifications/notifications-types.ts @@ -114,7 +114,8 @@ export type Notification = teamName: string; teamCustomUrl: string; } - >; + > + | NotificationItem<"SCHEDULE_TEAM_REMINDER">; type NotificationItem< T extends string, diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts index f106e7195..0d8f659d1 100644 --- a/app/features/notifications/notifications-utils.ts +++ b/app/features/notifications/notifications-utils.ts @@ -5,6 +5,7 @@ import { userSeasonsPage } from "~/features/user-page/user-page-urls"; import { assertUnreachable } from "~/utils/types"; import { badgePage, + EVENTS_PAGE, FRIENDS_PAGE, NEW_TROPHY_PAGE, PLUS_VOTING_PAGE, @@ -65,6 +66,8 @@ export const notificationNavIcon = (type: Notification["type"]) => { return "sendou_love"; case "TEAM_EVENT_ADDED": return "t"; + case "SCHEDULE_TEAM_REMINDER": + return "calendar"; default: assertUnreachable(type); } @@ -143,6 +146,9 @@ export const notificationLink = ( case "TEAM_EVENT_ADDED": { return teamSchedulePage(notification.meta.teamCustomUrl); } + case "SCHEDULE_TEAM_REMINDER": { + return EVENTS_PAGE; + } default: assertUnreachable(notification); } diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index 204646172..83e208233 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -1,9 +1,12 @@ import { cachified } from "@epic-web/cachified"; -import { addDays } from "date-fns"; +import { addDays, addWeeks } from "date-fns"; import { href } from "react-router"; import * as R from "remeda"; import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server"; +import type { AuthenticatedUser } from "~/features/auth/core/user.server"; import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server"; +import { AVAILABILITY } from "~/features/availability/availability-constants"; +import * as Availability from "~/features/availability/core/Availability"; import { userIsBanned } from "~/features/ban/core/banned.server"; import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; import { @@ -28,6 +31,7 @@ import type { SidebarScrim } from "~/features/scrims/ScrimPostRepository.server" import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; import { scrimsSearchParams } from "~/features/scrims/scrims-search-params"; import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; import { cache, ttl } from "~/utils/cache.server"; @@ -77,7 +81,9 @@ const UPCOMING_TOURNAMENT_WINDOW_DAYS = 3; const SENDOUQ_QUOTA = 2; const TOURNAMENT_SUB_QUOTA = 2; -export async function resolveSidebarData(userId: number | null) { +export async function resolveSidebarData(user: AuthenticatedUser | undefined) { + const userId = user?.id ?? null; + if (!userId) { return { events: [] as SidebarEvent[], @@ -85,6 +91,7 @@ export async function resolveSidebarData(userId: number | null) { streams: await combinedStreamsCached(), savedTournamentIds: [] as number[], incomingFriendRequestIds: [] as number[], + scheduleNudge: false, }; } @@ -97,6 +104,7 @@ export async function resolveSidebarData(userId: number | null) { incomingFriendRequestIds, streamedSendouQMatches, teamEvents, + scheduleNudge, ] = await Promise.all([ ShowcaseTournaments.categorizedTournamentsByUserId(userId), ScrimPostRepository.findUserScrims(userId), @@ -105,6 +113,7 @@ export async function resolveSidebarData(userId: number | null) { FriendRepository.findPendingReceivedRequestIds(userId), resolveSendouQMatchStreams(), findUpcomingTeamEvents(userId), + showScheduleNudge(user), ]); const seenTournamentIds = new Set(); @@ -151,9 +160,39 @@ export async function resolveSidebarData(userId: number | null) { streams: await combinedStreamsCached(), savedTournamentIds, incomingFriendRequestIds, + scheduleNudge, }; } +/** + * Whether to prompt the user to report next week: they are on its last day, it + * is still empty, and they have not waved the prompt away for this week. + */ +async function showScheduleNudge(user: AuthenticatedUser | undefined) { + if (!user) return false; + + const timezone = getViewerTimezone() ?? "UTC"; + const now = new Date(); + + if (!Availability.isLastDayOfWeek(now, timezone)) return false; + + const weekStartsAt = Availability.weekStartsAt(addWeeks(now, 1), timezone); + const dismissedAt = user.preferences?.scheduleNudgeDismissedWeekStartsAt; + + if ( + dismissedAt !== undefined && + Math.abs(dismissedAt - weekStartsAt) < + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS + ) { + return false; + } + + return !(await AvailabilityRepository.hasReportedWeek({ + userId: user.id, + weekStartsAt, + })); +} + function combinedStreamsCached(): Promise { return cachified({ key: COMBINED_STREAMS_KEY, diff --git a/app/root.tsx b/app/root.tsx index e426d4644..4a6344691 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -56,6 +56,7 @@ import { useTheme, } from "./features/theme/core/provider"; import { getThemeSession } from "./features/theme/core/theme-session.server"; +import { timezoneMiddleware } from "./features/timezone/timezone-middleware.server"; import { UnsavedChangesGuard } from "./form/UnsavedChangesGuard"; import { useUserIntlPreference } from "./hooks/intl/useUserIntlPreference"; import { useHydrated } from "./hooks/useHydrated"; @@ -87,6 +88,7 @@ export const middleware: Route.MiddlewareFunction[] = [ sessionIdMiddleware, userMiddleware, i18nMiddleware, + timezoneMiddleware, ]; import "~/styles/fonts.css"; diff --git a/app/routines/deleteOldAvailability.test.ts b/app/routines/deleteOldAvailability.test.ts new file mode 100644 index 000000000..e2dd9117d --- /dev/null +++ b/app/routines/deleteOldAvailability.test.ts @@ -0,0 +1,58 @@ +import { subMonths, subWeeks } from "date-fns"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server"; +import { AVAILABILITY } from "~/features/availability/availability-constants"; +import * as Availability from "~/features/availability/core/Availability"; +import { DeleteOldAvailabilityRoutine } from "./deleteOldAvailability"; + +const users = UserFactory.pool(); + +const NOW = new Date("2026-08-24T09:00:00Z"); + +const seedWeekOf = (date: Date) => + AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: Availability.weekStartsAt(date, "UTC"), + timezone: "UTC", + }); + +const remainingWeekStarts = async () => + ( + await AvailabilityRepository.findAllWeeksByUserIds({ + userIds: [users.id(1)], + startsAt: 0, + endsAt: Availability.weekStartsAt(NOW, "UTC") + 1, + }) + ).map((week) => week.weekStartsAt); + +describe("DeleteOldAvailabilityRoutine", () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + await users.create(1); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("deletes weeks that ended over the retention period ago, keeping the rest", async () => { + const retentionAgo = subMonths(NOW, AVAILABILITY.RETENTION_MONTHS); + const longGone = subWeeks(retentionAgo, 4); + // the week the retention period reaches into still ends inside it + const justInside = retentionAgo; + + await seedWeekOf(longGone); + await seedWeekOf(justInside); + await seedWeekOf(NOW); + + await DeleteOldAvailabilityRoutine.run(); + + expect(await remainingWeekStarts()).toEqual([ + Availability.weekStartsAt(justInside, "UTC"), + Availability.weekStartsAt(NOW, "UTC"), + ]); + }); +}); diff --git a/app/routines/deleteOldAvailability.ts b/app/routines/deleteOldAvailability.ts new file mode 100644 index 000000000..3d7e69936 --- /dev/null +++ b/app/routines/deleteOldAvailability.ts @@ -0,0 +1,27 @@ +import { subDays, subMonths } from "date-fns"; +import * as AvailabilityRepository from "../features/availability/AvailabilityRepository.server"; +import { AVAILABILITY } from "../features/availability/availability-constants"; +import { dateToDatabaseTimestamp } from "../utils/dates"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +const WEEK_DAYS = 7; + +export const DeleteOldAvailabilityRoutine = new Routine({ + name: "DeleteOldAvailability", + func: async () => { + // weeks are indexed by their start, so a week that ended long enough ago + // is one that started a week further back than that + const cutOff = subDays( + subMonths(new Date(), AVAILABILITY.RETENTION_MONTHS), + WEEK_DAYS, + ); + + const { numDeletedRows } = + await AvailabilityRepository.deleteWeeksStartedBefore( + dateToDatabaseTimestamp(cutOff), + ); + + logger.info(`Deleted ${numDeletedRows} old availability weeks`); + }, +}); diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index b96fea5ef..057b7c167 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -3,6 +3,7 @@ import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions"; import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes"; import { ComputeLutiDivsRoutine } from "./computeLutiDivs"; import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods"; +import { DeleteOldAvailabilityRoutine } from "./deleteOldAvailability"; import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams"; import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications"; import { DeleteOldPendingFriendRequestsRoutine } from "./deleteOldPendingFriendRequests"; @@ -13,6 +14,7 @@ import { EvictStaleRunningTournamentsRoutine } from "./evictStaleRunningTourname import { ExpireReadyChecksRoutine } from "./expireReadyChecks"; import { NotifyCheckInStartRoutine } from "./notifyCheckInStart"; import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting"; +import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder"; import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon"; import { NotifySeasonEndRoutine } from "./notifySeasonEnd"; import { NotifySeasonStartRoutine } from "./notifySeasonStart"; @@ -53,6 +55,8 @@ export const daily = [ DeleteOldPendingFriendRequestsRoutine, DeleteOldTournamentAuditLogsRoutine, DeleteOldScrimPickupRostersRoutine, + DeleteOldAvailabilityRoutine, + NotifyScheduleTeamReminderRoutine, CloseExpiredCommissionsRoutine, CloseExpiredChatRoomsRoutine, DeleteOrphanArtTagsRoutine, diff --git a/app/routines/notifyScheduleTeamReminder.test.ts b/app/routines/notifyScheduleTeamReminder.test.ts new file mode 100644 index 000000000..ade1ef77e --- /dev/null +++ b/app/routines/notifyScheduleTeamReminder.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +import * as TeamFactory from "~/db/seed/factories/TeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as Availability from "~/features/availability/core/Availability"; +import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder"; + +const users = UserFactory.pool(); + +const { mockNotify } = vi.hoisted(() => ({ + mockNotify: vi.fn(), +})); + +vi.mock("~/features/notifications/core/notify.server", () => ({ + notify: mockNotify, +})); + +const MONDAY = new Date("2026-08-24T09:00:00Z"); +const WEDNESDAY = new Date("2026-08-26T09:00:00Z"); + +const reportCurrentWeek = (userId: number) => + AvailabilityWeekFactory.create({ + userId, + weekStartsAt: Availability.weekStartsAt(MONDAY, "UTC"), + timezone: "UTC", + }); + +describe("NotifyScheduleTeamReminderRoutine", () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(MONDAY); + await users.create(2); + mockNotify.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("notifies the teammate who has not reported the week", async () => { + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] }); + await reportCurrentWeek(users.id(1)); + + await NotifyScheduleTeamReminderRoutine.run(); + + expect(mockNotify).toHaveBeenCalledWith({ + notification: { type: "SCHEDULE_TEAM_REMINDER" }, + userIds: [users.id(2)], + }); + }); + + test("notifies nobody when no teammate reported the week", async () => { + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] }); + + await NotifyScheduleTeamReminderRoutine.run(); + + expect(mockNotify).not.toHaveBeenCalled(); + }); + + test("does nothing on a day that is not the first of the week", async () => { + vi.setSystemTime(WEDNESDAY); + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] }); + await reportCurrentWeek(users.id(1)); + + await NotifyScheduleTeamReminderRoutine.run(); + + expect(mockNotify).not.toHaveBeenCalled(); + }); +}); diff --git a/app/routines/notifyScheduleTeamReminder.ts b/app/routines/notifyScheduleTeamReminder.ts new file mode 100644 index 000000000..5caf3d332 --- /dev/null +++ b/app/routines/notifyScheduleTeamReminder.ts @@ -0,0 +1,39 @@ +import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../features/admin/core/dev-controls"; +import * as AvailabilityRepository from "../features/availability/AvailabilityRepository.server"; +import * as Availability from "../features/availability/core/Availability"; +import { notify } from "../features/notifications/core/notify.server"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +/** + * Reminds users whose teammates have reported the week that just started while + * they have not. Runs on Mondays only, which is also what keeps it to at most + * one reminder per user per week. + */ +export const NotifyScheduleTeamReminderRoutine = new Routine({ + name: "NotifyScheduleTeamReminder", + func: async () => { + const now = new Date(); + + // runs whatever the day is when triggered by hand in development + if ( + !Availability.isFirstDayOfWeek(now, "UTC") && + !DANGEROUS_CAN_ACCESS_DEV_CONTROLS + ) { + return; + } + + const userIds = await AvailabilityRepository.findWeekReminderUserIds( + Availability.weekStartsAt(now, "UTC"), + ); + + if (userIds.length === 0) return; + + logger.info(`Reminding ${userIds.length} users about their schedule`); + + await notify({ + notification: { type: "SCHEDULE_TEAM_REMINDER" }, + userIds, + }); + }, +}); diff --git a/locales/da/common.json b/locales/da/common.json index f4413345f..170e4917e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "logindforsøg afbrudt", "auth.errors.failed": "Loginforsøg fejlet", "auth.errors.discordPermissions": "Før at du kan oprette en profil på sendou.ink, skal sendou.ink have adgang til din Discordprofils navn, brugerbillede og sociale forbindelser (de sociale medier, som du har tilknyttet din discordprofil).", diff --git a/locales/da/front.json b/locales/da/front.json index df82d5263..50d180a95 100644 --- a/locales/da/front.json +++ b/locales/da/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/de/common.json b/locales/de/common.json index 804deb45b..75aeb09cb 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Einloggen abgebrochen", "auth.errors.failed": "Einloggen fehlgeschlagen", "auth.errors.discordPermissions": "Für dein sendou.ink-Profil benötigt die Seite Zugriff auf den Namen, Avatar und verbundene Social-Media-Accounts in deinem Discord-Profil.", diff --git a/locales/de/front.json b/locales/de/front.json index df82d5263..50d180a95 100644 --- a/locales/de/front.json +++ b/locales/de/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/en/common.json b/locales/en/common.json index 909e6caec..1ff781ac8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} accepted your group invitation in {{tournamentName}}", "notifications.title.TEAM_EVENT_ADDED": "New Team Event", "notifications.text.TEAM_EVENT_ADDED": "{{teamName}} has a new event: {{eventName}}", + "notifications.title.SCHEDULE_TEAM_REMINDER": "Availability Missing", + "notifications.text.SCHEDULE_TEAM_REMINDER": "Your teammates are waiting for you to fill your availability for the week", "auth.errors.aborted": "Login Aborted", "auth.errors.failed": "Login Failed", "auth.errors.discordPermissions": "For your sendou.ink profile, the site needs access to your Discord profile's name, avatar and social connections.", @@ -477,4 +479,4 @@ "tier.confirmed": "{{tierName}}-tier tournament", "spoilerFree.showResults": "Show results", "spoilerFree.hideResults": "Hide results" -} +} \ No newline at end of file diff --git a/locales/en/front.json b/locales/en/front.json index 468392bfc..5a460a9c9 100644 --- a/locales/en/front.json +++ b/locales/en/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "vs. {{opponent}}", "sideNav.lookingForScrim": "Looking for scrim", "sideNav.scrimRequestPending": "Request pending", + "sideNav.scheduleNudge": "Add next week's availability", + "sideNav.scheduleNudge.dismiss": "Dismiss", "mobileNav.menu": "Menu", "mobileNav.friends": "Friends", "mobileNav.you": "You", diff --git a/locales/en/schedule.json b/locales/en/schedule.json index c259be6c3..e24e7253c 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -7,9 +7,9 @@ "editor.later": "Later", "editor.notFilled": "not filled", "editor.note": "Note", - "editor.saved": "Schedule saved", + "editor.saved": "Availability saved", "editor.saveWeek": "Save week", - "editor.title": "My schedule", + "editor.title": "My availability", "editor.timesInYourTimezone": "Times in your time zone", "editor.visibility": "Visible to your teammates and friends", "events.title": "Team events", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 0999aa835..e3be2b89e 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Inicio de Sesión Cancelado", "auth.errors.failed": "Error al Iniciar Sesión", "auth.errors.discordPermissions": "Para tu perfil de sendou.ink, el sitio necesita acceso al nombre, avatar y conexiones sociales de tu perfil de Discord.", diff --git a/locales/es-ES/front.json b/locales/es-ES/front.json index 43a78b935..55d944244 100644 --- a/locales/es-ES/front.json +++ b/locales/es-ES/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "vs. {{opponent}}", "sideNav.lookingForScrim": "Buscando scrim", "sideNav.scrimRequestPending": "Solicitud pendiente", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "Menú", "mobileNav.friends": "Amigos", "mobileNav.you": "Tú", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index fc397756a..f529281e9 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Ingreso cancelado", "auth.errors.failed": "Ingreso fallido", "auth.errors.discordPermissions": "Para tu perfil en sendou.ink, el sitio requiere acceso a tu nombre en Discord, avatar, y redes sociales.", diff --git a/locales/es-US/front.json b/locales/es-US/front.json index efb962de2..68993ce2f 100644 --- a/locales/es-US/front.json +++ b/locales/es-US/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "vs. {{opponent}}", "sideNav.lookingForScrim": "Buscando scrim", "sideNav.scrimRequestPending": "Solicitud pendiente", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "Menú", "mobileNav.friends": "Amigos", "mobileNav.you": "Tú", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 54200edb0..f725c62cf 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Connexion abandonnée", "auth.errors.failed": "Connexion échouée", "auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.", diff --git a/locales/fr-CA/front.json b/locales/fr-CA/front.json index df82d5263..50d180a95 100644 --- a/locales/fr-CA/front.json +++ b/locales/fr-CA/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 927a88c05..a832c75d7 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Connexion abandonnée", "auth.errors.failed": "Connexion échouée", "auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.", diff --git a/locales/fr-EU/front.json b/locales/fr-EU/front.json index faaec0026..f7194efda 100644 --- a/locales/fr-EU/front.json +++ b/locales/fr-EU/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/he/common.json b/locales/he/common.json index 37655eb30..a28d18b2f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "הכניסה בוטלה", "auth.errors.failed": "הכניסה נכשלה", "auth.errors.discordPermissions": "עבור פרופיל sendou.ink שלך, האתר זקוק לגישה לשם, הפרופיל והקשרים החברתיים של פרופיל ה-Discord שלך.", diff --git a/locales/he/front.json b/locales/he/front.json index df82d5263..50d180a95 100644 --- a/locales/he/front.json +++ b/locales/he/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/it/common.json b/locales/it/common.json index 5c018c72c..6125a6114 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Accesso cancellato", "auth.errors.failed": "Accesso fallito", "auth.errors.discordPermissions": "Per il tuo profilo di sendou.ink, il sito ha bisogno di accesso al nome utente, avatar e connessioni social del tuo profilo Discord.", diff --git a/locales/it/front.json b/locales/it/front.json index 2f1b62a1d..159266a35 100644 --- a/locales/it/front.json +++ b/locales/it/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 242eab479..77c7c44c2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}}が{{tournamentName}}への招待が承諾されました", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "ログインを中断しました", "auth.errors.failed": "ログインに失敗しました", "auth.errors.discordPermissions": "sendou.ink プロファイルを作成するには、Discord 名、アバター、そしてSNSの連携が必要です。", diff --git a/locales/ja/front.json b/locales/ja/front.json index df82d5263..50d180a95 100644 --- a/locales/ja/front.json +++ b/locales/ja/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index a0b27a283..e7bb1c6bb 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "로그인 중단됨", "auth.errors.failed": "로그인 실패", "auth.errors.discordPermissions": "sendou.ink 프로필을 위해 디스코드 프로필의 이름, 아바타와 연락처에 대한 접근이 필요합니다.", diff --git a/locales/ko/front.json b/locales/ko/front.json index df82d5263..50d180a95 100644 --- a/locales/ko/front.json +++ b/locales/ko/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index b548ac021..814697afb 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "", "auth.errors.failed": "", "auth.errors.discordPermissions": "", diff --git a/locales/nl/front.json b/locales/nl/front.json index df82d5263..50d180a95 100644 --- a/locales/nl/front.json +++ b/locales/nl/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 2ad53ffbe..b83b8be41 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Logowanie przerwane", "auth.errors.failed": "Logowanie nieudane", "auth.errors.discordPermissions": "Do twojego profilu sendou.ink, ta strona potrzebuje dostęp do twojej nazwy, avataru i połączeń konta Discord.", diff --git a/locales/pl/front.json b/locales/pl/front.json index df82d5263..50d180a95 100644 --- a/locales/pl/front.json +++ b/locales/pl/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index d75a4dd7f..961ed2a25 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Login Abortado", "auth.errors.failed": "Login Falhou", "auth.errors.discordPermissions": "Para o seu perfil do sendou.ink, o site precisa de acesso ao nome do perfil do seu Discord, incluindo também o avatar e conexões sociais.", diff --git a/locales/pt-BR/front.json b/locales/pt-BR/front.json index df82d5263..50d180a95 100644 --- a/locales/pt-BR/front.json +++ b/locales/pt-BR/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index e22052721..11ae04e12 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Вход отменён", "auth.errors.failed": "Ошибка входа", "auth.errors.discordPermissions": "Для вашего профиля на sendou.ink странице нужен доступ к вашему имени, аватару и привязанным аккаунтам соц. сетей в Discord.", diff --git a/locales/ru/front.json b/locales/ru/front.json index 50622da2d..d81054318 100644 --- a/locales/ru/front.json +++ b/locales/ru/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 255324406..293936c0d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -110,6 +110,8 @@ "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} 在赛事【{{tournamentName}}】中接受了您的小组邀请", "notifications.title.TEAM_EVENT_ADDED": "", "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "登录中止", "auth.errors.failed": "登录失败", "auth.errors.discordPermissions": "为了完善您的sendou.ink个人资料,网站需要获取您的 Discord 名字、头像和社交链接。", diff --git a/locales/zh/front.json b/locales/zh/front.json index 5bec39084..9f16b6f82 100644 --- a/locales/zh/front.json +++ b/locales/zh/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "对战 {{opponent}}", "sideNav.lookingForScrim": "寻找对抗战", "sideNav.scrimRequestPending": "请求待处理", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "菜单", "mobileNav.friends": "好友", "mobileNav.you": "你",