mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 14:18:04 -05:00
My schedule
This commit is contained in:
@@ -169,7 +169,9 @@ export async function seedAvailability({
|
||||
{
|
||||
userId: teams.allianceRogue.subUserId,
|
||||
timezone: "Europe/Helsinki",
|
||||
weekly: [[], [["18:00", "22:00"]], [["18:00", "22:00"]], [], [], [], []],
|
||||
// Wednesday ends exactly at midnight, the shape the drag editor
|
||||
// produces when a bar is pulled to the 00:00 tick
|
||||
weekly: [[], [["18:00", "22:00"]], [["18:00", "00:00"]], [], [], [], []],
|
||||
fillsNextWeek: true,
|
||||
},
|
||||
{
|
||||
|
||||
70
app/features/availability/actions/events.server.ts
Normal file
70
app/features/availability/actions/events.server.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { addWeeks } from "date-fns";
|
||||
import type { ActionFunction } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import * as AvailabilityRepository from "../AvailabilityRepository.server";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import { saveWeekSchema } from "../availability-schemas";
|
||||
import * as Availability from "../core/Availability";
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
requireUser();
|
||||
|
||||
const data = await parseRequestPayload({ request, schema: saveWeekSchema });
|
||||
const timezone = getViewerTimezone() ?? "UTC";
|
||||
|
||||
const weekStartsAt = Availability.localToTimestamp({
|
||||
date: data.days[0].date,
|
||||
time: "00:00",
|
||||
timezone,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
errorToastIfFalsy(
|
||||
R.range(0, AVAILABILITY.WEEK_HORIZON).some(
|
||||
(weekOffset) =>
|
||||
Availability.weekStartsAt(addWeeks(now, weekOffset), timezone) ===
|
||||
weekStartsAt,
|
||||
),
|
||||
"Only the current and the next week can be saved",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
data.days.every(
|
||||
(day, dayIndex) =>
|
||||
day.date ===
|
||||
Availability.dateInTimezone(
|
||||
weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
|
||||
timezone,
|
||||
),
|
||||
),
|
||||
"Days do not form one week",
|
||||
);
|
||||
|
||||
await AvailabilityRepository.upsertOwnWeek({
|
||||
weekStartsAt,
|
||||
timezone,
|
||||
slots: data.days.flatMap((day) =>
|
||||
day.ranges.map((range) => ({
|
||||
startsAt: Availability.dayMinutesToTimestamp({
|
||||
date: day.date,
|
||||
minutes: range.start,
|
||||
timezone,
|
||||
}),
|
||||
endsAt: Availability.dayMinutesToTimestamp({
|
||||
date: day.date,
|
||||
minutes: range.end,
|
||||
timezone,
|
||||
}),
|
||||
})),
|
||||
),
|
||||
dayNotes: data.days.flatMap((day) =>
|
||||
day.note ? [{ date: day.date, text: day.note }] : [],
|
||||
),
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -10,6 +10,8 @@ export const AVAILABILITY = {
|
||||
WEEK_HORIZON: 2,
|
||||
/** Weeks whose end is further in the past than this are deleted. */
|
||||
RETENTION_MONTHS: 3,
|
||||
/** A reported week belongs to a viewer week when their starts are closer than this — timezones set them apart by hours, never by days. */
|
||||
WEEK_MATCH_MAX_DISTANCE_SECONDS: 3.5 * 24 * 60 * 60,
|
||||
/** Left edge of the editor's clock window (14:00) — evenings are when people play. */
|
||||
TRACK_START_MINUTES: 14 * 60,
|
||||
/** Left edge of the clock window with the earlier-hours expander open (06:00). */
|
||||
|
||||
43
app/features/availability/availability-schemas.ts
Normal file
43
app/features/availability/availability-schemas.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as v from "valibot";
|
||||
import { _action } from "~/utils/schema";
|
||||
import { AVAILABILITY } from "./availability-constants";
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
const MAX_RANGES_PER_DAY = 24;
|
||||
|
||||
const dayTimeRangeSchema = v.pipe(
|
||||
v.object({
|
||||
start: v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(0),
|
||||
v.maxValue(DAY_MINUTES - 1),
|
||||
),
|
||||
end: v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(1),
|
||||
v.maxValue(2 * DAY_MINUTES),
|
||||
),
|
||||
}),
|
||||
v.check((range) => range.end > range.start, "Range must end after it starts"),
|
||||
v.check(
|
||||
(range) => range.end - range.start <= DAY_MINUTES,
|
||||
"Range must be at most a day long",
|
||||
),
|
||||
);
|
||||
|
||||
const editorDaySchema = v.object({
|
||||
date: v.pipe(v.string(), v.isoDate()),
|
||||
ranges: v.pipe(v.array(dayTimeRangeSchema), v.maxLength(MAX_RANGES_PER_DAY)),
|
||||
note: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.maxLength(AVAILABILITY.DAY_NOTE_MAX_LENGTH),
|
||||
),
|
||||
});
|
||||
|
||||
export const saveWeekSchema = v.object({
|
||||
_action: _action("SAVE_WEEK"),
|
||||
days: v.pipe(v.array(editorDaySchema), v.length(7)),
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, test } from "vitest";
|
||||
import { assertRoundTrips } from "~/modules/search-params/search-params-test-utils";
|
||||
import { teamScheduleSearchParams } from "./availability-search-params";
|
||||
import { scheduleWeekSearchParams } from "./availability-search-params";
|
||||
|
||||
describe("teamScheduleSearchParams", () => {
|
||||
describe("scheduleWeekSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(teamScheduleSearchParams, {
|
||||
assertRoundTrips(scheduleWeekSearchParams, {
|
||||
week: ["current", "next"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const teamScheduleSearchParams = SearchParams.define({
|
||||
export const scheduleWeekSearchParams = SearchParams.define({
|
||||
week: SP.param(v.picklist(["current", "next"]), {
|
||||
default: "current",
|
||||
loader: false,
|
||||
|
||||
27
app/features/availability/components/MySchedule.module.css
Normal file
27
app/features/availability/components/MySchedule.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.weekHeading {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
margin-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.notFilled {
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
font-size: var(--font-2xs);
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
189
app/features/availability/components/MySchedule.tsx
Normal file
189
app/features/availability/components/MySchedule.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FetcherWithComponents } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import {
|
||||
SendouChipRadio,
|
||||
SendouChipRadioGroup,
|
||||
} from "~/components/elements/ChipRadio";
|
||||
import { toastQueue } from "~/components/elements/Toast";
|
||||
import { useUnsavedChangesChecker } from "~/form/UnsavedChangesGuard";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useActionSubmit } from "~/hooks/useActionSubmit";
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import { saveWeekSchema } from "../availability-schemas";
|
||||
import { scheduleWeekSearchParams } from "../availability-search-params";
|
||||
import type { AvailabilityEditorWeek } from "../availability-types";
|
||||
import type { MyScheduleData } from "../core/MySchedule.server";
|
||||
import styles from "./MySchedule.module.css";
|
||||
import { WeekAvailabilityEditor } from "./WeekAvailabilityEditor";
|
||||
|
||||
const WEEK_VALUES = ["current", "next"] as const;
|
||||
|
||||
/**
|
||||
* The "My schedule" section of the events page: the schedule editor with a
|
||||
* current/next week toggle, "Copy last week" prefill and the save action.
|
||||
*/
|
||||
export function MySchedule({ data }: { data: MyScheduleData }) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams);
|
||||
const [weeks, setWeeks] = React.useState<Array<AvailabilityEditorWeek>>(() =>
|
||||
data.weeks.map((editorWeek) => editorWeek.days),
|
||||
);
|
||||
const { submit, fetcher, state } = useActionSubmit(saveWeekSchema, {
|
||||
encType: "application/json",
|
||||
});
|
||||
useSavedToast(fetcher);
|
||||
const { formatter: headingFormatter } = useDateTimeFormat({
|
||||
month: "short",
|
||||
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.
|
||||
const hasUnsavedChangesRef = React.useRef<
|
||||
Parameters<typeof useUnsavedChangesChecker>[0]["current"]
|
||||
>(() => false);
|
||||
hasUnsavedChangesRef.current = (navigation) =>
|
||||
fetcher.state === "idle" &&
|
||||
(!navigation ||
|
||||
navigation.currentLocation.pathname !==
|
||||
navigation.nextLocation.pathname) &&
|
||||
!R.isDeepEqual(
|
||||
weeks,
|
||||
data.weeks.map((editorWeek) => editorWeek.days),
|
||||
);
|
||||
useUnsavedChangesChecker(hasUnsavedChangesRef);
|
||||
|
||||
const weekIndex = week === "next" ? 1 : 0;
|
||||
const shownDays = weeks[weekIndex];
|
||||
|
||||
const copySourceRanges =
|
||||
weekIndex === 0 ? data.lastWeekRanges : weeks[0].map((day) => day.ranges);
|
||||
const canCopy =
|
||||
copySourceRanges?.some((ranges) => ranges.length > 0) ?? false;
|
||||
|
||||
const copyPreviousWeek = () => {
|
||||
if (!copySourceRanges) return;
|
||||
|
||||
setWeeks(
|
||||
weeks.map((days, index) =>
|
||||
index === weekIndex
|
||||
? days.map((day, dayIndex) => ({
|
||||
...day,
|
||||
ranges: copySourceRanges[dayIndex],
|
||||
}))
|
||||
: days,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const saveWeek = () => {
|
||||
submit("SAVE_WEEK", {
|
||||
days: shownDays.map((day) => ({
|
||||
date: day.date,
|
||||
ranges: day.ranges,
|
||||
note: day.note,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack sm" data-testid="my-schedule">
|
||||
<div className={styles.header}>
|
||||
<h2 className="text-lg mx-2">{t("schedule:editor.title")}</h2>
|
||||
<SendouChipRadioGroup>
|
||||
{WEEK_VALUES.map((value, index) => (
|
||||
<SendouChipRadio
|
||||
key={value}
|
||||
name="my-schedule-week"
|
||||
value={value}
|
||||
checked={weekIndex === index}
|
||||
onChange={() => setParams({ week: value })}
|
||||
>
|
||||
<span>
|
||||
{index === 0
|
||||
? t("schedule:team.currentWeek")
|
||||
: t("schedule:team.nextWeek")}
|
||||
{!data.weeks[index].submitted ? (
|
||||
<span
|
||||
className={styles.notFilled}
|
||||
data-testid={`week-not-filled-${value}`}
|
||||
>
|
||||
• {t("schedule:editor.notFilled")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SendouChipRadio>
|
||||
))}
|
||||
</SendouChipRadioGroup>
|
||||
</div>
|
||||
<h3 className={styles.weekHeading}>
|
||||
{t("schedule:team.weekHeading", {
|
||||
week: data.weeks[weekIndex].weekNumber,
|
||||
})}{" "}
|
||||
·{" "}
|
||||
{headingFormatter.formatRange(
|
||||
dateAtNoon(shownDays[0].date),
|
||||
dateAtNoon(shownDays[6].date),
|
||||
)}
|
||||
</h3>
|
||||
<WeekAvailabilityEditor
|
||||
key={data.weeks[weekIndex].weekStartsAt}
|
||||
value={shownDays}
|
||||
onChange={(value) =>
|
||||
setWeeks(
|
||||
weeks.map((days, index) => (index === weekIndex ? value : days)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
isDisabled={!canCopy}
|
||||
onPress={copyPreviousWeek}
|
||||
testId="copy-last-week-button"
|
||||
>
|
||||
{t("schedule:editor.copyLastWeek")}
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
isDisabled={state !== "idle"}
|
||||
onPress={saveWeek}
|
||||
testId="save-week-button"
|
||||
>
|
||||
{t("schedule:editor.saveWeek")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function useSavedToast(fetcher: FetcherWithComponents<unknown>) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const previousStateRef = React.useRef(fetcher.state);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
previousStateRef.current !== "idle" &&
|
||||
fetcher.state === "idle" &&
|
||||
fetcher.data === null
|
||||
) {
|
||||
toastQueue.add({
|
||||
message: t("schedule:editor.saved"),
|
||||
variant: "success",
|
||||
});
|
||||
}
|
||||
previousStateRef.current = fetcher.state;
|
||||
}, [fetcher.state, fetcher.data, t]);
|
||||
}
|
||||
|
||||
function dateAtNoon(date: string) {
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
|
||||
return new Date(year, month - 1, day, 12);
|
||||
}
|
||||
@@ -368,33 +368,53 @@ export function WeekAvailabilityEditor({
|
||||
setOpenDayAddRow(addRow);
|
||||
};
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const closeDayEditor = () => {
|
||||
const draft = dayDraftRef.current;
|
||||
|
||||
if (draft && openDayDate) {
|
||||
onChange(
|
||||
value.map((day) =>
|
||||
day.date === openDayDate
|
||||
? {
|
||||
...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,
|
||||
),
|
||||
);
|
||||
applyDayDraft(openDayDate, draft);
|
||||
}
|
||||
|
||||
dayDraftRef.current = null;
|
||||
setOpenDayDate(null);
|
||||
};
|
||||
|
||||
// deleting commits right away so the bar disappears as the button is
|
||||
// pressed; once no ranges are left the popover has nothing to edit and
|
||||
// closes too
|
||||
const handleRangeDelete = (draft: DayDraft) => {
|
||||
if (!openDayDate) return;
|
||||
|
||||
applyDayDraft(openDayDate, draft);
|
||||
|
||||
if (draft.ranges.every((range) => !range.start || !range.end)) {
|
||||
dayDraftRef.current = null;
|
||||
setOpenDayDate(null);
|
||||
} else {
|
||||
dayDraftRef.current = draft;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBarClick = (
|
||||
date: string,
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
@@ -426,7 +446,12 @@ export function WeekAvailabilityEditor({
|
||||
gesture && gesture.type !== "fill" && gesture.dayIndex === dayIndex
|
||||
? gesture
|
||||
: null;
|
||||
const liveRange = dayGesture?.range ?? null;
|
||||
// a plain click on a bar starts a move gesture too; the live time label
|
||||
// only belongs to an actual drag, not to the click opening the popover
|
||||
const liveRange =
|
||||
dayGesture?.type === "move" && !dayGesture.moved
|
||||
? null
|
||||
: (dayGesture?.range ?? null);
|
||||
const fillPreview =
|
||||
gesture?.type === "fill" &&
|
||||
gesture.dayIndex !== dayIndex &&
|
||||
@@ -485,6 +510,7 @@ export function WeekAvailabilityEditor({
|
||||
key={`${range.start}-${range.end}`}
|
||||
className={styles.bar}
|
||||
style={barStyle(shown)}
|
||||
data-testid="availability-bar"
|
||||
aria-label={`${t("schedule:editor.editDay", {
|
||||
day: dayLabelText(day),
|
||||
})} (${rangeText(day.date, range)})`}
|
||||
@@ -548,6 +574,7 @@ export function WeekAvailabilityEditor({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.editButton}
|
||||
data-testid={`availability-day-edit-${dayIndex}`}
|
||||
aria-label={t("schedule:editor.editDay", { day: dayLabelText(day) })}
|
||||
onClick={(event) => openDayEditor(day.date, event.currentTarget)}
|
||||
>
|
||||
@@ -673,6 +700,7 @@ export function WeekAvailabilityEditor({
|
||||
onDraftChange={(draft) => {
|
||||
dayDraftRef.current = draft;
|
||||
}}
|
||||
onRangeDelete={handleRangeDelete}
|
||||
/>
|
||||
</SendouAnchoredPopover>
|
||||
) : null}
|
||||
@@ -685,12 +713,15 @@ function DayEditor({
|
||||
dayLabel,
|
||||
startWithNewRow,
|
||||
onDraftChange,
|
||||
onRangeDelete,
|
||||
}: {
|
||||
day: AvailabilityEditorDay;
|
||||
dayLabel: string;
|
||||
/** Opens with an empty row already appended, for an "add time" entry point. */
|
||||
startWithNewRow: boolean;
|
||||
onDraftChange: (draft: DayDraft) => void;
|
||||
/** Called with the remaining draft after a range row is deleted — deletes commit instantly instead of waiting for the popover to close. */
|
||||
onRangeDelete: (draft: DayDraft) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule", "common", "forms"]);
|
||||
const noteId = React.useId();
|
||||
@@ -744,12 +775,11 @@ function DayEditor({
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
aria-label={t("common:actions.delete")}
|
||||
onPress={() =>
|
||||
update(
|
||||
ranges.filter((other) => other.id !== range.id),
|
||||
note,
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
const remaining = ranges.filter((other) => other.id !== range.id);
|
||||
update(remaining, note);
|
||||
onRangeDelete({ ranges: remaining, note });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -81,6 +81,56 @@ describe("Availability.dateInTimezone", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.dayMinutesToTimestamp", () => {
|
||||
test("matches localToTimestamp for a same-day time", () => {
|
||||
expect(
|
||||
Availability.dayMinutesToTimestamp({
|
||||
date: "2026-08-25",
|
||||
minutes: 18 * 60 + 30,
|
||||
timezone: HELSINKI,
|
||||
}),
|
||||
).toBe(at("2026-08-25", "18:30"));
|
||||
});
|
||||
|
||||
test("rolls minutes past 1440 into the next day", () => {
|
||||
expect(
|
||||
Availability.dayMinutesToTimestamp({
|
||||
date: "2026-08-25",
|
||||
minutes: 26 * 60,
|
||||
timezone: HELSINKI,
|
||||
}),
|
||||
).toBe(at("2026-08-26", "02:00"));
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
why: "spring",
|
||||
date: "2026-03-28",
|
||||
minutes: 28 * 60,
|
||||
expectedDate: "2026-03-29",
|
||||
expectedTime: "04:00",
|
||||
},
|
||||
{
|
||||
why: "autumn",
|
||||
date: "2026-10-24",
|
||||
minutes: 26 * 60,
|
||||
expectedDate: "2026-10-25",
|
||||
expectedTime: "02:00",
|
||||
},
|
||||
])(
|
||||
"rolls through the $why DST transition like a hand-entered time",
|
||||
({ date, minutes, expectedDate, expectedTime }) => {
|
||||
expect(
|
||||
Availability.dayMinutesToTimestamp({
|
||||
date,
|
||||
minutes,
|
||||
timezone: HELSINKI,
|
||||
}),
|
||||
).toBe(at(expectedDate, expectedTime));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Availability.overlaps", () => {
|
||||
test.each([
|
||||
{
|
||||
|
||||
@@ -73,6 +73,34 @@ export function localToTimestamp({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Database timestamp of the given minutes from midnight of `date` in
|
||||
* `timezone`, the clock representation the schedule editor uses. Minutes past
|
||||
* 1440 roll into the next day, so the end of a range crossing midnight
|
||||
* converts like any other. On a DST transition day the clock simply rolls
|
||||
* through the change, same as entering the time by hand would.
|
||||
*/
|
||||
export function dayMinutesToTimestamp({
|
||||
date,
|
||||
minutes,
|
||||
timezone,
|
||||
}: {
|
||||
date: string;
|
||||
minutes: number;
|
||||
timezone: string;
|
||||
}) {
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
|
||||
invariant(
|
||||
[year, month, day, minutes].every((part) => Number.isFinite(part)),
|
||||
`Malformed local time: ${date} +${minutes}min`,
|
||||
);
|
||||
|
||||
return dateToDatabaseTimestamp(
|
||||
new TZDate(year, month - 1, day, 0, minutes, 0, timezone),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` of the timestamp in `timezone`. What day a slot belongs to
|
||||
* depends on who is looking at it, so the day of a slot is always resolved from
|
||||
|
||||
131
app/features/availability/core/MySchedule.server.ts
Normal file
131
app/features/availability/core/MySchedule.server.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { addWeeks, subWeeks } from "date-fns";
|
||||
import * as R from "remeda";
|
||||
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import * as AvailabilityRepository from "../AvailabilityRepository.server";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import type { DayTimeRange, TimeRange } from "../availability-types";
|
||||
import * as Availability from "./Availability";
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
export type MyScheduleData = SerializeFrom<
|
||||
Awaited<ReturnType<typeof myScheduleData>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* The user's own reported schedule for the editable weeks (current and next)
|
||||
* in their timezone, as the wall-clock representation the schedule editor
|
||||
* uses. Also carries the ranges of the week before the current one for the
|
||||
* "Copy last week" prefill.
|
||||
*/
|
||||
export async function myScheduleData(userId: number) {
|
||||
const timezone = getViewerTimezone() ?? "UTC";
|
||||
const now = new Date();
|
||||
|
||||
const lastWeekRange = Availability.weekRange(subWeeks(now, 1), timezone);
|
||||
const reportedWeeks = await AvailabilityRepository.findAllWeeksByUserIds({
|
||||
userIds: [userId],
|
||||
startsAt: lastWeekRange.startsAt,
|
||||
endsAt: Availability.weekRange(
|
||||
addWeeks(now, AVAILABILITY.WEEK_HORIZON - 1),
|
||||
timezone,
|
||||
).endsAt,
|
||||
});
|
||||
|
||||
const weeks = R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) =>
|
||||
editorWeek({
|
||||
range: Availability.weekRange(addWeeks(now, weekOffset), timezone),
|
||||
timezone,
|
||||
reportedWeeks,
|
||||
}),
|
||||
);
|
||||
|
||||
const lastWeek = editorWeek({
|
||||
range: lastWeekRange,
|
||||
timezone,
|
||||
reportedWeeks,
|
||||
});
|
||||
|
||||
return {
|
||||
weeks,
|
||||
lastWeekRanges: lastWeek.submitted
|
||||
? lastWeek.days.map((day) => day.ranges)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
type ReportedWeek = Awaited<
|
||||
ReturnType<typeof AvailabilityRepository.findAllWeeksByUserIds>
|
||||
>[number];
|
||||
|
||||
function editorWeek({
|
||||
range,
|
||||
timezone,
|
||||
reportedWeeks,
|
||||
}: {
|
||||
range: TimeRange;
|
||||
timezone: string;
|
||||
reportedWeeks: Array<ReportedWeek>;
|
||||
}) {
|
||||
const matchingWeek = reportedWeeks.find(
|
||||
(week) =>
|
||||
Math.abs(week.weekStartsAt - range.startsAt) <
|
||||
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
|
||||
);
|
||||
|
||||
const days = R.range(0, 7).map((dayIndex) => {
|
||||
const date = Availability.dateInTimezone(
|
||||
range.startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
|
||||
timezone,
|
||||
);
|
||||
|
||||
return {
|
||||
date,
|
||||
ranges: Availability.mergedDayRanges(
|
||||
(matchingWeek?.slots ?? [])
|
||||
.filter(
|
||||
(slot) =>
|
||||
Availability.dateInTimezone(slot.startsAt, timezone) === date,
|
||||
)
|
||||
.map((slot) => slotToDayRange(slot, timezone)),
|
||||
),
|
||||
note: matchingWeek ? noteOfDay(matchingWeek, date, timezone) : "",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
weekStartsAt: range.startsAt,
|
||||
weekNumber: Availability.isoWeekNumber(
|
||||
range.startsAt + DAY_SECONDS / 2,
|
||||
timezone,
|
||||
),
|
||||
submitted: Boolean(matchingWeek),
|
||||
days,
|
||||
};
|
||||
}
|
||||
|
||||
function slotToDayRange(slot: TimeRange, timezone: string): DayTimeRange {
|
||||
const start = Availability.timeToMinutes(
|
||||
Availability.timeInTimezone(slot.startsAt, timezone),
|
||||
);
|
||||
|
||||
return { start, end: start + Math.round((slot.endsAt - slot.startsAt) / 60) };
|
||||
}
|
||||
|
||||
/** Notes were saved with dates of the week's stored timezone, so they map through that day's noon in case the viewer has since moved. */
|
||||
function noteOfDay(week: ReportedWeek, date: string, timezone: string) {
|
||||
return (
|
||||
week.dayNotes.find(
|
||||
(note) =>
|
||||
Availability.dateInTimezone(
|
||||
Availability.localToTimestamp({
|
||||
date: note.date,
|
||||
time: "12:00",
|
||||
timezone: week.timezone,
|
||||
}),
|
||||
timezone,
|
||||
) === date,
|
||||
)?.text ?? ""
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import type { PlayableWindowTier, TimeRange } from "../availability-types";
|
||||
import * as Availability from "../core/Availability";
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
/** A member's reported week belongs to a viewer week when their starts are closer than this — timezones set them apart by hours, never by days. */
|
||||
const WEEK_MATCH_MAX_DISTANCE_SECONDS = 3.5 * DAY_SECONDS;
|
||||
|
||||
export type TeamScheduleLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
@@ -168,7 +166,7 @@ function memberWeekRow({
|
||||
const matchingWeek = memberWeeks.find(
|
||||
(week) =>
|
||||
Math.abs(week.weekStartsAt - range.startsAt) <
|
||||
WEEK_MATCH_MAX_DISTANCE_SECONDS,
|
||||
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
|
||||
);
|
||||
|
||||
if (!matchingWeek) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { isSameDay } from "date-fns";
|
||||
import { Flag } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData, useMatches } from "react-router";
|
||||
@@ -15,9 +16,10 @@ import { getMemberRoleType } from "~/features/team/team-utils";
|
||||
import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { teamScheduleSearchParams } from "../availability-search-params";
|
||||
import { scheduleWeekSearchParams } from "../availability-search-params";
|
||||
import type { TeamScheduleLoaderData } from "../loaders/t.$customUrl.schedule.server";
|
||||
import { loader } from "../loaders/t.$customUrl.schedule.server";
|
||||
|
||||
@@ -56,7 +58,7 @@ export default function TeamSchedulePage() {
|
||||
|
||||
function ScheduleWeeks({ weeks }: { weeks: Array<WeekData> }) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const [{ week }, setParams] = useSearchParamsTyped(teamScheduleSearchParams);
|
||||
const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams);
|
||||
const { formatter: headingFormatter } = useDateTimeFormat({
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -198,6 +200,17 @@ function ScheduleCell({
|
||||
|
||||
const note = row.notes.find((note) => note.dayIndex === dayIndex);
|
||||
|
||||
// formatRange expands to full dates when the ends fall on different
|
||||
// calendar days, so a range crossing (or ending exactly at) midnight
|
||||
// formats its ends separately to stay times-only
|
||||
const rangeText = (range: MemberWeekRow["days"][number][number]) =>
|
||||
isSameDay(
|
||||
databaseTimestampToDate(range.startsAt),
|
||||
databaseTimestampToDate(range.endsAt),
|
||||
)
|
||||
? timeFormatter.formatRange(range.startsAt, range.endsAt)
|
||||
: `${timeFormatter.format(range.startsAt)} – ${timeFormatter.format(range.endsAt)}`;
|
||||
|
||||
return (
|
||||
<td
|
||||
className={styles.cell}
|
||||
@@ -225,7 +238,7 @@ function ScheduleCell({
|
||||
className={styles.range}
|
||||
data-testid="schedule-range"
|
||||
>
|
||||
{timeFormatter.formatRange(range.startsAt, range.endsAt)}
|
||||
{rangeText(range)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { myScheduleData } from "~/features/availability/core/MySchedule.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
import {
|
||||
@@ -19,12 +20,14 @@ export const loader = async () => {
|
||||
savedTournaments,
|
||||
upcomingTournaments,
|
||||
userOrganizations,
|
||||
mySchedule,
|
||||
] = await Promise.all([
|
||||
ShowcaseTournaments.categorizedTournamentsByUserId(user.id),
|
||||
ScrimPostRepository.findUserScrims(user.id),
|
||||
SavedCalendarEventRepository.findAllUpcomingByUserId(user.id),
|
||||
ShowcaseTournaments.upcomingTournaments(),
|
||||
TournamentOrganizationRepository.findByUserId(user.id),
|
||||
myScheduleData(user.id),
|
||||
]);
|
||||
|
||||
const registered = tournamentsData.participatingFor
|
||||
@@ -54,5 +57,5 @@ export const loader = async () => {
|
||||
.map(tournamentToSidebarEvent)
|
||||
.sort((a, b) => a.startsAt - b.startsAt);
|
||||
|
||||
return { registered, hosting, scrims, saved, organization };
|
||||
return { registered, hosting, scrims, saved, organization, mySchedule };
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Link, useLoaderData } from "react-router";
|
||||
import { EventsList } from "~/components/EventsList";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubNav, SubNavLink } from "~/components/SubNav";
|
||||
import { action } from "~/features/availability/actions/events.server";
|
||||
import { MySchedule } from "~/features/availability/components/MySchedule";
|
||||
import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { CALENDAR_PAGE } from "~/utils/urls";
|
||||
@@ -12,12 +15,17 @@ import {
|
||||
type ViewFilter,
|
||||
} from "../calendar-search-params";
|
||||
import type { EventsLoaderData } from "../loaders/events.server";
|
||||
import { loader } from "../loaders/events.server";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
import type { Route } from "./+types/events";
|
||||
import styles from "./events.module.css";
|
||||
|
||||
export { loader } from "../loaders/events.server";
|
||||
export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware];
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["calendar"],
|
||||
i18n: ["calendar", "schedule"],
|
||||
};
|
||||
|
||||
export default function EventsPage() {
|
||||
@@ -42,36 +50,41 @@ export default function EventsPage() {
|
||||
const hasNoEventsAtAll = VIEW_FILTERS.every((key) => data[key].length === 0);
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<div className={styles.eventsListHeader}>
|
||||
<h2 className="text-lg mx-2">{t("calendar:events.title")}</h2>
|
||||
{hasNoEventsAtAll ? null : (
|
||||
<SubNav secondary className={styles.subNav}>
|
||||
{VIEW_FILTERS.map((value) => (
|
||||
<SubNavLink
|
||||
key={value}
|
||||
to={calendarEventsSearchParams.href("", { view: value })}
|
||||
secondary
|
||||
controlled
|
||||
active={filter === value}
|
||||
defaultShouldRevalidate={false}
|
||||
>
|
||||
{viewLabels[value]}
|
||||
</SubNavLink>
|
||||
))}
|
||||
</SubNav>
|
||||
<Main className="stack lg">
|
||||
<MySchedule data={data.mySchedule} />
|
||||
<div>
|
||||
<div className={styles.eventsListHeader}>
|
||||
<h2 className="text-lg mx-2">{t("calendar:events.title")}</h2>
|
||||
{hasNoEventsAtAll ? null : (
|
||||
<SubNav secondary className={styles.subNav}>
|
||||
{VIEW_FILTERS.map((value) => (
|
||||
<SubNavLink
|
||||
key={value}
|
||||
to={calendarEventsSearchParams.href("", { view: value })}
|
||||
secondary
|
||||
controlled
|
||||
active={filter === value}
|
||||
defaultShouldRevalidate={false}
|
||||
>
|
||||
{viewLabels[value]}
|
||||
</SubNavLink>
|
||||
))}
|
||||
</SubNav>
|
||||
)}
|
||||
</div>
|
||||
{hasNoEventsAtAll ? (
|
||||
<p className="no-results mt-4">
|
||||
{t("calendar:events.emptyAll")}{" "}
|
||||
<Link to={CALENDAR_PAGE}>
|
||||
{t("calendar:events.findOnCalendar")}
|
||||
</Link>
|
||||
</p>
|
||||
) : shownEvents.length === 0 ? (
|
||||
<p className="no-results mt-4">{t("calendar:events.empty")}</p>
|
||||
) : (
|
||||
<EventsList events={shownEvents} />
|
||||
)}
|
||||
</div>
|
||||
{hasNoEventsAtAll ? (
|
||||
<p className="no-results mt-4">
|
||||
{t("calendar:events.emptyAll")}{" "}
|
||||
<Link to={CALENDAR_PAGE}>{t("calendar:events.findOnCalendar")}</Link>
|
||||
</p>
|
||||
) : shownEvents.length === 0 ? (
|
||||
<p className="no-results mt-4">{t("calendar:events.empty")}</p>
|
||||
) : (
|
||||
<EventsList events={shownEvents} />
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBlocker } from "react-router";
|
||||
import { type Location, useBlocker } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
|
||||
const dirtyCheckers = new Set<() => boolean>();
|
||||
/**
|
||||
* Reports whether the registering component has unsaved changes. For an in-app
|
||||
* navigation the blocked locations are passed, so a checker whose state
|
||||
* survives same-route navigations can ignore those; a full page unload passes
|
||||
* nothing and every dirty checker should warn.
|
||||
*/
|
||||
type UnsavedChangesChecker = (navigation?: {
|
||||
currentLocation: Location;
|
||||
nextLocation: Location;
|
||||
}) => boolean;
|
||||
|
||||
const dirtyCheckers = new Set<UnsavedChangesChecker>();
|
||||
|
||||
/**
|
||||
* Confirms navigating away when any mounted form has unsaved changes.
|
||||
@@ -19,7 +30,7 @@ export function UnsavedChangesGuard() {
|
||||
({ currentLocation, nextLocation }) =>
|
||||
(currentLocation.pathname !== nextLocation.pathname ||
|
||||
currentLocation.search !== nextLocation.search) &&
|
||||
hasUnsavedChanges(),
|
||||
hasUnsavedChanges({ currentLocation, nextLocation }),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -65,10 +76,11 @@ export function UnsavedChangesGuard() {
|
||||
* form state without re-registering on every render.
|
||||
*/
|
||||
export function useUnsavedChangesChecker(
|
||||
checkerRef: React.RefObject<() => boolean>,
|
||||
checkerRef: React.RefObject<UnsavedChangesChecker>,
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
const checker = () => checkerRef.current();
|
||||
const checker: UnsavedChangesChecker = (navigation) =>
|
||||
checkerRef.current(navigation);
|
||||
dirtyCheckers.add(checker);
|
||||
return () => {
|
||||
dirtyCheckers.delete(checker);
|
||||
@@ -76,9 +88,9 @@ export function useUnsavedChangesChecker(
|
||||
}, [checkerRef]);
|
||||
}
|
||||
|
||||
function hasUnsavedChanges() {
|
||||
function hasUnsavedChanges(navigation?: Parameters<UnsavedChangesChecker>[0]) {
|
||||
for (const checker of dirtyCheckers) {
|
||||
if (checker()) return true;
|
||||
if (checker(navigation)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { addHours } from "date-fns";
|
||||
import { addHours, subWeeks } from "date-fns";
|
||||
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 { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { expect, impersonate, test } from "./helpers/playwright";
|
||||
import {
|
||||
expect,
|
||||
impersonate,
|
||||
isNotVisible,
|
||||
MACHINE_TIMEZONE,
|
||||
setTimezoneCookie,
|
||||
test,
|
||||
} from "./helpers/playwright";
|
||||
import { EventsPage } from "./pages/calendar/events-page";
|
||||
|
||||
const JOINED_TOURNAMENT_NAME = "Joined Tournament";
|
||||
const ORGANIZED_TOURNAMENT_NAME = "Organized Tournament";
|
||||
const WEDNESDAY = 2;
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
test.describe("Events", () => {
|
||||
test("filters between tabs and navigates to an event", async ({
|
||||
@@ -58,3 +68,101 @@ test.describe("Events", () => {
|
||||
await expect(page).not.toHaveURL(/\/events/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("My schedule", () => {
|
||||
test("saves a week, edits it and submits an empty week", async ({ page }) => {
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await setTimezoneCookie(page);
|
||||
|
||||
const events = new EventsPage(page);
|
||||
await events.goto();
|
||||
|
||||
await expect(events.weekNotFilledMarker("current")).toBeVisible();
|
||||
|
||||
await events.dayEditButton(WEDNESDAY).click();
|
||||
const popover = events.locators.dayEditorPopover;
|
||||
await popover.getByLabel("Start").fill("18:00");
|
||||
await popover.getByLabel("End").fill("22:00");
|
||||
await popover.getByLabel("Note").fill("Leaving early");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(events.locators.availabilityBars).toHaveCount(1);
|
||||
|
||||
// leaving the page with the unsaved week warns first
|
||||
await page
|
||||
.getByRole("link", { name: "Find an event to join on the calendar!" })
|
||||
.click();
|
||||
await page.getByText("Unsaved changes").waitFor();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page).toHaveURL(/\/events/);
|
||||
|
||||
await events.locators.saveWeekButton.click();
|
||||
await expect(page.getByText("Schedule saved")).toBeAttached();
|
||||
|
||||
await events.goto();
|
||||
await expect(events.locators.availabilityBars).toHaveCount(1);
|
||||
await isNotVisible(events.weekNotFilledMarker("current"));
|
||||
await expect(events.weekNotFilledMarker("next")).toBeVisible();
|
||||
|
||||
await events.dayEditButton(WEDNESDAY).click();
|
||||
await expect(popover.getByLabel("Note")).toHaveValue("Leaving early");
|
||||
// deleting the only range commits instantly: the popover closes and the
|
||||
// bar disappears without waiting for a popover close + save
|
||||
await popover.getByRole("button", { name: "Delete" }).click();
|
||||
await isNotVisible(events.locators.dayEditorPopover);
|
||||
await isNotVisible(events.locators.availabilityBars);
|
||||
await events.locators.saveWeekButton.click();
|
||||
await expect(page.getByText("Schedule saved")).toBeAttached();
|
||||
|
||||
// an empty submitted week is "unavailable all week", not missing
|
||||
await events.goto();
|
||||
await isNotVisible(events.locators.availabilityBars);
|
||||
await isNotVisible(events.weekNotFilledMarker("current"));
|
||||
});
|
||||
|
||||
test("copies last week's ranges into the current week", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const lastWeekRange = Availability.weekRange(
|
||||
subWeeks(new Date(), 1),
|
||||
MACHINE_TIMEZONE,
|
||||
);
|
||||
const lastWednesday = Availability.dateInTimezone(
|
||||
lastWeekRange.startsAt + WEDNESDAY * DAY_SECONDS + DAY_SECONDS / 2,
|
||||
MACHINE_TIMEZONE,
|
||||
);
|
||||
await factories.AvailabilityWeekFactory.create({
|
||||
userId: ADMIN_ID,
|
||||
weekStartsAt: lastWeekRange.startsAt,
|
||||
timezone: MACHINE_TIMEZONE,
|
||||
slots: [
|
||||
{
|
||||
startsAt: Availability.localToTimestamp({
|
||||
date: lastWednesday,
|
||||
time: "19:00",
|
||||
timezone: MACHINE_TIMEZONE,
|
||||
}),
|
||||
endsAt: Availability.localToTimestamp({
|
||||
date: lastWednesday,
|
||||
time: "21:00",
|
||||
timezone: MACHINE_TIMEZONE,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await setTimezoneCookie(page);
|
||||
|
||||
const events = new EventsPage(page);
|
||||
await events.goto();
|
||||
|
||||
await isNotVisible(events.locators.availabilityBars);
|
||||
await events.locators.copyLastWeekButton.click();
|
||||
await expect(events.locators.availabilityBars).toHaveCount(1);
|
||||
|
||||
await events.locators.saveWeekButton.click();
|
||||
await expect(page.getByText("Schedule saved")).toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -363,3 +363,23 @@ export async function clickNavTab(page: Page, testId: string) {
|
||||
}
|
||||
await visibleTab.click();
|
||||
}
|
||||
|
||||
/** The IANA timezone of the machine running the tests, the one fixture times should be computed in. */
|
||||
export const MACHINE_TIMEZONE =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
/**
|
||||
* Writes the timezone cookie the browser would after hydration, so that the
|
||||
* very first document request already renders in the machine's timezone the
|
||||
* test computed its fixture times in.
|
||||
*/
|
||||
export function setTimezoneCookie(page: Page) {
|
||||
return page.context().addCookies([
|
||||
{
|
||||
name: "timezone",
|
||||
value: MACHINE_TIMEZONE,
|
||||
domain: "localhost",
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,27 @@ export class EventsPage {
|
||||
this.page = page;
|
||||
this.main = page.locator("main");
|
||||
this.locators = {
|
||||
title: page.getByRole("heading", { name: "My Events" }),
|
||||
title: page.getByRole("heading", { name: "My events" }),
|
||||
viewTabs: this.main.getByRole("navigation"),
|
||||
emptyCategoryText: page.getByText("No events in this category"),
|
||||
mySchedule: page.getByTestId("my-schedule"),
|
||||
availabilityBars: page.getByTestId("availability-bar"),
|
||||
saveWeekButton: page.getByTestId("save-week-button"),
|
||||
copyLastWeekButton: page.getByTestId("copy-last-week-button"),
|
||||
dayEditorPopover: page.getByRole("dialog"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The "• not filled" marker on a week toggle chip. */
|
||||
weekNotFilledMarker(week: "current" | "next") {
|
||||
return this.page.getByTestId(`week-not-filled-${week}`);
|
||||
}
|
||||
|
||||
/** The pencil button opening the day editor popover of a day track. */
|
||||
dayEditButton(dayIndex: number) {
|
||||
return this.page.getByTestId(`availability-day-edit-${dayIndex}`);
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await navigate({ page: this.page, url: EVENTS_PAGE });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import * as Availability from "~/features/availability/core/Availability";
|
||||
@@ -7,7 +6,9 @@ import {
|
||||
expect,
|
||||
impersonate,
|
||||
isNotVisible,
|
||||
MACHINE_TIMEZONE,
|
||||
navigate,
|
||||
setTimezoneCookie,
|
||||
test,
|
||||
} from "./helpers/playwright";
|
||||
import { AnythingAdder } from "./pages/layout/anything-adder";
|
||||
@@ -25,7 +26,6 @@ const ROSTER_SIZE = 4;
|
||||
const WEDNESDAY = 2;
|
||||
const THURSDAY = 3;
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
const MACHINE_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
test.describe("New team creation", () => {
|
||||
test("creates new team", async ({ page }) => {
|
||||
@@ -474,19 +474,3 @@ function daySlot(dayIndex: number, start: string, end: string) {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the timezone cookie the browser would after hydration, so that the
|
||||
* very first document request already renders in the machine's timezone the
|
||||
* test computed its fixture times in.
|
||||
*/
|
||||
function setTimezoneCookie(page: Page) {
|
||||
return page.context().addCookies([
|
||||
{
|
||||
name: "timezone",
|
||||
value: MACHINE_TIMEZONE,
|
||||
domain: "localhost",
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"forms.draft": "Draft",
|
||||
"forms.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.",
|
||||
"forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket.",
|
||||
"events.title": "My Events",
|
||||
"events.title": "My events",
|
||||
"events.view.registered": "Registered",
|
||||
"events.view.hosting": "Hosting",
|
||||
"events.view.scrims": "Scrims",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "Add time",
|
||||
"editor.copyLastWeek": "Copy last week",
|
||||
"editor.earlier": "Earlier",
|
||||
"editor.editDay": "Edit {{day}}",
|
||||
"editor.later": "Later",
|
||||
"editor.notFilled": "not filled",
|
||||
"editor.note": "Note",
|
||||
"editor.saved": "Schedule saved",
|
||||
"editor.saveWeek": "Save week",
|
||||
"editor.title": "My schedule",
|
||||
"editor.timesInYourTimezone": "Times in your time zone",
|
||||
"editor.visibility": "Visible to your teammates and friends",
|
||||
"team.canPlay": "Team can play ({{players}}+)",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.notFilled": "",
|
||||
"editor.note": "",
|
||||
"editor.saved": "",
|
||||
"editor.saveWeek": "",
|
||||
"editor.title": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": "",
|
||||
"team.canPlay": "",
|
||||
|
||||
@@ -15,6 +15,26 @@ export default defineConfig((config) => {
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
// Vite dev serves everything with no-cache, so the browser revalidates
|
||||
// the woff2 on every font re-resolution — any <head> mutation (e.g. an
|
||||
// intent-prefetch link mounting) then flashes fallback fonts across the
|
||||
// whole page while the 304 round-trips. Fonts effectively never change,
|
||||
// so dev caches them hard, matching how the production build serves them.
|
||||
name: "cache-fonts-in-dev",
|
||||
apply: "serve",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url?.includes("/fonts/") && req.url.includes(".woff2")) {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
// Wraps CSS modules in @layer components so utility classes always win.
|
||||
// The layer order declaration is prepended to each module because in Vite
|
||||
|
||||
Reference in New Issue
Block a user