From 199e1546c294efcb7d86b5be0bda20c36964ebe8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:44:05 +0300 Subject: [PATCH] My schedule --- app/db/seed/dev/availability.ts | 4 +- .../availability/actions/events.server.ts | 70 +++++++ .../availability/availability-constants.ts | 2 + .../availability/availability-schemas.ts | 43 ++++ .../availability-search-params.test.ts | 6 +- .../availability-search-params.ts | 2 +- .../components/MySchedule.module.css | 27 +++ .../availability/components/MySchedule.tsx | 189 ++++++++++++++++++ .../components/WeekAvailabilityEditor.tsx | 78 +++++--- .../availability/core/Availability.test.ts | 50 +++++ .../availability/core/Availability.ts | 28 +++ .../availability/core/MySchedule.server.ts | 131 ++++++++++++ .../loaders/t.$customUrl.schedule.server.ts | 4 +- .../routes/t.$customUrl.schedule.tsx | 19 +- .../calendar/loaders/events.server.ts | 5 +- app/features/calendar/routes/events.tsx | 73 ++++--- app/form/UnsavedChangesGuard.tsx | 26 ++- e2e/events.spec.ts | 112 ++++++++++- e2e/helpers/playwright.ts | 20 ++ e2e/pages/calendar/events-page.ts | 17 +- e2e/team.spec.ts | 20 +- locales/da/schedule.json | 5 + locales/de/schedule.json | 5 + locales/en/calendar.json | 2 +- locales/en/schedule.json | 5 + locales/es-ES/schedule.json | 5 + locales/es-US/schedule.json | 5 + locales/fr-CA/schedule.json | 5 + locales/fr-EU/schedule.json | 5 + locales/he/schedule.json | 5 + locales/it/schedule.json | 5 + locales/ja/schedule.json | 5 + locales/ko/schedule.json | 5 + locales/nl/schedule.json | 5 + locales/pl/schedule.json | 5 + locales/pt-BR/schedule.json | 5 + locales/ru/schedule.json | 5 + locales/zh/schedule.json | 5 + vite.config.ts | 20 ++ 39 files changed, 933 insertions(+), 95 deletions(-) create mode 100644 app/features/availability/actions/events.server.ts create mode 100644 app/features/availability/availability-schemas.ts create mode 100644 app/features/availability/components/MySchedule.module.css create mode 100644 app/features/availability/components/MySchedule.tsx create mode 100644 app/features/availability/core/MySchedule.server.ts diff --git a/app/db/seed/dev/availability.ts b/app/db/seed/dev/availability.ts index aae8a42c4..9f00dd49f 100644 --- a/app/db/seed/dev/availability.ts +++ b/app/db/seed/dev/availability.ts @@ -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, }, { diff --git a/app/features/availability/actions/events.server.ts b/app/features/availability/actions/events.server.ts new file mode 100644 index 000000000..ff6aefd87 --- /dev/null +++ b/app/features/availability/actions/events.server.ts @@ -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; +}; diff --git a/app/features/availability/availability-constants.ts b/app/features/availability/availability-constants.ts index da489fa12..d5ee24cde 100644 --- a/app/features/availability/availability-constants.ts +++ b/app/features/availability/availability-constants.ts @@ -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). */ diff --git a/app/features/availability/availability-schemas.ts b/app/features/availability/availability-schemas.ts new file mode 100644 index 000000000..41fe39337 --- /dev/null +++ b/app/features/availability/availability-schemas.ts @@ -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)), +}); diff --git a/app/features/availability/availability-search-params.test.ts b/app/features/availability/availability-search-params.test.ts index 910ebeab8..9a74b5ef8 100644 --- a/app/features/availability/availability-search-params.test.ts +++ b/app/features/availability/availability-search-params.test.ts @@ -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"], }); }); diff --git a/app/features/availability/availability-search-params.ts b/app/features/availability/availability-search-params.ts index c9b3cdac3..ef1c7dad1 100644 --- a/app/features/availability/availability-search-params.ts +++ b/app/features/availability/availability-search-params.ts @@ -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, diff --git a/app/features/availability/components/MySchedule.module.css b/app/features/availability/components/MySchedule.module.css new file mode 100644 index 000000000..5e607b07f --- /dev/null +++ b/app/features/availability/components/MySchedule.module.css @@ -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); +} diff --git a/app/features/availability/components/MySchedule.tsx b/app/features/availability/components/MySchedule.tsx new file mode 100644 index 000000000..67a39d9a0 --- /dev/null +++ b/app/features/availability/components/MySchedule.tsx @@ -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>(() => + 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[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 ( +
+
+

{t("schedule:editor.title")}

+ + {WEEK_VALUES.map((value, index) => ( + setParams({ week: value })} + > + + {index === 0 + ? t("schedule:team.currentWeek") + : t("schedule:team.nextWeek")} + {!data.weeks[index].submitted ? ( + + • {t("schedule:editor.notFilled")} + + ) : null} + + + ))} + +
+

+ {t("schedule:team.weekHeading", { + week: data.weeks[weekIndex].weekNumber, + })}{" "} + ·{" "} + {headingFormatter.formatRange( + dateAtNoon(shownDays[0].date), + dateAtNoon(shownDays[6].date), + )} +

+ + setWeeks( + weeks.map((days, index) => (index === weekIndex ? value : days)), + ) + } + /> +
+ + {t("schedule:editor.copyLastWeek")} + + + {t("schedule:editor.saveWeek")} + +
+
+ ); +} + +function useSavedToast(fetcher: FetcherWithComponents) { + 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); +} diff --git a/app/features/availability/components/WeekAvailabilityEditor.tsx b/app/features/availability/components/WeekAvailabilityEditor.tsx index d7a7ff44b..257ba3bf4 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.tsx +++ b/app/features/availability/components/WeekAvailabilityEditor.tsx @@ -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, @@ -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({