Compare commits

...
4 changed files with 526 additions and 4 deletions
+39
View File
@@ -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<HouseholdSettings>;
export const SETTINGS_PATH = "/api/v1/admin/settings";
export async function fetchSettings(): Promise<HouseholdSettings> {
return readJson<HouseholdSettings>(await apiFetch(SETTINGS_PATH));
}
export async function updateSettings(changes: SettingsChanges): Promise<HouseholdSettings> {
return readJson<HouseholdSettings>(
await apiFetch(SETTINGS_PATH, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
}),
);
}
+36
View File
@@ -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;
}
+196
View File
@@ -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<typeof vi.fn>;
@@ -64,6 +80,11 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
return respond();
}
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body };
return jsonResponse(settings);
}
if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, {
colour: body.colour ?? null,
@@ -124,6 +145,7 @@ describe("SettingsScreen — children", () => {
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(<SettingsScreen onClose={onClose} />);
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<void>((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(<SettingsScreen onClose={vi.fn()} />);
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" });
});
});
+255 -4
View File
@@ -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<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>
);
}
function ChildRow({
child,
index,
@@ -314,6 +487,12 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
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);
@@ -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<HTMLDivElement>) {
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 }) {
</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>
);