diff --git a/app/db/tables.ts b/app/db/tables.ts index e3c61a768..d5aa0b35b 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1376,8 +1376,8 @@ export interface AvailabilityDayNote { export interface TeamEvent { id: GeneratedAlways; teamId: number; - /** User who created the event */ - authorId: number; + /** User who created the event. Null if their account has since been deleted. */ + authorId: number | null; name: string; startsAt: number; endsAt: number; diff --git a/app/features/availability/actions/events.server.test.ts b/app/features/availability/actions/events.server.test.ts new file mode 100644 index 000000000..7f0791a10 --- /dev/null +++ b/app/features/availability/actions/events.server.test.ts @@ -0,0 +1,85 @@ +import { addWeeks } from "date-fns"; +import * as R from "remeda"; +import { describe, expect, test } from "vitest"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { assertResponseErrored, wrappedAction } from "~/utils/Test"; +import * as AvailabilityRepository from "../AvailabilityRepository.server"; +import type { saveWeekSchema } from "../availability-schemas"; +import * as Availability from "../core/Availability"; +import { action as eventsAction } from "./events.server"; + +const DAY_SECONDS = 24 * 60 * 60; +// the action has no request timezone in tests, so it falls back to UTC +const TIMEZONE = "UTC"; + +const saveWeek = wrappedAction({ + action: eventsAction, + isJsonSubmission: true, +}); + +const weekDays = (weeksFromNow: number) => { + const weekStartsAt = Availability.weekStartsAt( + addWeeks(new Date(), weeksFromNow), + TIMEZONE, + ); + + return R.range(0, 7).map((dayIndex) => ({ + date: Availability.dateInTimezone( + weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + TIMEZONE, + ), + ranges: [], + note: "", + })); +}; + +describe("events action: SAVE_WEEK", () => { + test("saves the current week", async () => { + const user = await UserFactory.createRegular(); + + const response = await saveWeek( + { _action: "SAVE_WEEK", days: weekDays(0) }, + { user: "regular" }, + ); + + expect(response).toBeNull(); + expect( + await AvailabilityRepository.hasReportedWeek({ + userId: user.id, + weekStartsAt: Availability.weekStartsAt(new Date(), TIMEZONE), + }), + ).toBe(true); + }); + + test.each([ + { why: "a week before the current one", weeksFromNow: -1 }, + { why: "a week past the horizon", weeksFromNow: 2 }, + ])("rejects $why", async ({ weeksFromNow }) => { + await UserFactory.createRegular(); + + const response = await saveWeek( + { _action: "SAVE_WEEK", days: weekDays(weeksFromNow) }, + { user: "regular" }, + ); + + assertResponseErrored( + response, + "Only the current and the next week can be saved", + ); + }); + + test("rejects days that do not form one week", async () => { + await UserFactory.createRegular(); + const days = weekDays(0); + + const response = await saveWeek( + { + _action: "SAVE_WEEK", + days: [...days.slice(0, 6), { ...days[6], date: days[0].date }], + }, + { user: "regular" }, + ); + + assertResponseErrored(response, "Days do not form one week"); + }); +}); diff --git a/app/features/availability/availability-schemas.test.ts b/app/features/availability/availability-schemas.test.ts new file mode 100644 index 000000000..3a1d01d7e --- /dev/null +++ b/app/features/availability/availability-schemas.test.ts @@ -0,0 +1,47 @@ +import * as R from "remeda"; +import * as v from "valibot"; +import { describe, expect, test } from "vitest"; +import { saveWeekSchema } from "./availability-schemas"; + +const DAY_MINUTES = 24 * 60; + +const weekWith = (ranges: Array<{ start: number; end: number }>) => ({ + _action: "SAVE_WEEK" as const, + days: R.range(0, 7).map((dayIndex) => ({ + date: `2026-08-${String(24 + dayIndex).padStart(2, "0")}`, + ranges: dayIndex === 0 ? ranges : [], + note: "", + })), +}); + +describe("saveWeekSchema", () => { + test.each([ + { why: "a range ending when it starts", start: 600, end: 600 }, + { why: "a range ending before it starts", start: 600, end: 540 }, + { + why: "a range longer than a day", + start: 60, + end: 60 + DAY_MINUTES + 30, + }, + { why: "a range ending past the next day", start: 1380, end: 2881 }, + ])("rejects $why", ({ start, end }) => { + expect( + v.safeParse(saveWeekSchema, weekWith([{ start, end }])).success, + ).toBe(false); + }); + + test.each([ + { why: "a range within one day", start: 600, end: 720 }, + { why: "a range crossing midnight", start: 1380, end: 1500 }, + { why: "a range exactly a day long", start: 0, end: DAY_MINUTES }, + { + why: "the last minute a range can start", + start: DAY_MINUTES - 1, + end: DAY_MINUTES, + }, + ])("accepts $why", ({ start, end }) => { + expect( + v.safeParse(saveWeekSchema, weekWith([{ start, end }])).success, + ).toBe(true); + }); +}); diff --git a/app/features/availability/components/MySchedule.tsx b/app/features/availability/components/MySchedule.tsx index dc89b2611..bf2ed3eb3 100644 --- a/app/features/availability/components/MySchedule.tsx +++ b/app/features/availability/components/MySchedule.tsx @@ -35,10 +35,12 @@ export function MySchedule({ data }: { data: MyScheduleData }) { day: "numeric", }); - // dirty = the editor differs from what the loader last saw; a successful - // save revalidates the loader, which makes this read clean again. Edits - // survive same-route navigations (the view tabs), so only a pathname - // change or a full unload warns. + // dirty = the editor differs from what the loader last saw, or the day + // popover holds edits it has not committed yet; a successful save + // revalidates the loader, which makes this read clean again. Edits survive + // same-route navigations (the view tabs), so only a pathname change or a + // full unload warns. + const hasPendingDraftRef = React.useRef(false); const hasUnsavedChangesRef = React.useRef< Parameters[0]["current"] >(() => false); @@ -47,10 +49,11 @@ export function MySchedule({ data }: { data: MyScheduleData }) { (!navigation || navigation.currentLocation.pathname !== navigation.nextLocation.pathname) && - !R.isDeepEqual( - weeks, - data.weeks.map((editorWeek) => editorWeek.days), - ); + (hasPendingDraftRef.current || + !R.isDeepEqual( + weeks, + data.weeks.map((editorWeek) => editorWeek.days), + )); useUnsavedChangesChecker(hasUnsavedChangesRef); const weekIndex = week === "next" ? 1 : 0; @@ -129,6 +132,9 @@ export function MySchedule({ data }: { data: MyScheduleData }) { weeks.map((days, index) => (index === weekIndex ? value : days)), ) } + onPendingDraftChange={(hasPendingDraft) => { + hasPendingDraftRef.current = hasPendingDraft; + }} />
void; commitments?: Array; + /** Reports edits typed in the day popover but not yet committed into `value`, which an unsaved changes guard would otherwise miss. */ + onPendingDraftChange?: (hasPendingDraft: boolean) => void; }) { const { t } = useTranslation(["schedule", "common"]); const { formatter: dayFormatter } = useDateTimeFormat({ @@ -345,28 +349,26 @@ export function WeekAvailabilityEditor({ const openDayEditor = (date: string, anchor: HTMLElement, addRow = false) => { popoverAnchorRef.current = anchor; dayDraftRef.current = null; + onPendingDraftChange?.(false); setOpenDayDate(date); setOpenDayAddRow(addRow); }; + const dayFromDraft = (day: AvailabilityEditorDay, draft: DayDraft) => ({ + ...day, + ranges: Availability.mergedDayRanges( + draft.ranges + .filter((range) => range.start && range.end) + .map((range) => Availability.dayRangeFromTimes(range.start, range.end)), + ), + note: draft.note.trim(), + }); + const applyDayDraft = (date: string, draft: DayDraft) => { onChange( - value.map((day) => - day.date === date - ? { - ...day, - ranges: Availability.mergedDayRanges( - draft.ranges - .filter((range) => range.start && range.end) - .map((range) => - Availability.dayRangeFromTimes(range.start, range.end), - ), - ), - note: draft.note.trim(), - } - : day, - ), + value.map((day) => (day.date === date ? dayFromDraft(day, draft) : day)), ); + onPendingDraftChange?.(false); }; const closeDayEditor = () => { @@ -377,6 +379,7 @@ export function WeekAvailabilityEditor({ } dayDraftRef.current = null; + onPendingDraftChange?.(false); setOpenDayDate(null); }; @@ -631,6 +634,9 @@ export function WeekAvailabilityEditor({ startWithNewRow={openDayAddRow} onDraftChange={(draft) => { dayDraftRef.current = draft; + onPendingDraftChange?.( + !R.isDeepEqual(dayFromDraft(openDay, draft), openDay), + ); }} onRangeDelete={handleRangeDelete} /> diff --git a/app/features/availability/core/Availability.ts b/app/features/availability/core/Availability.ts index 8dcfd728f..19a242982 100644 --- a/app/features/availability/core/Availability.ts +++ b/app/features/availability/core/Availability.ts @@ -24,6 +24,7 @@ import type { } from "../availability-types"; const MINUTE_IN_SECONDS = 60; +const DAY_MINUTES = 24 * 60; /** * Database timestamp of the Monday 00:00 that starts the week `date` falls in, @@ -566,8 +567,6 @@ export function resizedRange({ return { start: range.start, end }; } -const DAY_MINUTES = 24 * 60; - const toTimeRange = (range: DayTimeRange): TimeRange => ({ startsAt: range.start, endsAt: range.end, diff --git a/app/features/availability/core/RegistrationAvailability.server.test.ts b/app/features/availability/core/RegistrationAvailability.server.test.ts new file mode 100644 index 000000000..f8939d9e8 --- /dev/null +++ b/app/features/availability/core/RegistrationAvailability.server.test.ts @@ -0,0 +1,89 @@ +import { addWeeks, subWeeks } from "date-fns"; +import { beforeEach, describe, expect, test } from "vitest"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { AVAILABILITY } from "../availability-constants"; +import * as Availability from "./Availability"; +import * as RegistrationAvailability from "./RegistrationAvailability.server"; + +const users = UserFactory.pool(); +const playerId = () => users.id(1); + +const TIMEZONE = "UTC"; +const HOUR = 60 * 60; +const DAY = 24 * HOUR; + +const weekStartsAtIn = (weeksFromNow: number) => + Availability.weekStartsAt(addWeeks(new Date(), weeksFromNow), TIMEZONE); + +const tournamentStartingAt = (startsAt: number) => ({ + id: 1, + name: "In The Zone", + organizationId: null, + startsAt, + minMembersPerTeam: 4, + bracketTypes: ["single_elimination" as const], + teamCount: 8, +}); + +const availabilityFor = (startsAt: number) => + RegistrationAvailability.registrationAvailability({ + tournament: tournamentStartingAt(startsAt), + userIds: [playerId()], + timezone: TIMEZONE, + }); + +describe("RegistrationAvailability.registrationAvailability", () => { + beforeEach(async () => { + await users.create(1); + }); + + test("computes nothing for a tournament past the reportable horizon", async () => { + const startsAt = weekStartsAtIn(AVAILABILITY.WEEK_HORIZON) + 18 * HOUR; + + const result = await availabilityFor(startsAt); + + expect(result.window).toBeNull(); + expect(result.entries).toBeNull(); + expect(result.beyondHorizon?.opensAt).toBe( + Availability.weekStartsAt( + subWeeks(databaseTimestampToDate(startsAt), 1), + TIMEZONE, + ), + ); + }); + + test("computes availability for a tournament on the last day still within the horizon", async () => { + const startsAt = weekStartsAtIn(AVAILABILITY.WEEK_HORIZON) - HOUR; + + const result = await availabilityFor(startsAt); + + expect(result.beyondHorizon).toBeNull(); + expect(result.window?.startsAt).toBe(startsAt); + expect(result.entries).toHaveLength(1); + }); + + test("returns only the day notes falling inside the tournament's window", async () => { + const weekStartsAt = weekStartsAtIn(1); + const startsAt = weekStartsAt + 2 * DAY + 18 * HOUR; + const dateOfDay = (dayIndex: number) => + Availability.dateInTimezone( + weekStartsAt + dayIndex * DAY + DAY / 2, + TIMEZONE, + ); + await AvailabilityWeekFactory.create({ + userId: playerId(), + weekStartsAt, + timezone: TIMEZONE, + dayNotes: [ + { date: dateOfDay(2), text: "Have to leave by 21" }, + { date: dateOfDay(5), text: "Away for the weekend" }, + ], + }); + + const result = await availabilityFor(startsAt); + + expect(result.entries?.[0].notes).toEqual(["Have to leave by 21"]); + }); +}); diff --git a/app/features/availability/routes/t.$customUrl.schedule.tsx b/app/features/availability/routes/t.$customUrl.schedule.tsx index 2b510fdb0..7cd07514b 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.tsx +++ b/app/features/availability/routes/t.$customUrl.schedule.tsx @@ -8,10 +8,6 @@ import * as R from "remeda"; import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; import { SendouButton } from "~/components/elements/Button"; -import { - SendouChipRadio, - SendouChipRadioGroup, -} from "~/components/elements/ChipRadio"; import { SendouDialog } from "~/components/elements/Dialog"; import { FormMessage } from "~/components/FormMessage"; import { UserLink } from "~/components/UserLink"; @@ -33,6 +29,7 @@ import { } from "../availability-schemas"; import { scheduleWeekSearchParams } from "../availability-search-params"; import { ScheduleDayCell } from "../components/ScheduleDayCell"; +import { WeekToggle } from "../components/WeekToggle"; import type { TeamScheduleLoaderData } from "../loaders/t.$customUrl.schedule.server"; import { loader } from "../loaders/t.$customUrl.schedule.server"; @@ -89,24 +86,11 @@ function ScheduleWeeks({ weeks }: { weeks: Array }) { shownWeek.days[6].noonAt, )} - - setParams({ week: "current" })} - > - {t("schedule:team.currentWeek")} - - setParams({ week: "next" })} - > - {t("schedule:team.nextWeek")} - - + setParams({ week: value })} + />
diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index d299a234c..0d5d7771f 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -248,7 +248,6 @@ export interface PickableSlot extends TimeRange { * The roster's shared free time as the slots a scrim post can be picked from: * maximal spans where the team is at most one player short, the `ONE_SHORT` * ones being the "grab a sub" case. - * be given. */ export function pickableSlots({ members, diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 1d9f17c95..231ea0371 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -1,4 +1,5 @@ import type { ActionFunction } from "react-router"; +import * as R from "remeda"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; @@ -18,7 +19,7 @@ import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLF import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseFormDataWithImages } from "~/form/parse.server"; import { logger } from "~/utils/logger"; -import { errorToastIfFalsy } from "~/utils/remix.server"; +import { errorToastIfFalsy, successToast } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; import { registerSchema } from "../tournament-schemas.server"; @@ -348,20 +349,18 @@ export const action: ActionFunction = async ({ request, params }) => { tournament.maxMembersPerTeam - ownTeam.memberUserIds.length; let addedCount = 0; + const skippedReasons: Array = []; for (const candidate of candidates) { if (addedCount >= spotsLeft) break; - const eligible = - (await UserRepository.findLeanById(candidate.id))?.friendCode && - !(await isBannedByOrganization({ - tournament, - userId: candidate.id, - })) && - (await fulfillsSendouQParticipation({ - tournament, - userId: candidate.id, - })); - if (!eligible) continue; + const reason = await ineligibleReason({ + tournament, + userId: candidate.id, + }); + if (reason) { + skippedReasons.push(reason); + continue; + } await addPlayerToOwnTeam({ tournament, @@ -373,10 +372,19 @@ export const action: ActionFunction = async ({ request, params }) => { addedCount++; } - errorToastIfFalsy(addedCount > 0, "No players could be added"); + errorToastIfFalsy( + addedCount > 0, + `No players could be added. ${skippedSummary(skippedReasons)}`.trim(), + ); await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); + if (skippedReasons.length > 0) { + return successToast( + `Added ${addedCount} player(s). ${skippedSummary(skippedReasons)}`, + ); + } + break; } case "UNREGISTER": { @@ -419,6 +427,43 @@ export const action: ActionFunction = async ({ request, params }) => { return null; }; +type IneligibleReason = + | "no friend code" + | "banned by the organization" + | "not enough SendouQ participation"; + +/** Why the "add all" bulk add has to pass a candidate over, or `null` if they can be added. */ +async function ineligibleReason({ + tournament, + userId, +}: { + tournament: Tournament; + userId: number; +}): Promise { + if (!(await UserRepository.findLeanById(userId))?.friendCode) { + return "no friend code"; + } + if (await isBannedByOrganization({ tournament, userId })) { + return "banned by the organization"; + } + if (!(await fulfillsSendouQParticipation({ tournament, userId }))) { + return "not enough SendouQ participation"; + } + + return null; +} + +// names are left out on purpose: the message travels in a redirect's query string +function skippedSummary(reasons: Array) { + if (reasons.length === 0) return ""; + + const counts = R.countBy(reasons, (reason) => reason); + + return `Skipped ${reasons.length} player(s): ${Object.entries(counts) + .map(([reason, count]) => `${reason} (${count})`) + .join(", ")}`; +} + async function addPlayerToOwnTeam({ tournament, tournamentId, diff --git a/e2e/friends.spec.ts b/e2e/friends.spec.ts index b8259ff79..13ab4baf7 100644 --- a/e2e/friends.spec.ts +++ b/e2e/friends.spec.ts @@ -1,6 +1,7 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import * as Availability from "~/features/availability/core/Availability"; +import { weekDates, weekRange } from "./helpers/availability"; import { expect, impersonate, @@ -18,7 +19,6 @@ import { FriendsPage } from "./pages/friends/friends-page"; import { NotificationPopover } from "./pages/layout/notification-popover"; const WEDNESDAY = 2; -const DAY_SECONDS = 24 * 60 * 60; test.describe("Friends", () => { test("send friend request, accept it, then delete friend", async ({ @@ -75,7 +75,7 @@ test.describe("Friends", () => { await factories.AvailabilityWeekFactory.create({ userId: scheduled.id, - weekStartsAt: currentWeek().startsAt, + weekStartsAt: weekRange().startsAt, timezone: MACHINE_TIMEZONE, slots: [daySlot(WEDNESDAY, "18:00", "22:00")], }); @@ -116,23 +116,8 @@ test.describe("Friends", () => { }); }); -function currentWeek() { - return Availability.weekRange(new Date(), MACHINE_TIMEZONE); -} - -function currentWeekDates() { - const { startsAt } = currentWeek(); - - return Array.from({ length: 7 }, (_, dayIndex) => - Availability.dateInTimezone( - startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, - MACHINE_TIMEZONE, - ), - ); -} - function daySlot(dayIndex: number, start: string, end: string) { - const date = currentWeekDates()[dayIndex]; + const date = weekDates()[dayIndex]; return { startsAt: Availability.localToTimestamp({ diff --git a/e2e/helpers/availability.ts b/e2e/helpers/availability.ts new file mode 100644 index 000000000..5eaa9a0e4 --- /dev/null +++ b/e2e/helpers/availability.ts @@ -0,0 +1,22 @@ +import * as R from "remeda"; +import * as Availability from "~/features/availability/core/Availability"; +import { MACHINE_TIMEZONE } from "./playwright"; + +const DAY_SECONDS = 24 * 60 * 60; + +/** The week `date` (default: now) falls in, as the test machine's timezone sees it. */ +export function weekRange(date = new Date()) { + return Availability.weekRange(date, MACHINE_TIMEZONE); +} + +/** The seven `YYYY-MM-DD` dates of the week `date` (default: now) falls in. */ +export function weekDates(date = new Date()) { + const { startsAt } = weekRange(date); + + return R.range(0, 7).map((dayIndex) => + Availability.dateInTimezone( + startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + MACHINE_TIMEZONE, + ), + ); +} diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 0c4b98a14..ffe0c4400 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -3,6 +3,7 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants"; import { addTeamEventSchema } from "~/features/availability/availability-schemas"; import * as Availability from "~/features/availability/core/Availability"; +import { weekDates, weekRange } from "./helpers/availability"; import type { Factories } from "./helpers/factories"; import { expect, @@ -29,7 +30,6 @@ const ROSTER_SIZE = 4; const TOURNAMENT_NAME = "In The Zone 30"; const WEDNESDAY = 2; const THURSDAY = 3; -const DAY_SECONDS = 24 * 60 * 60; test.describe("New team creation", () => { test("creates new team", async ({ page }) => { @@ -429,7 +429,7 @@ test.describe("Team schedule", () => { memberUserIds: [ADMIN_ID, NZAP_TEST_ID, noScheduleMember.id], }); - const { startsAt } = currentWeek(); + const { startsAt } = weekRange(); await factories.AvailabilityWeekFactory.create({ userId: ADMIN_ID, weekStartsAt: startsAt, @@ -441,9 +441,7 @@ test.describe("Team schedule", () => { daySlot(WEDNESDAY, "18:00", "22:00"), daySlot(THURSDAY, "00:30", "02:00"), ], - dayNotes: [ - { date: currentWeekDates()[WEDNESDAY], text: "Leaving early" }, - ], + dayNotes: [{ date: weekDates()[WEDNESDAY], text: "Leaving early" }], }); await factories.AvailabilityWeekFactory.create({ userId: NZAP_TEST_ID, @@ -502,7 +500,7 @@ test.describe("Team schedule", () => { }); await factories.AvailabilityWeekFactory.create({ userId: ADMIN_ID, - weekStartsAt: currentWeek().startsAt, + weekStartsAt: weekRange().startsAt, timezone: MACHINE_TIMEZONE, slots: [daySlot(WEDNESDAY, "18:00", "22:00")], }); @@ -558,31 +556,9 @@ test.describe("Team schedule", () => { }); }); -function currentWeek() { - return Availability.weekRange(new Date(), MACHINE_TIMEZONE); -} - -function currentWeekDates() { - const { startsAt } = currentWeek(); - - return Array.from({ length: 7 }, (_, dayIndex) => - Availability.dateInTimezone( - startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, - MACHINE_TIMEZONE, - ), - ); -} - /** Wall-clock time on a day of next week, always ahead of "now" so the add-event form accepts it. */ function nextWeekTime(dayIndex: number, time: string) { - const { startsAt } = Availability.weekRange( - addWeeks(new Date(), 1), - MACHINE_TIMEZONE, - ); - const date = Availability.dateInTimezone( - startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, - MACHINE_TIMEZONE, - ); + const date = weekDates(addWeeks(new Date(), 1))[dayIndex]; return new Date( Availability.localToTimestamp({ date, time, timezone: MACHINE_TIMEZONE }) * @@ -591,7 +567,7 @@ function nextWeekTime(dayIndex: number, time: string) { } function daySlot(dayIndex: number, start: string, end: string) { - const dates = currentWeekDates(); + const dates = weekDates(); return { startsAt: Availability.localToTimestamp({ diff --git a/locales/en/common.json b/locales/en/common.json index 86b9250c2..27ec78f9f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -111,7 +111,7 @@ "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", + "notifications.text.SCHEDULE_TEAM_REMINDER": "Your teammates are waiting for you to fill in 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.", diff --git a/locales/en/schedule.json b/locales/en/schedule.json index 7db96627d..4e899731a 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -24,7 +24,7 @@ "registration.estimated": "estimated", "registration.notVisible": "Schedule not shared with you", "registration.friends": "Friends", - "registration.beyondHorizon": "Schedules for that week open {{date}}", + "registration.beyondHorizon": "Schedules for that week open on {{date}}", "registration.summary.available": "{{amount}} available", "registration.summary.partial": "{{amount}} partial", "registration.summary.out": "{{amount}} out", @@ -38,7 +38,7 @@ "team.noWindows": "No shared free time", "team.weekHeading": "Week {{week}}", "team.withSub": "With a sub ({{players}})", - "picker.title": "Pick a start from your schedule", + "picker.title": "Pick a start time from your team's schedule", "picker.free": "{{amount}} free", "picker.noSchedule": "No schedule this week: {{users}}", "picker.andOthers": "{{amount}} more", diff --git a/migrations/20260822034109-availability.ts b/migrations/20260822034109-availability.ts index b7bb12a9b..facbc6381 100644 --- a/migrations/20260822034109-availability.ts +++ b/migrations/20260822034109-availability.ts @@ -65,7 +65,7 @@ export async function up(db: Kysely): Promise { // the event belongs to the team, so it outlives its author's account, // the way every other authored row of the schema does .addColumn("authorId", "integer", (col) => - col.notNull().references("User.id").onDelete("restrict"), + col.references("User.id").onDelete("set null"), ) .addColumn("name", "text", (col) => col.notNull()) .addColumn("startsAt", "integer", (col) => col.notNull()) diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index badcaec60..54e184c75 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -5,6 +5,7 @@ import * as ApiRepository from "~/features/api/ApiRepository.server"; import * as ArtRepository from "~/features/art/ArtRepository.server"; import * as AssociationRepository from "~/features/associations/AssociationRepository.server"; import * as LogInLinkRepository from "~/features/auth/LogInLinkRepository.server"; +import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; @@ -159,6 +160,66 @@ export function buildCases(fx: Fixtures): { LogInLinkRepository.findValidByCode(code), ); + // AvailabilityRepository + add( + "AvailabilityRepository.findAllWeeksByUserIds", + both(fx.manyUserIds, fx.availabilityWindow), + ([userIds, window]) => + AvailabilityRepository.findAllWeeksByUserIds({ + userIds, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "AvailabilityRepository.hasReportedWeek", + both(fx.heavyUser, fx.availabilityWindow), + ([user, window]) => + AvailabilityRepository.hasReportedWeek({ + userId: user.id, + weekStartsAt: window.weekStartsAt, + }), + ); + add( + "AvailabilityRepository.findWeekReminderUserIds", + fx.availabilityWindow, + (window) => + AvailabilityRepository.findWeekReminderUserIds(window.weekStartsAt), + ); + add( + "AvailabilityRepository.findAllTeamEventsByUserIds", + both(fx.manyUserIds, fx.availabilityWindow), + ([userIds, window]) => + AvailabilityRepository.findAllTeamEventsByUserIds({ + userIds, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "AvailabilityRepository.findTeamEventsByTeamId", + both(fx.heavyTeam, fx.availabilityWindow), + ([team, window]) => + AvailabilityRepository.findTeamEventsByTeamId({ + teamId: team.id, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "AvailabilityRepository.findAllUpcomingTeamEventsByUserId", + both(fx.heavyTeam, fx.availabilityWindow), + ([team, window]) => + AvailabilityRepository.findAllUpcomingTeamEventsByUserId({ + userId: team.memberUserId, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add("AvailabilityRepository.findTeamEventById", fx.teamEventId, (id) => + AvailabilityRepository.findTeamEventById(id), + ); + // BadgeRepository addStatic("BadgeRepository.findAll", () => BadgeRepository.findAll()); add("BadgeRepository.findById", fx.heavyBadgeId, (badgeId) => @@ -626,6 +687,16 @@ export function buildCases(fx: Fixtures): { add("ScrimPostRepository.findUserScrims", fx.scrimUserIds, (userIds) => ScrimPostRepository.findUserScrims(userIds[0]), ); + add( + "ScrimPostRepository.findAllAcceptedByUserIds", + both(fx.scrimUserIds, fx.scrimWindow), + ([userIds, window]) => + ScrimPostRepository.findAllAcceptedByUserIds({ + userIds, + startsAt: dateToDatabaseTimestamp(window.startTime), + endsAt: dateToDatabaseTimestamp(window.endTime), + }), + ); // GroupMatchContinueVoteRepository add( @@ -1236,6 +1307,16 @@ export function buildCases(fx: Fixtures): { fx.tournamentTeamPair, (teamIds) => TournamentTeamRepository.findMapPoolsByTeamIds(teamIds), ); + add( + "TournamentTeamRepository.findAllRegistrationsByUserIds", + both(fx.manyUserIds, fx.availabilityWindow), + ([userIds, window]) => + TournamentTeamRepository.findAllRegistrationsByUserIds({ + userIds, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); add( "TournamentTeamRepository.isOrganizerAddedMember", both(fx.heavyTournamentTeamId, fx.heavyUser), diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts index 1cc8d857f..beb65af01 100644 --- a/scripts/benchmark-db/fixtures.ts +++ b/scripts/benchmark-db/fixtures.ts @@ -1,7 +1,8 @@ -import { sub } from "date-fns"; +import { addWeeks, sub } from "date-fns"; import { sql } from "kysely"; import { db } from "~/db/sql"; import type { Tables } from "~/db/tables"; +import * as Availability from "~/features/availability/core/Availability"; import * as ChatRepository from "~/features/chat/ChatRepository.server"; import type { ChatRoomType } from "~/features/chat/chat-types"; import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils"; @@ -60,6 +61,13 @@ export interface Fixtures { calendarAuthorId: number | null; calendarWindow: { startTime: Date; endTime: Date } | null; scrimWindow: { startTime: Date; endTime: Date } | null; + /** The horizon availability reads cover: the current week's start, and the current-plus-next week as a range. */ + availabilityWindow: { + weekStartsAt: number; + startsAt: number; + endsAt: number; + } | null; + teamEventId: number | null; heavyScrimPostId: number | null; scrimUserIds: number[] | null; heavyOrg: { @@ -174,6 +182,8 @@ export async function resolveFixtures(): Promise { calendarAuthorId: await resolveCalendarAuthorId(), calendarWindow: await resolveCalendarWindow(), scrimWindow: await resolveScrimWindow(), + availabilityWindow: resolveAvailabilityWindow(), + teamEventId: await resolveTeamEventId(), heavyScrimPostId, scrimUserIds: await resolveScrimUserIds(heavyScrimPostId, heavyUser), heavyOrg: await resolveHeavyOrg(), @@ -1423,6 +1433,27 @@ async function resolveScannerIngestSendouq() { }; } +function resolveAvailabilityWindow() { + const current = Availability.weekRange(new Date(), "UTC"); + + return { + weekStartsAt: current.startsAt, + startsAt: current.startsAt, + endsAt: Availability.weekRange(addWeeks(new Date(), 1), "UTC").endsAt, + }; +} + +async function resolveTeamEventId() { + const row = await db + .selectFrom("TeamEvent") + .select("id") + .orderBy("startsAt", "desc") + .limit(1) + .executeTakeFirst(); + + return row?.id ?? null; +} + async function resolveCastedTournamentId() { const row = await db .selectFrom("Tournament")