/** * Settings — a pushed screen opened from the Today header's Settings button. * * Children: list, add, edit, deactivate/reactivate and reorder the * household's child profiles. Every write refetches the list. * * Preferences: the household settings (planning horizon, day rollover, * timezone, notifications). Save patches only the changed fields. The * timezone — the application's source of local time — is only ever set by * the admin picking or typing a zone here and saving it. * * One write at a time across both sections. */ import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { ApiForbiddenError } from "../api/client"; import { createChild, reorderChildren, setChildActive, updateChild, type ChildCreateBody, type ChildEditBody, } from "../api/children"; import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions"; import { fetchSettings, updateSettings, type HouseholdSettings, type SettingsChanges, } from "../api/settings"; import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor"; import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs"; import "./SettingsScreen.css"; /** The swatch picker's presets; any other "#RRGGBB" can be typed. */ export const COLOUR_PRESETS = ["#e11d48", "#f97316", "#eab308", "#16a34a", "#0ea5e9", "#7c3aed"]; /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ export const SCROLL_BOTTOM_PADDING = "92px"; const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/; type LoadState = | { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; children: AdminChild[] }; type FormTarget = null | "new" | AdminChild; interface Draft { name: string; colour: string; avatar: string; order: string; } /** Display order: sort_order, ties broken by id (the API's own order). */ function sortChildren(children: AdminChild[]): AdminChild[] { return [...children].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id); } function errorMessage(error: unknown, action: string): string { if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiRequestError && error.detail) return `${action}: ${error.detail}`; return `${action}. Check your connection and try again.`; } function draftFor(target: "new" | AdminChild, siblings: AdminChild[]): Draft { if (target === "new") { const next = siblings.reduce((max, c) => Math.max(max, c.sort_order), 0) + 1; return { name: "", colour: "", avatar: "", order: String(next) }; } return { name: target.display_name, colour: target.colour ?? "", avatar: target.avatar_ref ?? "", order: String(target.sort_order), }; } /** The request body for the draft, or a validation message. */ function buildBody( draft: Draft, target: "new" | AdminChild, ): ChildCreateBody | ChildEditBody | string { const name = draft.name.trim(); const colour = draft.colour.trim(); const avatar = draft.avatar.trim(); const order = draft.order.trim(); if (!name) return "Enter a display name."; if (colour && !HEX_COLOUR.test(colour)) return "Colour must be a hex value like #0ea5e9."; if (order && !/^-?\d+$/.test(order)) return "Order must be a whole number."; if (target === "new") { const body: ChildCreateBody = { display_name: name }; if (colour) body.colour = colour; if (avatar) body.avatar_ref = avatar; if (order) body.sort_order = Number(order); return body; } // The API rejects null, so a set colour, avatar or order cannot be cleared. if (!colour && target.colour !== null) return "A colour, once set, cannot be removed. Pick another."; if (!avatar && target.avatar_ref !== null) return "An avatar ref, once set, cannot be removed."; if (!order) return "Enter an order."; const body: ChildEditBody = {}; if (name !== target.display_name) body.display_name = name; if (colour && colour !== target.colour) body.colour = colour; if (avatar && avatar !== target.avatar_ref) body.avatar_ref = avatar; if (Number(order) !== target.sort_order) body.sort_order = Number(order); if (Object.keys(body).length === 0) return "No changes to save."; return body; } function ChildForm({ target, siblings, saving, error, onCancel, onSubmit, }: { target: "new" | AdminChild; /** The current list; a new child's order defaults to after the last. */ siblings: AdminChild[]; saving: boolean; error: string | null; onCancel: () => void; onSubmit: (body: ChildCreateBody | ChildEditBody | string) => void; }) { const [draft, setDraft] = useState(() => draftFor(target, siblings)); const nameRef = useRef(null); const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); const isNew = target === "new"; const title = isNew ? "Add child" : `Edit ${target.display_name}`; useEffect(() => { nameRef.current?.focus(); }, []); function submit(event: FormEvent) { event.preventDefault(); onSubmit(buildBody(draft, target)); } return (

{title}

{COLOUR_PRESETS.map((hex) => (
{error ? (

{error}

) : null}
); } type PreferencesState = | { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; settings: HouseholdSettings }; type TimeField = | "day_rollover_time" | "morning_summary_time" | "afternoon_reminder_time" | "end_of_day_report_time"; type ToggleField = | "morning_summary_enabled" | "afternoon_reminder_enabled" | "end_of_day_report_enabled" | "celebration_enabled"; const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] = [ { label: "Morning summary", time: "morning_summary_time", toggle: "morning_summary_enabled" }, { label: "Afternoon reminder", time: "afternoon_reminder_time", toggle: "afternoon_reminder_enabled" }, { label: "End-of-day report", time: "end_of_day_report_time", toggle: "end_of_day_report_enabled" }, ]; const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/; /** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */ const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/; /** The Timezone picker's zones; any other IANA zone can be typed via "Other". */ export const COMMON_TIMEZONES = [ "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Phoenix", "America/Los_Angeles", "America/Anchorage", "Pacific/Honolulu", "America/Halifax", "America/St_Johns", "America/Toronto", "America/Vancouver", "America/Mexico_City", "America/Sao_Paulo", "Europe/London", "Europe/Dublin", "Europe/Paris", "Europe/Berlin", "Europe/Madrid", "Europe/Rome", "Europe/Athens", "Africa/Johannesburg", "Asia/Dubai", "Asia/Kolkata", "Asia/Singapore", "Asia/Shanghai", "Asia/Tokyo", "Australia/Perth", "Australia/Sydney", "Pacific/Auckland", ]; /** The picker's "Other IANA zone…" option; never sent to the API. */ const OTHER_ZONE = "__other__"; /** The form's working copy: the number is text so it can be mid-edit. */ type PreferencesDraft = Omit & { horizon_days: string }; function preferencesDraft(settings: HouseholdSettings): PreferencesDraft { return { ...settings, horizon_days: String(settings.horizon_days) }; } /** Only the fields that differ from `loaded`, or a validation message. */ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): SettingsChanges | string { const horizon = draft.horizon_days.trim(); const horizonDays = Number(horizon); // A digit string too long for a safe integer would become Infinity, which // JSON serializes as null, and the API reads null as "reset to default". if (!/^\d+$/.test(horizon) || !Number.isSafeInteger(horizonDays) || horizonDays < 1) { return "Planning horizon must be a whole number of days, at least 1."; } const times: [TimeField, string][] = [ ["day_rollover_time", "Day rollover"], ...NOTIFICATIONS.map((n): [TimeField, string] => [n.time, `${n.label} time`]), ]; for (const [key, label] of times) { if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`; } const timezone = draft.timezone.trim(); if (timezone && !IANA_ZONE.test(timezone)) { return "Timezone must be an IANA zone like America/New_York, or UTC."; } const next: HouseholdSettings = { ...draft, horizon_days: horizonDays, notify_target: draft.notify_target.trim(), timezone, }; const changes: SettingsChanges = {}; for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) { if (next[key] !== loaded[key]) (changes as Record)[key] = next[key]; } if (Object.keys(changes).length === 0) return "No changes to save."; return changes; } function PreferencesForm({ settings, saving, locked, message, onSubmit, }: { settings: HouseholdSettings; saving: boolean; /** Another write is in flight. */ locked: boolean; message: { kind: "error" | "info"; text: string } | null; onSubmit: (changes: SettingsChanges | string) => void; }) { const [draft, setDraft] = useState(() => preferencesDraft(settings)); const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); // A saved zone outside the curated list opens in the free-text entry. const [otherZone, setOtherZone] = useState( () => settings.timezone !== "" && !COMMON_TIMEZONES.includes(settings.timezone), ); const disabled = saving || locked; function submit(event: FormEvent) { event.preventDefault(); onSubmit(buildChanges(draft, settings)); } function timeInput(key: TimeField, label: string) { return ( ); } function toggle(key: ToggleField, label: string) { return ( ); } return (
{timeInput("day_rollover_time", "Day rollover")}
{otherZone ? ( update({ timezone: event.target.value })} /> ) : null}

The application timezone: the source of local time for the panel, the scheduler and this admin app. It changes only when you save it here; with no zone set, the API host's local time is used.

{NOTIFICATIONS.map((n) => (
{toggle(n.toggle, n.label)} {timeInput(n.time, `${n.label} time`)}
))} {toggle("celebration_enabled", "Celebration")} {message ? (

{message.text}

) : null}
); } function ChildRow({ child, index, count, busy, onEdit, onMove, onToggleActive, }: { child: AdminChild; index: number; count: number; /** The in-flight action key, or null. Every write control waits on it. */ busy: string | null; onEdit: () => void; onMove: (delta: -1 | 1) => void; onToggleActive: () => void; }) { const locked = busy !== null; const toggling = busy === `active-${child.id}`; return (
  • ); } export default function SettingsScreen({ onClose }: { onClose: () => void }) { const [state, setState] = useState({ kind: "loading" }); const [attempt, setAttempt] = useState(0); const [form, setForm] = useState(null); const [formError, setFormError] = useState(null); const [actionError, setActionError] = useState(null); const [busy, setBusy] = useState(null); const [preferences, setPreferences] = useState({ kind: "loading" }); const [preferencesAttempt, setPreferencesAttempt] = useState(0); const [preferencesMessage, setPreferencesMessage] = useState<{ kind: "error" | "info"; text: string; } | null>(null); // A ref, not state: two taps in one frame must not both see `busy === null`. const inFlight = useRef(false); const screenRef = useRef(null); const titleRef = useRef(null); const addRef = useRef(null); const formWasOpen = useRef(false); // Modal focus: move in on open, keep the background out of reach, and hand // focus back to the opener (the Settings button) on close. useEffect(() => { const trigger = document.activeElement as HTMLElement | null; titleRef.current?.focus(); const restoreBackground = screenRef.current ? inertOutside(screenRef.current) : undefined; return () => { restoreBackground?.(); if (trigger && trigger.isConnected) trigger.focus(); }; }, []); // The form's controls unmount when it closes; hand focus to Add child then // (after the render that re-enables it). useEffect(() => { if (formWasOpen.current && form === null) addRef.current?.focus(); formWasOpen.current = form !== null; }, [form]); useEffect(() => { let cancelled = false; setState((current) => (current.kind === "ready" ? current : { kind: "loading" })); fetchChildren() .then((children) => { if (!cancelled) setState({ kind: "ready", children: sortChildren(children) }); }) .catch((error: unknown) => { if (cancelled) return; setState({ kind: "error", message: errorMessage(error, "Children could not be loaded") }); }); return () => { cancelled = true; }; }, [attempt]); useEffect(() => { let cancelled = false; setPreferences({ kind: "loading" }); fetchSettings() .then((settings) => { if (!cancelled) setPreferences({ kind: "ready", settings }); }) .catch((error: unknown) => { if (cancelled) return; setPreferences({ kind: "error", message: errorMessage(error, "Preferences could not be loaded") }); }); return () => { cancelled = true; }; }, [preferencesAttempt]); function onKeyDown(event: ReactKeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); if (!inFlight.current) onClose(); 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(); } } /** * Run one write, then refetch the list. One write at a time; a failure * keeps the screen (and any open form) as it was and says why. */ async function run(key: string, write: () => Promise, action: string): Promise { if (inFlight.current) return false; inFlight.current = true; setBusy(key); setActionError(null); setFormError(null); try { await write(); } catch (error) { const message = errorMessage(error, action); if (key === "form") setFormError(message); else setActionError(message); inFlight.current = false; setBusy(null); return false; } try { const children = await fetchChildren(); setState({ kind: "ready", children: sortChildren(children) }); } catch (error) { setActionError(errorMessage(error, "Saved, but the children list could not be refreshed")); } inFlight.current = false; setBusy(null); return true; } function closeForm() { setForm(null); setFormError(null); } async function submitForm(body: ChildCreateBody | ChildEditBody | string) { if (form === null) return; if (typeof body === "string") { setFormError(body); return; } const saved = form === "new" ? await run("form", () => createChild(body as ChildCreateBody), "The child could not be added") : await run("form", () => updateChild(form.id, body), "The child could not be saved"); if (saved) closeForm(); } async function savePreferences(changes: SettingsChanges | string) { if (typeof changes === "string") { setPreferencesMessage({ kind: changes === "No changes to save." ? "info" : "error", text: changes }); return; } if (inFlight.current) return; inFlight.current = true; setBusy("preferences"); setPreferencesMessage(null); try { const settings = await updateSettings(changes); setPreferences({ kind: "ready", settings }); setPreferencesMessage({ kind: "info", text: "Preferences saved." }); } catch (error) { setPreferencesMessage({ kind: "error", text: errorMessage(error, "The preferences could not be saved"), }); } inFlight.current = false; setBusy(null); } function move(children: AdminChild[], index: number, delta: -1 | 1) { const ids = children.map((c) => c.id); const target = index + delta; [ids[index], ids[target]] = [ids[target], ids[index]]; void run(`move-${children[index].id}`, () => reorderChildren(ids), "The order could not be saved"); } function toggleActive(child: AdminChild) { void run( `active-${child.id}`, () => setChildActive(child.id, !child.is_active), child.is_active ? "The child could not be deactivated" : "The child could not be reactivated", ); } let body; if (state.kind === "loading") { body = (
    Loading children…
    ); } else if (state.kind === "error") { body = (

    {state.message}

    ); } else { const { children } = state; body = ( <> {children.length > 0 ? (
      {children.map((child, index) => ( { setFormError(null); setForm(child); }} onMove={(delta) => move(children, index, delta)} onToggleActive={() => toggleActive(child)} /> ))}
    ) : (
    No children yet.
    )} {form !== null ? ( void submitForm(b)} /> ) : null} ); } return (

    Settings

    Household setup

    Children

    {actionError !== null ? (
    {actionError}
    ) : null} {body}

    Preferences

    {preferences.kind === "loading" ? (
    Loading preferences…
    ) : preferences.kind === "error" ? (

    {preferences.message}

    ) : ( void savePreferences(changes)} /> )}
    ); }