mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 12:16:12 -05:00
Fixes
This commit is contained in:
@@ -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;
|
||||
|
||||
85
app/features/availability/actions/events.server.test.ts
Normal file
85
app/features/availability/actions/events.server.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
47
app/features/availability/availability-schemas.test.ts
Normal file
47
app/features/availability/availability-schemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
22
e2e/helpers/availability.ts
Normal file
22
e2e/helpers/availability.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user