/** * Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4). * * Every day's cycle position comes from the anchor date (./cycle.ts), never * from ISO week numbers or week parity (D-004). */ import { useEffect, useRef, useState } from "react"; import { ApiForbiddenError } from "../api/client"; import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions"; import { deletePresenceOverride, fetchPresenceOverrides, fetchPresenceSchedule, savePresenceSchedule, type PresenceOverride, type PresenceSchedule, type PresenceScheduleBody, } from "../api/presence"; import { cycleWeekIndex, dayState, fullPattern, localTodayIso, monthDates, monthStart, patternIncludes, presenceRule, resizePattern, togglePatternDay, weekdayOf, type DayState, } from "./cycle"; import { anchorLabel, dateRange, monthLabel, shortDate } from "./format"; import { CalendarCheckGlyph, CalendarXGlyph, ChevronLeftGlyph, ChevronRightGlyph, Trash2Glyph, } from "./glyphs"; import OverrideEditor from "./OverrideEditor"; import "./ScheduleTab.css"; /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ export const SCROLL_BOTTOM_PADDING = "92px"; export const DEFAULT_CYCLE_LENGTH_WEEKS = 2; const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4]; const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"]; export const STATE_LABELS: Record = { home: "Home", away: "Away", "override-away": "Override · away", "override-home": "Override · home", }; interface Loaded { children: AdminChild[]; schedules: Record; overrides: PresenceOverride[]; } type LoadState = | { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; data: Loaded }; type DeleteState = | { kind: "idle" } | { kind: "confirming"; id: number } | { kind: "deleting"; id: number } | { kind: "error"; id: number; message: string }; type SaveState = | { kind: "idle" } | { kind: "saving" } | { kind: "saved" } | { kind: "error"; message: string }; async function loadSchedule(): Promise { const children = (await fetchChildren()) .filter((child) => child.is_active) .sort((a, b) => a.sort_order - b.sort_order); const [schedules, overrides] = await Promise.all([ Promise.all(children.map((child) => fetchPresenceSchedule(child.id))), fetchPresenceOverrides(), ]); return { children, schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])), overrides, }; } function loadErrorMessage(error: unknown): string { return error instanceof ApiForbiddenError ? error.message : "The schedule could not be loaded. Check your connection and try again."; } function saveErrorMessage( error: unknown, fallback = "The pattern could not be saved. Check your connection and try again.", ): string { if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiRequestError && error.detail) return error.detail; return fallback; } function ScreenHeader({ sub }: { sub: string }) { return (

Schedule

{sub}

); } interface MonthGridProps { month: string; today: string; schedule: PresenceScheduleBody; /** Null when the child has no saved schedule and no edits: home every day. */ effective: PresenceScheduleBody | null; overrides: PresenceOverride[]; onMonth: (delta: number) => void; onToggle: (date: string) => void; } function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) { const dates = monthDates(month); const leading = weekdayOf(month); return ( <>
{monthLabel(month)}
{WEEKDAY_INITIALS.map((initial, i) => ( ))} {Array.from({ length: leading }, (_, i) => (
    {(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
  • ))}
); } interface OverrideRowProps { override: PresenceOverride; childName: string; deletion: DeleteState; onAskDelete: () => void; onKeep: () => void; onDelete: () => void; } function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) { const mine = (deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id; return (
  • {override.is_present ? : }
    {childName} {override.is_present ? "home" : "away"} ·{" "} {dateRange(override.start_date, override.end_date)}
    {override.note || "No reason given"}
    {mine ? (
    Delete override?
    ) : ( )}
  • ); } export interface ScheduleTabProps { /** "YYYY-MM-DD"; defaults to the viewer's local date. */ today?: string; } export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) { const [state, setState] = useState({ kind: "loading" }); const [attempt, setAttempt] = useState(0); const [selectedId, setSelectedId] = useState(null); const [month, setMonth] = useState(() => monthStart(today)); const [drafts, setDrafts] = useState>({}); const [save, setSave] = useState({ kind: "idle" }); const [editorOpen, setEditorOpen] = useState(false); const [deletion, setDeletion] = useState({ kind: "idle" }); const addRef = useRef(null); const editorWasOpen = useRef(false); // The editor replaces this screen, so Add is remounted when it closes; // hand focus back to it then. useEffect(() => { if (editorWasOpen.current && !editorOpen) addRef.current?.focus(); editorWasOpen.current = editorOpen; }, [editorOpen]); /** Reload children, schedules and overrides in place (no loading flash). */ const refresh = () => loadSchedule() .then((data) => setState({ kind: "ready", data })) .catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) })); useEffect(() => { let cancelled = false; setState({ kind: "loading" }); loadSchedule() .then((data) => { if (cancelled) return; setState({ kind: "ready", data }); setSelectedId((current) => current ?? data.children[0]?.id ?? null); }) .catch((error: unknown) => { if (cancelled) return; setState({ kind: "error", message: loadErrorMessage(error) }); }); return () => { cancelled = true; }; }, [attempt]); if (editorOpen && state.kind === "ready" && state.data.children.length > 0) { const { children } = state.data; return ( c.id === selectedId)?.id ?? children[0].id} today={today} onCancel={() => setEditorOpen(false)} onSaved={(override) => { setEditorOpen(false); setSelectedId(override.child_id); void refresh(); }} /> ); } let sub = ""; let body; if (state.kind === "loading") { body = (
    Loading schedule…
    ); } else if (state.kind === "error") { body = (

    {state.message}

    ); } else if (state.data.children.length === 0) { body =
    No children yet.
    ; } else { const { data } = state; const child = data.children.find((c) => c.id === selectedId) ?? data.children[0]; const saved = data.schedules[child.id] ?? null; const draft = drafts[child.id]; // Edits start from the saved schedule, or — with none — from "home every // day" on a 2-week cycle anchored at the displayed month's 1st. const schedule: PresenceScheduleBody = draft ?? saved ?? { cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS, anchor_date: month, pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS), }; const effective = draft ?? saved; const childOverrides = data.overrides.filter((o) => o.child_id === child.id); sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`; const edit = (next: PresenceScheduleBody) => { setDrafts((all) => ({ ...all, [child.id]: next })); setSave({ kind: "idle" }); }; const toggle = (date: string) => { const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks); edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) }); }; const confirmDelete = (id: number) => { setDeletion({ kind: "deleting", id }); deletePresenceOverride(id) .then(() => refresh()) .then(() => setDeletion({ kind: "idle" })) .catch((error: unknown) => setDeletion({ kind: "error", id, message: saveErrorMessage( error, "The override could not be deleted. Check your connection and try again.", ), }), ); }; const submit = () => { if (!draft) return; const childId = child.id; setSave({ kind: "saving" }); const { cycle_length_weeks, anchor_date, pattern } = draft; savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern }) .then((stored) => { setState((current) => current.kind === "ready" ? { kind: "ready", data: { ...current.data, schedules: { ...current.data.schedules, [childId]: stored }, }, } : current, ); setDrafts((all) => { const rest = { ...all }; delete rest[childId]; return rest; }); setSave({ kind: "saved" }); }) .catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) })); }; body = ( <>
    {data.children.map((c) => ( ))}
    setMonth((m) => monthStart(m, delta))} onToggle={toggle} />
    {save.kind === "error" ? (

    {save.message}

    ) : save.kind === "saved" ? (

    Pattern saved.

    ) : null}

    Presence pattern

      {data.children.map((c) => (
    • {c.display_name}
      {presenceRule(data.schedules[c.id] ?? null)}
    • ))}

    Overrides

    {childOverrides.length > 0 ? (
      {childOverrides.map((override) => ( setDeletion({ kind: "confirming", id: override.id })} onKeep={() => setDeletion({ kind: "idle" })} onDelete={() => confirmDelete(override.id)} /> ))}
    ) : (
    No overrides for {child.display_name}.
    )} {deletion.kind === "error" ? (

    {deletion.message}

    ) : null} ); } return (
    {body}
    ); }