This commit is contained in:
Kalle
2026-08-29 12:50:35 +03:00
parent a39213ecc6
commit a101cb35a5
18 changed files with 471 additions and 116 deletions

View File

@@ -1376,8 +1376,8 @@ export interface AvailabilityDayNote {
export interface TeamEvent {
id: GeneratedAlways<number>;
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;

View File

@@ -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<typeof saveWeekSchema>({
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");
});
});

View File

@@ -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);
});
});

View File

@@ -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<typeof useUnsavedChangesChecker>[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;
}}
/>
<div className={styles.actions}>
<SendouButton

View File

@@ -2,6 +2,7 @@ import clsx from "clsx";
import { Flag, Plus, SquarePen, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { SendouAnchoredPopover } from "~/components/elements/Popover";
import { Input } from "~/components/Input";
@@ -79,10 +80,13 @@ export function WeekAvailabilityEditor({
value,
onChange,
commitments = [],
onPendingDraftChange,
}: {
value: AvailabilityEditorWeek;
onChange: (value: AvailabilityEditorWeek) => void;
commitments?: Array<EditorCommitment>;
/** 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}
/>

View File

@@ -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,

View File

@@ -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"]);
});
});

View File

@@ -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<WeekData> }) {
shownWeek.days[6].noonAt,
)}
</h2>
<SendouChipRadioGroup>
<SendouChipRadio
name="schedule-week"
value="current"
checked={week === "current"}
onChange={() => setParams({ week: "current" })}
>
{t("schedule:team.currentWeek")}
</SendouChipRadio>
<SendouChipRadio
name="schedule-week"
value="next"
checked={week === "next"}
onChange={() => setParams({ week: "next" })}
>
{t("schedule:team.nextWeek")}
</SendouChipRadio>
</SendouChipRadioGroup>
<WeekToggle
name="schedule-week"
value={week}
onChange={(value) => setParams({ week: value })}
/>
</div>
<TeamEvents week={shownWeek} />
<ScheduleGrid week={shownWeek} />

View File

@@ -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,

View File

@@ -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<IneligibleReason> = [];
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<IneligibleReason | null> {
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<IneligibleReason>) {
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,

View File

@@ -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({

View File

@@ -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,
),
);
}

View File

@@ -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({

View File

@@ -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.",

View File

@@ -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",

View File

@@ -65,7 +65,7 @@ export async function up(db: Kysely<any>): Promise<void> {
// 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())

View File

@@ -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),

View File

@@ -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<Fixtures> {
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")