/** * Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4) and the * selected child's presence patterns and overrides. * * The grid shows the COMBINED presence of every pattern (./cycle.ts * `dayState`); each pattern's cycle position comes from its own anchor date, * 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, deletePresencePattern, fetchPresenceOverrides, fetchPresencePatterns, type PresenceOverride, type PresencePattern, } from "../api/presence"; import { cycleLabel, dayState, localTodayIso, monthDates, monthStart, patternDaysLabel, weekdayOf, type DayState, } from "./cycle"; import { dateRange, monthLabel, shortDate } from "./format"; import { CalendarCheckGlyph, CalendarXGlyph, ChevronLeftGlyph, ChevronRightGlyph, PencilGlyph, Trash2Glyph, } from "./glyphs"; import OverrideEditor from "./OverrideEditor"; import PatternEditor from "./PatternEditor"; import "./ScheduleTab.css"; /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ export const SCROLL_BOTTOM_PADDING = "92px"; /** Sunday-first header; `weekdayOf` stays Monday=0. */ const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"]; export const STATE_LABELS: Record = { home: "Home", away: "Away", "override-away": "Override · away", "override-home": "Override · home", }; interface Loaded { children: AdminChild[]; patterns: 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 EditorState = | { kind: "closed" } | { kind: "override" } | { kind: "pattern"; pattern: PresencePattern | null }; async function loadSchedule(): Promise { const children = (await fetchChildren()) .filter((child) => child.is_active) .sort((a, b) => a.sort_order - b.sort_order); const [patterns, overrides] = await Promise.all([ Promise.all(children.map((child) => fetchPresencePatterns(child.id))), fetchPresenceOverrides(), ]); return { children, patterns: Object.fromEntries(children.map((child, i) => [child.id, patterns[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: string): 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; /** Every pattern of the child; none means home every day. */ patterns: PresencePattern[]; overrides: PresenceOverride[]; onMonth: (delta: number) => void; } function MonthGrid({ month, today, patterns, overrides, onMonth }: MonthGridProps) { const dates = monthDates(month); const leading = (weekdayOf(month) + 1) % 7; return ( <>
{monthLabel(month)}
{WEEKDAY_INITIALS.map((initial, i) => ( ))} {Array.from({ length: leading }, (_, i) => (
    {(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
  • ))}

Combined from every pattern: away beats home; overrides beat both.

); } interface PatternRowProps { pattern: PresencePattern; deletion: DeleteState; onEdit: () => void; onAskDelete: () => void; onKeep: () => void; onDelete: () => void; } function PatternRow({ pattern, deletion, onEdit, onAskDelete, onKeep, onDelete }: PatternRowProps) { const mine = (deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === pattern.id; return (
  • {pattern.kind === "home" ? : }
    {pattern.name}
    {pattern.kind === "home" ? "Home" : "Away"} · {cycleLabel(pattern.cycle_length_weeks)}
    {patternDaysLabel(pattern)}
    {mine ? (
    Delete pattern?
    ) : ( <> )}
  • ); } 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 [editor, setEditor] = useState({ kind: "closed" }); const [deletion, setDeletion] = useState({ kind: "idle" }); const [patternDeletion, setPatternDeletion] = useState({ kind: "idle" }); const addRef = useRef(null); const addPatternRef = useRef(null); const openedEditor = useRef("closed"); // An editor replaces this screen, so the Add controls are remounted when // it closes; hand focus back to the one that belongs to that editor. useEffect(() => { if (editor.kind === "closed") { if (openedEditor.current === "override") addRef.current?.focus(); if (openedEditor.current === "pattern") addPatternRef.current?.focus(); } openedEditor.current = editor.kind; }, [editor.kind]); /** Reload children, patterns 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 (editor.kind !== "closed" && state.kind === "ready" && state.data.children.length > 0) { const { children } = state.data; const current = children.find((c) => c.id === selectedId) ?? children[0]; if (editor.kind === "pattern") { return ( setEditor({ kind: "closed" })} onSaved={() => { setEditor({ kind: "closed" }); void refresh(); }} /> ); } return ( setEditor({ kind: "closed" })} onSaved={(override) => { setEditor({ kind: "closed" }); 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 patterns = data.patterns[child.id] ?? []; const childOverrides = data.overrides.filter((o) => o.child_id === child.id); sub = patterns.length === 0 ? `${child.display_name} · no patterns, home every day` : `${child.display_name} · ${patterns.length} ${patterns.length === 1 ? "pattern" : "patterns"}`; 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 confirmPatternDelete = (id: number) => { setPatternDeletion({ kind: "deleting", id }); deletePresencePattern(id) .then(() => refresh()) .then(() => setPatternDeletion({ kind: "idle" })) .catch((error: unknown) => setPatternDeletion({ kind: "error", id, message: saveErrorMessage( error, "The pattern could not be deleted. Check your connection and try again.", ), }), ); }; body = ( <>
    {data.children.map((c) => ( ))}
    setMonth((m) => monthStart(m, delta))} />

    Presence patterns

    {patterns.length > 0 ? (
      {patterns.map((pattern) => ( setEditor({ kind: "pattern", pattern })} onAskDelete={() => setPatternDeletion({ kind: "confirming", id: pattern.id })} onKeep={() => setPatternDeletion({ kind: "idle" })} onDelete={() => confirmPatternDelete(pattern.id)} /> ))}
    ) : (
    No patterns for {child.display_name} — home every day.
    )} {patternDeletion.kind === "error" ? (

    {patternDeletion.message}

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