Replace the free-text Timezone box with a select of curated IANA zones,
an "API host local time" choice that clears the zone, and an "Other IANA
zone…" choice that opens a text entry for any other zone. A saved zone
outside the list opens in that entry on load. Helper text states this is
the application timezone and the source of local time for the panel,
scheduler and admin app. Saving still patches only changed fields, so a
timezone change sends exactly {"timezone": ...}; nothing is written on
load and the browser zone is never read.
API tests add invalid-zone 422 cases, a non-admin timezone PATCH refusal,
and a lone timezone PATCH sticking on a fresh database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
906 lines
30 KiB
TypeScript
906 lines
30 KiB
TypeScript
/**
|
|
* 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<Draft>(() => draftFor(target, siblings));
|
|
const nameRef = useRef<HTMLInputElement>(null);
|
|
const update = (patch: Partial<Draft>) => 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 (
|
|
<form className="settings-card settings-form" aria-label={title} onSubmit={submit} noValidate>
|
|
<h4 className="settings-form-title">{title}</h4>
|
|
<label className="settings-field">
|
|
<span className="settings-label">Display name</span>
|
|
<input
|
|
ref={nameRef}
|
|
className="settings-input"
|
|
type="text"
|
|
required
|
|
value={draft.name}
|
|
disabled={saving}
|
|
onChange={(event) => update({ name: event.target.value })}
|
|
/>
|
|
</label>
|
|
<div className="settings-field">
|
|
<label className="settings-field">
|
|
<span className="settings-label">Colour</span>
|
|
<input
|
|
className="settings-input"
|
|
type="text"
|
|
placeholder="#0ea5e9"
|
|
value={draft.colour}
|
|
disabled={saving}
|
|
onChange={(event) => update({ colour: event.target.value })}
|
|
/>
|
|
</label>
|
|
<div className="settings-swatches" role="group" aria-label="Colour presets">
|
|
{COLOUR_PRESETS.map((hex) => (
|
|
<button
|
|
key={hex}
|
|
type="button"
|
|
className="settings-swatch-option"
|
|
style={{ background: hex }}
|
|
aria-label={`Colour ${hex}`}
|
|
aria-pressed={draft.colour.toLowerCase() === hex}
|
|
disabled={saving}
|
|
onClick={() => update({ colour: hex })}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<label className="settings-field">
|
|
<span className="settings-label">Avatar ref (optional)</span>
|
|
<input
|
|
className="settings-input"
|
|
type="text"
|
|
value={draft.avatar}
|
|
disabled={saving}
|
|
onChange={(event) => update({ avatar: event.target.value })}
|
|
/>
|
|
</label>
|
|
<label className="settings-field">
|
|
<span className="settings-label">Order</span>
|
|
<input
|
|
className="settings-input"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draft.order}
|
|
disabled={saving}
|
|
onChange={(event) => update({ order: event.target.value })}
|
|
/>
|
|
</label>
|
|
{error ? (
|
|
<p className="settings-error" role="alert" data-testid="child-form-error">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
<div className="settings-form-footer">
|
|
<button type="button" className="settings-cancel" disabled={saving} onClick={onCancel}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" className="settings-save" disabled={saving} aria-busy={saving}>
|
|
{saving ? "Saving…" : isNew ? "Add child" : "Save child"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
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<HouseholdSettings, "horizon_days"> & { 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<string, unknown>)[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>(() => preferencesDraft(settings));
|
|
const update = (patch: Partial<PreferencesDraft>) => 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 (
|
|
<label className="settings-field">
|
|
<span className="settings-label">{label}</span>
|
|
<input
|
|
className="settings-input"
|
|
type="time"
|
|
value={draft[key]}
|
|
disabled={disabled}
|
|
onChange={(event) => update({ [key]: event.target.value })}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function toggle(key: ToggleField, label: string) {
|
|
return (
|
|
<label className="settings-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={draft[key]}
|
|
disabled={disabled}
|
|
onChange={(event) => update({ [key]: event.target.checked })}
|
|
/>
|
|
<span>{label}</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form className="settings-card settings-form" aria-label="Preferences" onSubmit={submit} noValidate>
|
|
<label className="settings-field">
|
|
<span className="settings-label">Planning horizon (days)</span>
|
|
<input
|
|
className="settings-input"
|
|
type="number"
|
|
min={1}
|
|
step={1}
|
|
inputMode="numeric"
|
|
value={draft.horizon_days}
|
|
disabled={disabled}
|
|
onChange={(event) => update({ horizon_days: event.target.value })}
|
|
/>
|
|
</label>
|
|
{timeInput("day_rollover_time", "Day rollover")}
|
|
<div className="settings-field">
|
|
<label className="settings-label" htmlFor="settings-timezone">
|
|
Timezone
|
|
</label>
|
|
<select
|
|
id="settings-timezone"
|
|
className="settings-input"
|
|
aria-describedby="settings-timezone-help"
|
|
value={otherZone ? OTHER_ZONE : draft.timezone}
|
|
disabled={disabled}
|
|
onChange={(event) => {
|
|
const choice = event.target.value;
|
|
setOtherZone(choice === OTHER_ZONE);
|
|
if (choice !== OTHER_ZONE) update({ timezone: choice });
|
|
}}
|
|
>
|
|
<option value="">API host local time (no zone set)</option>
|
|
{COMMON_TIMEZONES.map((zone) => (
|
|
<option key={zone} value={zone}>
|
|
{zone}
|
|
</option>
|
|
))}
|
|
<option value={OTHER_ZONE}>Other IANA zone…</option>
|
|
</select>
|
|
{otherZone ? (
|
|
<input
|
|
className="settings-input"
|
|
type="text"
|
|
aria-label="Other timezone"
|
|
placeholder="Area/Location, e.g. America/Boise"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
value={draft.timezone}
|
|
disabled={disabled}
|
|
onChange={(event) => update({ timezone: event.target.value })}
|
|
/>
|
|
) : null}
|
|
<p className="settings-hint" id="settings-timezone-help">
|
|
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.
|
|
</p>
|
|
</div>
|
|
<label className="settings-field">
|
|
<span className="settings-label">Notify target</span>
|
|
<input
|
|
className="settings-input"
|
|
type="text"
|
|
value={draft.notify_target}
|
|
disabled={disabled}
|
|
onChange={(event) => update({ notify_target: event.target.value })}
|
|
/>
|
|
</label>
|
|
{NOTIFICATIONS.map((n) => (
|
|
<div key={n.toggle} className="settings-notification">
|
|
{toggle(n.toggle, n.label)}
|
|
{timeInput(n.time, `${n.label} time`)}
|
|
</div>
|
|
))}
|
|
{toggle("celebration_enabled", "Celebration")}
|
|
{message ? (
|
|
<p
|
|
className={message.kind === "error" ? "settings-error" : "settings-notice"}
|
|
role={message.kind === "error" ? "alert" : "status"}
|
|
data-testid="preferences-message"
|
|
>
|
|
{message.text}
|
|
</p>
|
|
) : null}
|
|
<div className="settings-form-footer">
|
|
<button type="submit" className="settings-save" disabled={disabled} aria-busy={saving}>
|
|
{saving ? "Saving…" : "Save preferences"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<li
|
|
className={child.is_active ? "settings-child" : "settings-child settings-child--inactive"}
|
|
data-testid={`settings-child-${child.id}`}
|
|
>
|
|
<span
|
|
className="settings-swatch"
|
|
data-testid={`settings-swatch-${child.id}`}
|
|
style={{ background: child.colour ?? "var(--nq-a-border)" }}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="settings-child-text">
|
|
<div className="settings-child-name">{child.display_name}</div>
|
|
<div className="settings-child-meta">
|
|
Order {child.sort_order} · {child.colour ?? "no colour"}
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={child.is_active ? "settings-pill settings-pill--active" : "settings-pill"}
|
|
data-testid={`settings-active-${child.id}`}
|
|
>
|
|
{child.is_active ? "Active" : "Inactive"}
|
|
</span>
|
|
<div className="settings-child-actions">
|
|
<button
|
|
type="button"
|
|
className="settings-icon-button"
|
|
aria-label={`Move ${child.display_name} up`}
|
|
aria-busy={busy === `move-${child.id}`}
|
|
disabled={locked || index === 0}
|
|
onClick={() => onMove(-1)}
|
|
>
|
|
<ArrowUpGlyph />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="settings-icon-button"
|
|
aria-label={`Move ${child.display_name} down`}
|
|
aria-busy={busy === `move-${child.id}`}
|
|
disabled={locked || index === count - 1}
|
|
onClick={() => onMove(1)}
|
|
>
|
|
<ArrowDownGlyph />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="settings-action"
|
|
aria-label={`Edit ${child.display_name}`}
|
|
disabled={locked}
|
|
onClick={onEdit}
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="settings-action"
|
|
aria-label={`${child.is_active ? "Deactivate" : "Reactivate"} ${child.display_name}`}
|
|
aria-busy={toggling}
|
|
disabled={locked}
|
|
onClick={onToggleActive}
|
|
>
|
|
{toggling ? "Saving…" : child.is_active ? "Deactivate" : "Reactivate"}
|
|
</button>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
|
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
|
const [attempt, setAttempt] = useState(0);
|
|
const [form, setForm] = useState<FormTarget>(null);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [preferences, setPreferences] = useState<PreferencesState>({ 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<HTMLDivElement>(null);
|
|
const titleRef = useRef<HTMLHeadingElement>(null);
|
|
const addRef = useRef<HTMLButtonElement>(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<HTMLDivElement>) {
|
|
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<HTMLElement>(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<unknown>, action: string): Promise<boolean> {
|
|
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 = (
|
|
<div className="settings-status" role="status" data-testid="settings-loading">
|
|
Loading children…
|
|
</div>
|
|
);
|
|
} else if (state.kind === "error") {
|
|
body = (
|
|
<div className="settings-status settings-status--error" role="alert" data-testid="settings-error">
|
|
<p>{state.message}</p>
|
|
<button type="button" className="settings-action" onClick={() => setAttempt((n) => n + 1)}>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
);
|
|
} else {
|
|
const { children } = state;
|
|
body = (
|
|
<>
|
|
{children.length > 0 ? (
|
|
<ul className="settings-card settings-list" aria-label="Children">
|
|
{children.map((child, index) => (
|
|
<ChildRow
|
|
key={child.id}
|
|
child={child}
|
|
index={index}
|
|
count={children.length}
|
|
busy={busy}
|
|
onEdit={() => {
|
|
setFormError(null);
|
|
setForm(child);
|
|
}}
|
|
onMove={(delta) => move(children, index, delta)}
|
|
onToggleActive={() => toggleActive(child)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<div className="settings-card settings-empty">No children yet.</div>
|
|
)}
|
|
{form !== null ? (
|
|
<ChildForm
|
|
key={form === "new" ? "new" : form.id}
|
|
target={form}
|
|
siblings={children}
|
|
saving={busy === "form"}
|
|
error={formError}
|
|
onCancel={closeForm}
|
|
onSubmit={(b) => void submitForm(b)}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={screenRef}
|
|
className="settings-screen"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="settings-title"
|
|
onKeyDown={onKeyDown}
|
|
>
|
|
<header className="settings-header">
|
|
<button
|
|
type="button"
|
|
className="settings-back"
|
|
aria-label="Back"
|
|
disabled={busy !== null}
|
|
onClick={onClose}
|
|
>
|
|
<ChevronLeftGlyph size={20} />
|
|
</button>
|
|
<div>
|
|
<h2 className="settings-title" id="settings-title" ref={titleRef} tabIndex={-1}>
|
|
Settings
|
|
</h2>
|
|
<p className="settings-sub">Household setup</p>
|
|
</div>
|
|
</header>
|
|
<div
|
|
className="settings-scroll"
|
|
data-testid="settings-scroll"
|
|
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
|
>
|
|
<div className="settings-section-head">
|
|
<h3 className="settings-section-label">Children</h3>
|
|
<button
|
|
ref={addRef}
|
|
type="button"
|
|
className="settings-add"
|
|
disabled={state.kind !== "ready" || busy !== null || form === "new"}
|
|
onClick={() => {
|
|
setFormError(null);
|
|
setForm("new");
|
|
}}
|
|
>
|
|
<PlusGlyph />
|
|
Add child
|
|
</button>
|
|
</div>
|
|
{actionError !== null ? (
|
|
<div className="settings-action-error" role="alert" data-testid="settings-action-error">
|
|
<span>{actionError}</span>
|
|
<button type="button" className="settings-action" onClick={() => setActionError(null)}>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{body}
|
|
<div className="settings-section-head">
|
|
<h3 className="settings-section-label">Preferences</h3>
|
|
</div>
|
|
{preferences.kind === "loading" ? (
|
|
<div className="settings-status" role="status" data-testid="preferences-loading">
|
|
Loading preferences…
|
|
</div>
|
|
) : preferences.kind === "error" ? (
|
|
<div
|
|
className="settings-status settings-status--error"
|
|
role="alert"
|
|
data-testid="preferences-error"
|
|
>
|
|
<p>{preferences.message}</p>
|
|
<button
|
|
type="button"
|
|
className="settings-action"
|
|
onClick={() => setPreferencesAttempt((n) => n + 1)}
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<PreferencesForm
|
|
// A saved response remounts the form so its draft matches the store.
|
|
key={JSON.stringify(preferences.settings)}
|
|
settings={preferences.settings}
|
|
saving={busy === "preferences"}
|
|
locked={busy !== null && busy !== "preferences"}
|
|
message={preferencesMessage}
|
|
onSubmit={(changes) => void savePreferences(changes)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|