Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c85b7bbd7 | ||
|
|
5650a1f5a2 | ||
|
|
7acc6501d9 | ||
|
|
345a5129b6 | ||
|
|
3d293e0431 | ||
|
|
9cdc69ea72 | ||
|
|
1eb136fc67 |
@@ -1,5 +1,73 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.7.1 — 2026-09-24
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release bringing the admin PWA follow-ups and its hosting deployment from
|
||||
`dev` onto 0.7.0.
|
||||
|
||||
- **Feature 24 — Admin PWA Hosting:** the admin PWA's static hosting is now
|
||||
reproducible from the repository — `deploy/pwa/Dockerfile` (multi-stage, pinned
|
||||
`node`/`nginx` base images by digest, `VITE_AUTHENTIK_CLIENT_ID` /
|
||||
`VITE_AUTHENTIK_ISSUER` / `VITE_API_BASE_URL` build args), `deploy/pwa/nginx.conf`
|
||||
(SPA fallback plus a source allow-list for the reverse proxy and the host), a
|
||||
`pwa` service in `deploy/compose.yaml` published on `:8081`, and a
|
||||
`deploy/RUNBOOK.md` section describing the build/serve and the Traefik
|
||||
`nestquest-app` router. (PRs #220, #221.)
|
||||
- **Feature 19 follow-up — Manage children:** the Today header's Settings button
|
||||
now opens a Settings screen. Its **Children** section lists the household's
|
||||
children and lets a parent add (POST `/api/v1/admin/children`), edit
|
||||
(PATCH `.../{id}`), activate/deactivate (PATCH `.../{id}/active`) and reorder
|
||||
them (POST `.../reorder`). (PRs #222, #223.)
|
||||
- **Feature 19 follow-up — Settings preferences:** the same screen has a
|
||||
**Preferences** section for the planning horizon, day rollover time, notify
|
||||
target, the morning/afternoon/end-of-day notification times and their toggles,
|
||||
and celebration, read from `GET /api/v1/admin/settings` and saved with a partial
|
||||
`PATCH` of only the changed fields. (PRs #224, #225, #226.)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
- A running NestQuest API service as in 0.7.0 (the Feature 17 deployment); the
|
||||
`admin/` PWA is served from the owner's deployment at
|
||||
`https://nestquest.cubecraftlabs.com`, not by HACS.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **None.** The schema is unchanged at version 8 and no migration runs.
|
||||
`custom_components/` is unchanged from 0.7.0 apart from the manifest
|
||||
release-version metadata — no Home Assistant entity, service, event or
|
||||
automation surface changes. This release adds admin PWA screens and deployment
|
||||
artifacts only.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **HACS does not ship the admin PWA or the deployment artifacts.** HACS copies
|
||||
only `custom_components/nestquest`, so upgrading via HACS changes nothing
|
||||
functional over 0.7.0; the PWA screens and hosting are deployed from `admin/`
|
||||
and `deploy/` per the runbook.
|
||||
- Serving the PWA and API still requires the Feature 17 deployment (host, reverse
|
||||
proxy, Authentik, backup target) as documented in `deploy/RUNBOOK.md`.
|
||||
- **Feature 12's TouchHub/DAKOS runbook task remains physically unverified**
|
||||
(needs the wall panel).
|
||||
- The Settings UI does not expose resetting a field to its default with `null`.
|
||||
|
||||
### Rollback
|
||||
|
||||
Schema is unchanged (8), so no migration is involved in either direction.
|
||||
`custom_components/` is unchanged from 0.7.0 apart from the release-version
|
||||
metadata; rolling back the integration is a plain swap, and the PWA/hosting can be
|
||||
redeployed from the 0.7.0 tree.
|
||||
|
||||
**HACS installs:** HACS > Integrations > NestQuest > Redownload > 0.7.0, then
|
||||
restart Home Assistant.
|
||||
|
||||
**Manual installs:** replace `custom_components/nestquest/` with the 0.7.0 tree,
|
||||
then restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.7.1 release merge on `main`.
|
||||
|
||||
## Version 0.7.0 — 2026-09-24
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -362,6 +362,39 @@ describe("DefinitionsTab", () => {
|
||||
expect(screen.getByText("4 definitions · 3 children")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("the weekday picker lists Sunday first and Sunday still stores 6", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
|
||||
await renderReady();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Feed the cat" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||
target: { value: "weekly" },
|
||||
});
|
||||
|
||||
const days = within(s).getByRole("group", { name: "Days" });
|
||||
expect(
|
||||
within(days)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.getAttribute("aria-label")),
|
||||
).toEqual(["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]);
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Sunday" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
const body = api.writes()[0].body as { rule: DefinitionRule };
|
||||
expect(body.rule.weekday_set).toEqual([2, 6]);
|
||||
expect(meta(await screen.findByTestId("definition-99"))).toContain("on Sun, Wed");
|
||||
});
|
||||
|
||||
it("tapping a row opens the sheet in editing state and saving PATCHes", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("definition-11");
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../api/definitions";
|
||||
import {
|
||||
MONTH_SHORT,
|
||||
WEEKDAY_DISPLAY_ORDER,
|
||||
WEEKDAY_SHORT,
|
||||
WINDOW_LABELS,
|
||||
WINDOW_ORDER,
|
||||
@@ -680,9 +681,9 @@ function EditSheet({
|
||||
</div>
|
||||
{draft.repeats === "weekly" || draft.repeats === "custom" ? (
|
||||
<div className="defs-weekdays" role="group" aria-label="Days">
|
||||
{WEEKDAY_SHORT.map((label, day) => (
|
||||
{WEEKDAY_DISPLAY_ORDER.map((day) => (
|
||||
<button
|
||||
key={label}
|
||||
key={day}
|
||||
type="button"
|
||||
aria-pressed={draft.weekdays.includes(day)}
|
||||
aria-label={WEEKDAY_LONG[day]}
|
||||
@@ -691,7 +692,7 @@ function EditSheet({
|
||||
}
|
||||
onClick={() => update({ weekdays: toggle(draft.weekdays, day) })}
|
||||
>
|
||||
{label.charAt(0)}
|
||||
{WEEKDAY_SHORT[day].charAt(0)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,9 @@ export const WINDOW_LABELS: Record<WindowName, string> = {
|
||||
/** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */
|
||||
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
/** Display order for a week: Sunday first. The stored indices stay Monday=0. */
|
||||
export const WEEKDAY_DISPLAY_ORDER = [6, 0, 1, 2, 3, 4, 5];
|
||||
|
||||
export const MONTH_SHORT = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
@@ -51,7 +54,7 @@ export function assigneeSummary(
|
||||
|
||||
function weekdayList(days: number[] | null): string {
|
||||
return [...(days ?? [])]
|
||||
.sort((a, b) => a - b)
|
||||
.sort((a, b) => WEEKDAY_DISPLAY_ORDER.indexOf(a) - WEEKDAY_DISPLAY_ORDER.indexOf(b))
|
||||
.map((day) => WEEKDAY_SHORT[day])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
@@ -131,10 +131,20 @@ describe("ScheduleTab", () => {
|
||||
expect(today.className).toContain("schedule-day--today");
|
||||
expect(today.dataset.state).toBe("home");
|
||||
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
|
||||
// September 2026 starts on a Tuesday: one leading pad after the initials.
|
||||
// Sunday-first header; September 2026 starts on a Tuesday: two leading pads.
|
||||
const grid = screen.getByRole("group", { name: "Month grid" });
|
||||
expect(Array.from(grid.children, (c) => c.textContent).slice(0, 7)).toEqual([
|
||||
"S",
|
||||
"M",
|
||||
"T",
|
||||
"W",
|
||||
"T",
|
||||
"F",
|
||||
"S",
|
||||
]);
|
||||
expect(grid.children[7].tagName).toBe("SPAN");
|
||||
expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01");
|
||||
expect(grid.children[8].tagName).toBe("SPAN");
|
||||
expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01");
|
||||
|
||||
const legend = screen.getByRole("list", { name: "Legend" });
|
||||
expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
|
||||
|
||||
@@ -46,7 +46,8 @@ export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
|
||||
const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
|
||||
const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
|
||||
/** Sunday-first header; `weekdayOf` stays Monday=0. */
|
||||
const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
export const STATE_LABELS: Record<DayState, string> = {
|
||||
home: "Home",
|
||||
@@ -130,7 +131,7 @@ interface MonthGridProps {
|
||||
|
||||
function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) {
|
||||
const dates = monthDates(month);
|
||||
const leading = weekdayOf(month);
|
||||
const leading = (weekdayOf(month) + 1) % 7;
|
||||
return (
|
||||
<>
|
||||
<div className="schedule-month-head">
|
||||
|
||||
@@ -58,6 +58,24 @@ describe("calendar helpers", () => {
|
||||
expect(weekdayOf("2026-09-06")).toBe(6);
|
||||
});
|
||||
|
||||
it("weekdayOf keeps the stored Monday=0 .. Sunday=6 numbering (display order is separate)", () => {
|
||||
// A full known week plus dates across month and year ends.
|
||||
expect(
|
||||
[
|
||||
"2026-09-07",
|
||||
"2026-09-08",
|
||||
"2026-09-09",
|
||||
"2026-09-10",
|
||||
"2026-09-11",
|
||||
"2026-09-12",
|
||||
"2026-09-13",
|
||||
].map(weekdayOf),
|
||||
).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
expect(weekdayOf("2026-11-01")).toBe(6); // Sunday
|
||||
expect(weekdayOf("2027-01-01")).toBe(4); // Friday
|
||||
expect(weekdayOf("2024-02-29")).toBe(3); // Thursday
|
||||
});
|
||||
|
||||
it("monthStart shifts across year ends", () => {
|
||||
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
|
||||
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.7.0"
|
||||
"version": "0.7.1"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
Reference in New Issue
Block a user