diff --git a/admin/src/api/settings.ts b/admin/src/api/settings.ts new file mode 100644 index 0000000..a27030b --- /dev/null +++ b/admin/src/api/settings.ts @@ -0,0 +1,39 @@ +/** + * Typed client for the admin household-settings routes + * (/api/v1/admin/settings). + */ +import { apiFetch } from "./client"; +import { readJson } from "./definitions"; + +export interface HouseholdSettings { + horizon_days: number; + /** Local wall-clock "HH:MM", as are the other *_time fields. */ + day_rollover_time: string; + notify_target: string; + morning_summary_time: string; + afternoon_reminder_time: string; + end_of_day_report_time: string; + morning_summary_enabled: boolean; + afternoon_reminder_enabled: boolean; + end_of_day_report_enabled: boolean; + celebration_enabled: boolean; +} + +/** Only the supplied fields are updated; the API answers with all of them. */ +export type SettingsChanges = Partial; + +export const SETTINGS_PATH = "/api/v1/admin/settings"; + +export async function fetchSettings(): Promise { + return readJson(await apiFetch(SETTINGS_PATH)); +} + +export async function updateSettings(changes: SettingsChanges): Promise { + return readJson( + await apiFetch(SETTINGS_PATH, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(changes), + }), + ); +} diff --git a/admin/src/settings/SettingsScreen.css b/admin/src/settings/SettingsScreen.css index d751ce9..68829b4 100644 --- a/admin/src/settings/SettingsScreen.css +++ b/admin/src/settings/SettingsScreen.css @@ -329,3 +329,39 @@ background: var(--nq-brand-gradient); color: #fff; } + +/* Preferences: one box per notification — its toggle, then its time. */ +.settings-notification { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid var(--nq-a-divider); + border-radius: var(--nq-a-density-radius); +} + +.settings-toggle { + display: flex; + align-items: center; + gap: 10px; + min-height: var(--nq-a-tap-min); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.settings-toggle > input { + width: 20px; + height: 20px; + margin: 0; +} + +.settings-notice { + margin: 0; + padding: 10px 12px; + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-divider); + color: var(--nq-a-ink-secondary); + font-size: 12.5px; + font-weight: 700; +} diff --git a/admin/src/settings/SettingsScreen.test.tsx b/admin/src/settings/SettingsScreen.test.tsx index fdd9916..94a0609 100644 --- a/admin/src/settings/SettingsScreen.test.tsx +++ b/admin/src/settings/SettingsScreen.test.tsx @@ -3,10 +3,25 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li import App from "../App"; import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen"; import type { AdminChild } from "../api/definitions"; +import type { HouseholdSettings } from "../api/settings"; import { storeTokens } from "../auth/oidc"; const CHILDREN_PATH = "/api/v1/admin/children"; const REORDER_PATH = `${CHILDREN_PATH}/reorder`; +const SETTINGS_PATH = "/api/v1/admin/settings"; + +const DEFAULT_SETTINGS: HouseholdSettings = { + horizon_days: 14, + day_rollover_time: "03:00", + notify_target: "notify.family", + morning_summary_time: "07:00", + afternoon_reminder_time: "15:30", + end_of_day_report_time: "20:00", + morning_summary_enabled: true, + afternoon_reminder_enabled: false, + end_of_day_report_enabled: true, + celebration_enabled: false, +}; function child( id: number, @@ -31,6 +46,7 @@ interface Call { } let children: AdminChild[]; +let settings: HouseholdSettings; /** Overrides the next write's response (the store is left untouched). */ let writeResponse: (() => Response) | null; let fetchMock: ReturnType; @@ -64,6 +80,11 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise { child(1, "Declan", 1, { colour: "#0ea5e9", avatar_ref: "fox" }), child(3, "Rory", 3, { is_active: false }), ]; + settings = { ...DEFAULT_SETTINGS }; writeResponse = null; fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); vi.stubGlobal("fetch", fetchMock); @@ -316,3 +338,177 @@ describe("SettingsScreen — children", () => { expect(document.activeElement).toBe(screen.getByRole("button", { name: "Settings" })); }); }); + +describe("SettingsScreen — preferences", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + children = [child(1, "Declan", 1)]; + settings = { ...DEFAULT_SETTINGS }; + writeResponse = null; + fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + async function renderPreferences(onClose = vi.fn()) { + render(); + await screen.findByTestId("settings-child-1"); + const form = await screen.findByRole("form", { name: "Preferences" }); + return { form, onClose }; + } + + function checkbox(form: HTMLElement, label: string): HTMLInputElement { + return within(form).getByRole("checkbox", { name: label }) as HTMLInputElement; + } + + function save(form: HTMLElement) { + fireEvent.click(within(form).getByRole("button", { name: "Save preferences" })); + } + + it("renders the current value of all ten fields", async () => { + const { form } = await renderPreferences(); + expect(field(form, "Planning horizon (days)").value).toBe("14"); + expect(field(form, "Day rollover").value).toBe("03:00"); + expect(field(form, "Notify target").value).toBe("notify.family"); + expect(field(form, "Morning summary time").value).toBe("07:00"); + expect(field(form, "Afternoon reminder time").value).toBe("15:30"); + expect(field(form, "End-of-day report time").value).toBe("20:00"); + expect(checkbox(form, "Morning summary").checked).toBe(true); + expect(checkbox(form, "Afternoon reminder").checked).toBe(false); + expect(checkbox(form, "End-of-day report").checked).toBe(true); + expect(checkbox(form, "Celebration").checked).toBe(false); + }); + + it("saving one changed field patches exactly that field", async () => { + const { form } = await renderPreferences(); + fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } }); + save(form); + await screen.findByText("Preferences saved."); + expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { horizon_days: 21 } }]); + const reloaded = screen.getByRole("form", { name: "Preferences" }); + expect(field(reloaded, "Planning horizon (days)").value).toBe("21"); + }); + + it("saving several changed fields sends exactly those", async () => { + const { form } = await renderPreferences(); + fireEvent.change(field(form, "Day rollover"), { target: { value: "04:15" } }); + fireEvent.change(field(form, "Notify target"), { target: { value: " notify.parents " } }); + fireEvent.change(field(form, "End-of-day report time"), { target: { value: "21:45" } }); + fireEvent.click(checkbox(form, "Afternoon reminder")); + fireEvent.click(checkbox(form, "Celebration")); + save(form); + await screen.findByText("Preferences saved."); + expect(writes()).toEqual([ + { + method: "PATCH", + path: SETTINGS_PATH, + body: { + day_rollover_time: "04:15", + notify_target: "notify.parents", + end_of_day_report_time: "21:45", + afternoon_reminder_enabled: true, + celebration_enabled: true, + }, + }, + ]); + const reloaded = screen.getByRole("form", { name: "Preferences" }); + expect(checkbox(reloaded, "Celebration").checked).toBe(true); + expect(field(reloaded, "Notify target").value).toBe("notify.parents"); + }); + + it("with nothing changed it says so and sends no request", async () => { + const { form } = await renderPreferences(); + fireEvent.click(checkbox(form, "Celebration")); + fireEvent.click(checkbox(form, "Celebration")); + save(form); + expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save."); + expect(writes()).toEqual([]); + }); + + it("a 422 shows the API detail naming the field and keeps the edit", async () => { + const { form, onClose } = await renderPreferences(); + fireEvent.change(field(form, "Notify target"), { target: { value: "notify.nobody" } }); + writeResponse = () => jsonResponse({ detail: "notify_target is not a notify service" }, 422); + save(form); + const message = await screen.findByTestId("preferences-message"); + expect(message.textContent).toBe( + "The preferences could not be saved: notify_target is not a notify service", + ); + expect(message.getAttribute("role")).toBe("alert"); + expect(field(form, "Notify target").value).toBe("notify.nobody"); + expect(writes()).toEqual([ + { method: "PATCH", path: SETTINGS_PATH, body: { notify_target: "notify.nobody" } }, + ]); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("a 403 on save shows the refusal message", async () => { + const { form } = await renderPreferences(); + fireEvent.click(checkbox(form, "Celebration")); + writeResponse = () => jsonResponse({ detail: "forbidden" }, 403); + save(form); + const message = await screen.findByTestId("preferences-message"); + expect(message.textContent).toContain("Your account is not in the nestquest-admins group."); + expect(checkbox(form, "Celebration").checked).toBe(true); + }); + + it.each([ + ["empty", ""], + ["zero", "0"], + ["oversized (Infinity would serialize as null and reset the field)", "9".repeat(400)], + ["past the safe-integer range", "123456789012345678901"], + ])("a %s horizon is rejected before any request", async (_case, value) => { + const { form } = await renderPreferences(); + fireEvent.change(field(form, "Planning horizon (days)"), { target: { value } }); + save(form); + expect(screen.getByTestId("preferences-message").textContent).toBe( + "Planning horizon must be a whole number of days, at least 1.", + ); + expect(writes()).toEqual([]); + }); + + it("disables every write control while a save is in flight (no double submit)", async () => { + const { form } = await renderPreferences(); + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => { + await gate; + return routeFetch(url, init); + }); + fireEvent.click(checkbox(form, "Celebration")); + const button = within(form).getByRole("button", { name: "Save preferences" }) as HTMLButtonElement; + fireEvent.click(button); + fireEvent.click(button); + await waitFor(() => expect(button.disabled).toBe(true)); + expect(checkbox(form, "Celebration").disabled).toBe(true); + expect(field(form, "Planning horizon (days)").disabled).toBe(true); + expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(true); + expect(writes()).toHaveLength(1); + release(); + await screen.findByText("Preferences saved."); + expect(writes()).toHaveLength(1); + }); + + it("a failed load shows the reason and Try again refetches", async () => { + let fail = true; + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + if (fail && new URL(url, "http://x").pathname === SETTINGS_PATH) { + return jsonResponse({ detail: "boom" }, 500); + } + return routeFetch(url, init); + }); + render(); + const error = await screen.findByTestId("preferences-error"); + expect(error.textContent).toContain("Preferences could not be loaded: boom"); + fail = false; + fireEvent.click(within(error).getByRole("button", { name: "Try again" })); + await screen.findByRole("form", { name: "Preferences" }); + }); +}); diff --git a/admin/src/settings/SettingsScreen.tsx b/admin/src/settings/SettingsScreen.tsx index 66c1602..57c46d4 100644 --- a/admin/src/settings/SettingsScreen.tsx +++ b/admin/src/settings/SettingsScreen.tsx @@ -1,11 +1,13 @@ /** * Settings — a pushed screen opened from the Today header's Settings button. * - * Its one section so far is Children: list, add, edit, deactivate/reactivate - * and reorder the household's child profiles. Every write refetches the list. + * Children: list, add, edit, deactivate/reactivate and reorder the + * household's child profiles. Every write refetches the list. * - * TODO: the other household settings (horizon days, day rollover time, - * notification config) are a separate later task. + * Preferences: the household settings (planning horizon, day rollover, + * notifications). Save patches only the changed fields. + * + * One write at a time across both sections. */ import { useEffect, @@ -24,6 +26,12 @@ import { 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"; @@ -219,6 +227,171 @@ function ChildForm({ ); } +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 & { 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)[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 })); + 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")} + + {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, @@ -314,6 +487,12 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) { 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); @@ -356,6 +535,22 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) { }; }, [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(); @@ -427,6 +622,29 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) { 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; @@ -554,6 +772,39 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) { ) : null} {body} +
+

Preferences

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

{preferences.message}

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