2026-09-24 15:06:22 -04:00
|
|
|
/**
|
|
|
|
|
* Settings — a pushed screen opened from the Today header's Settings button.
|
|
|
|
|
*
|
2026-09-24 16:17:52 -04:00
|
|
|
* Children: list, add, edit, deactivate/reactivate and reorder the
|
|
|
|
|
* household's child profiles. Every write refetches the list.
|
2026-09-24 15:06:22 -04:00
|
|
|
*
|
2026-09-24 16:17:52 -04:00
|
|
|
* Preferences: the household settings (planning horizon, day rollover,
|
|
|
|
|
* notifications). Save patches only the changed fields.
|
|
|
|
|
*
|
|
|
|
|
* One write at a time across both sections.
|
2026-09-24 15:06:22 -04:00
|
|
|
*/
|
|
|
|
|
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";
|
2026-09-24 16:17:52 -04:00
|
|
|
import {
|
|
|
|
|
fetchSettings,
|
|
|
|
|
updateSettings,
|
|
|
|
|
type HouseholdSettings,
|
|
|
|
|
type SettingsChanges,
|
|
|
|
|
} from "../api/settings";
|
2026-09-24 15:06:22 -04:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 16:17:52 -04:00
|
|
|
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$/;
|
|
|
|
|
|
|
|
|
|
/** 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 next: HouseholdSettings = {
|
|
|
|
|
...draft,
|
|
|
|
|
horizon_days: horizonDays,
|
|
|
|
|
notify_target: draft.notify_target.trim(),
|
|
|
|
|
};
|
|
|
|
|
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 }));
|
|
|
|
|
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")}
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 15:06:22 -04:00
|
|
|
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);
|
2026-09-24 16:17:52 -04:00
|
|
|
const [preferences, setPreferences] = useState<PreferencesState>({ kind: "loading" });
|
|
|
|
|
const [preferencesAttempt, setPreferencesAttempt] = useState(0);
|
|
|
|
|
const [preferencesMessage, setPreferencesMessage] = useState<{
|
|
|
|
|
kind: "error" | "info";
|
|
|
|
|
text: string;
|
|
|
|
|
} | null>(null);
|
2026-09-24 15:06:22 -04:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-09-24 16:17:52 -04:00
|
|
|
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]);
|
|
|
|
|
|
2026-09-24 15:06:22 -04:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 16:17:52 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 15:06:22 -04:00
|
|
|
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}
|
2026-09-24 16:17:52 -04:00
|
|
|
<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)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-09-24 15:06:22 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|