/** * Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6. * * The consequence warning is counted by the API (POST * /presence-overrides/consequence) before anything is saved; Save stays off * until that count is on screen. Saving only ever sends the create request: * the API never removes an instance that already has a completion event (D-005). */ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { ApiForbiddenError } from "../api/client"; import { ApiRequestError, type AdminChild } from "../api/definitions"; import { createPresenceOverride, previewOverrideConsequence, type PresenceOverride, type PresenceOverrideBody, } from "../api/presence"; import { dayNumber, isoFromDayNumber } from "./cycle"; import { dateRange } from "./format"; import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs"; import "./OverrideEditor.css"; /** How long the form must sit still before the consequence is recounted. */ export const CONSEQUENCE_DEBOUNCE_MS = 300; /** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */ export function isIsoDate(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; return isoFromDayNumber(dayNumber(value)) === value; } export function isValidRange(start: string, end: string): boolean { return isIsoDate(start) && isIsoDate(end) && start <= end; } type Consequence = | { kind: "invalid" } | { kind: "counting" } | { kind: "ready"; removed: number } | { kind: "error"; message: string }; function errorMessage(error: unknown, fallback: string): string { if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiRequestError && error.detail) return error.detail; return fallback; } const FOCUSABLE = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; /** * Make everything outside `root` inert (siblings of each ancestor up to * ), so the tab bar behind the pushed screen cannot take focus. * Returns the undo. */ function inertOutside(root: HTMLElement): () => void { const made: HTMLElement[] = []; for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) { for (const sibling of Array.from(node.parentElement.children)) { if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue; sibling.setAttribute("inert", ""); sibling.setAttribute("aria-hidden", "true"); made.push(sibling); } } return () => { for (const el of made) { el.removeAttribute("inert"); el.removeAttribute("aria-hidden"); } }; } function tasksPhrase(count: number): string { return `${count} upcoming ${count === 1 ? "task" : "tasks"}`; } export interface OverrideEditorProps { /** Active children, in display order. */ childOptions: AdminChild[]; initialChildId: number; /** "YYYY-MM-DD"; the date fields start here. */ today: string; onCancel: () => void; onSaved: (override: PresenceOverride) => void; } export default function OverrideEditor({ childOptions, initialChildId, today, onCancel, onSaved, }: OverrideEditorProps) { const [childId, setChildId] = useState(initialChildId); const [isPresent, setIsPresent] = useState(false); const [startDate, setStartDate] = useState(today); const [endDate, setEndDate] = useState(today); const [note, setNote] = useState(""); const [consequence, setConsequence] = useState({ kind: "counting" }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // Every form change bumps the sequence; only the latest request may render. const requestSeq = useRef(0); const screenRef = useRef(null); const titleRef = useRef(null); // Modal focus: move in on open and keep the background out of reach. The // opener hands focus back to its Add control once this screen closes. useEffect(() => { titleRef.current?.focus(); return screenRef.current ? inertOutside(screenRef.current) : undefined; }, []); function onKeyDown(event: ReactKeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); if (!saving) onCancel(); return; } if (event.key !== "Tab" || !screenRef.current) return; const focusable = Array.from(screenRef.current.querySelectorAll(FOCUSABLE)); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; const current = document.activeElement; const outside = !focusable.includes(current as HTMLElement); if (event.shiftKey && (current === first || outside)) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && (current === last || outside)) { event.preventDefault(); first.focus(); } } const child = childOptions.find((c) => c.id === childId) ?? childOptions[0]; const rangeValid = isValidRange(startDate, endDate); useEffect(() => { const seq = ++requestSeq.current; if (!rangeValid) { setConsequence({ kind: "invalid" }); return; } if (isPresent) { // Keeping a child home removes nothing by definition. setConsequence({ kind: "ready", removed: 0 }); return; } setConsequence({ kind: "counting" }); const timer = setTimeout(() => { previewOverrideConsequence({ child_id: childId, start_date: startDate, end_date: endDate, is_present: false, }) .then((removed) => { if (seq === requestSeq.current) setConsequence({ kind: "ready", removed }); }) .catch((err: unknown) => { if (seq !== requestSeq.current) return; setConsequence({ kind: "error", message: errorMessage(err, "The upcoming tasks could not be counted. Try again."), }); }); }, CONSEQUENCE_DEBOUNCE_MS); return () => clearTimeout(timer); }, [childId, startDate, endDate, isPresent, rangeValid]); const canSave = rangeValid && consequence.kind === "ready" && !saving; function save() { if (!canSave) return; const body: PresenceOverrideBody = { child_id: childId, start_date: startDate, end_date: endDate, is_present: isPresent, }; const reason = note.trim(); if (reason) body.note = reason; setSaving(true); setError(null); createPresenceOverride(body) .then(onSaved) .catch((err: unknown) => { setError(errorMessage(err, "The override could not be saved. Check your connection and try again.")); setSaving(false); }); } let headline: string; let detail: string; const range = rangeValid ? dateRange(startDate, endDate) : ""; if (consequence.kind === "invalid") { headline = "Choose a valid date range"; detail = "Through must be on or after From."; } else if (consequence.kind === "counting") { headline = "Counting upcoming tasks…"; detail = `${child.display_name} away ${range}. Completed days are untouched.`; } else if (consequence.kind === "error") { headline = "Upcoming tasks not counted"; detail = consequence.message; } else { headline = `This removes ${tasksPhrase(consequence.removed)}`; detail = isPresent ? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.` : `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`; } return (

Presence override

Beats the custody pattern for these dates

Child
{childOptions.map((c) => ( ))}
Status for these dates

{headline}

{detail}

{error ? (

{error}

) : null}
); }