My schedule

This commit is contained in:
Kalle
2026-08-22 16:44:05 +03:00
parent be973fa8c1
commit 199e1546c2
39 changed files with 933 additions and 95 deletions

View File

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

View 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;
};

View File

@@ -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). */

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View 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 ?? ""
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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