/** * 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; } export 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. */ export 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] = useStateBeats the custody pattern for these dates
{headline}
{detail}
{error}
) : null}