diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8125b58..74861ea 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/admin/src/api/children.ts b/admin/src/api/children.ts new file mode 100644 index 0000000..90333ff --- /dev/null +++ b/admin/src/api/children.ts @@ -0,0 +1,50 @@ +/** + * Typed client for the admin child-profile write routes + * (/api/v1/admin/children). The list read is `fetchChildren` in ./definitions. + */ +import { apiFetch } from "./client"; +import { CHILDREN_PATH, readJson, type AdminChild } from "./definitions"; + +export interface ChildCreateBody { + display_name: string; + colour?: string; + avatar_ref?: string; + sort_order?: number; +} + +/** + * Omitted fields stay unchanged. The API rejects an explicit null, so a set + * colour or avatar cannot be cleared through this route. + */ +export type ChildEditBody = Partial; + +export const CHILDREN_REORDER_PATH = `${CHILDREN_PATH}/reorder`; + +function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise { + return apiFetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export async function createChild(body: ChildCreateBody): Promise { + return readJson(await sendJson(CHILDREN_PATH, "POST", body)); +} + +export async function updateChild(id: number, body: ChildEditBody): Promise { + return readJson(await sendJson(`${CHILDREN_PATH}/${id}`, "PATCH", body)); +} + +export async function setChildActive(id: number, isActive: boolean): Promise { + return readJson( + await sendJson(`${CHILDREN_PATH}/${id}/active`, "PATCH", { is_active: isActive }), + ); +} + +/** `orderedIds` must be every child id in the household, in the new order. */ +export async function reorderChildren(orderedIds: number[]): Promise { + await readJson<{ status: string }>( + await sendJson(CHILDREN_REORDER_PATH, "POST", { ordered_ids: orderedIds }), + ); +} 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/schedule/OverrideEditor.tsx b/admin/src/schedule/OverrideEditor.tsx index 964c6ad..3a6ca4a 100644 --- a/admin/src/schedule/OverrideEditor.tsx +++ b/admin/src/schedule/OverrideEditor.tsx @@ -45,7 +45,7 @@ function errorMessage(error: unknown, fallback: string): string { return fallback; } -const FOCUSABLE = +export const FOCUSABLE = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; /** @@ -53,7 +53,7 @@ const FOCUSABLE = * ), so the tab bar behind the pushed screen cannot take focus. * Returns the undo. */ -function inertOutside(root: HTMLElement): () => void { +export function inertOutside(root: HTMLElement): () => void { const made: HTMLElement[] = []; for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) { for (const sibling of Array.from(node.parentElement.children)) { diff --git a/admin/src/settings/SettingsScreen.css b/admin/src/settings/SettingsScreen.css new file mode 100644 index 0000000..68829b4 --- /dev/null +++ b/admin/src/settings/SettingsScreen.css @@ -0,0 +1,367 @@ +/* Settings — pushed screen over the Today tab (and the tab bar), like the + presence override editor. Compact density list, roomy form card. */ + +.settings-screen { + position: fixed; + inset: 0; + z-index: 30; + overflow-y: auto; + background: var(--nq-a-page); +} + +.settings-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); +} + +.settings-back { + flex: none; + display: grid; + place-items: center; + width: var(--nq-a-tap-min); + height: var(--nq-a-tap-min); + margin: -6px 0 -6px -12px; + padding: 0; + border: 0; + background: none; + color: var(--nq-a-ink); + cursor: pointer; +} + +.settings-title { + margin: 0; + font-size: var(--nq-a-size-screen); + font-weight: 800; + letter-spacing: -0.02em; +} + +.settings-sub { + margin: 2px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */ +.settings-scroll { + display: flex; + flex-direction: column; + gap: var(--nq-a-density-gap); + width: 100%; + max-width: 640px; + margin: 0 auto; + padding: 14px 16px; +} + +.settings-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.settings-section-label { + margin: 0; + font-size: var(--nq-a-size-label); + font-weight: 700; + text-transform: uppercase; + letter-spacing: var(--nq-a-track-label); + color: var(--nq-a-ink-secondary); +} + +.settings-add { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 36px; + padding: 0 12px; + border: 0; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-brand-gradient); + color: #fff; + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; +} + +.settings-card { + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); +} + +.settings-list { + margin: 0; + padding: 0; + list-style: none; +} + +.settings-list > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.settings-child { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: var(--nq-a-density-row-pad); +} + +.settings-child--inactive .settings-child-name { + color: var(--nq-a-ink-tertiary); +} + +.settings-swatch { + flex: none; + width: 22px; + height: 22px; + border-radius: var(--nq-a-radius-pill); + border: 1px solid rgba(9, 9, 11, 0.12); +} + +.settings-child-text { + flex: 1; + min-width: 0; +} + +.settings-child-name { + font-size: var(--nq-a-size-row); + font-weight: 700; +} + +.settings-child-meta { + margin-top: 1px; + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-tertiary); +} + +.settings-pill { + flex: none; + padding: 2px 8px; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-divider); + color: var(--nq-a-ink-secondary); + font-size: var(--nq-a-size-meta); + font-weight: 700; +} + +.settings-pill--active { + background: #dcfce7; + color: var(--nq-a-success); +} + +.settings-child-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + width: 100%; +} + +.settings-icon-button, +.settings-action { + min-height: 36px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.settings-icon-button { + display: grid; + place-items: center; + width: 36px; + padding: 0; +} + +.settings-action { + padding: 0 12px; +} + +.settings-icon-button:disabled, +.settings-action:disabled, +.settings-add:disabled, +.settings-back:disabled, +.settings-cancel:disabled, +.settings-save:disabled { + opacity: 0.45; + cursor: default; +} + +.settings-empty, +.settings-status { + padding: 16px; + font-size: 13px; + font-weight: 600; + color: var(--nq-a-ink-secondary); + text-align: center; +} + +.settings-status--error { + color: var(--nq-a-danger-strong); +} + +.settings-status p { + margin: 0 0 10px; +} + +.settings-action-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-danger-bg); + color: var(--nq-a-danger-strong); + font-size: 12.5px; + font-weight: 700; +} + +/* Add / edit form: roomy density, 18px padding, 16px gaps. */ +.settings-form { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px; +} + +.settings-form-title { + margin: 0; + font-size: 17px; + font-weight: 800; +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.settings-label { + font-size: var(--nq-a-size-label); + font-weight: 700; + text-transform: uppercase; + letter-spacing: var(--nq-a-track-label); + color: var(--nq-a-ink-secondary); +} + +.settings-input { + width: 100%; + min-height: var(--nq-a-tap-min); + padding: 13px; + border: 1px solid var(--nq-a-border); + border-radius: 10px; + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 14px; + font-weight: 600; +} + +.settings-swatches { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.settings-swatch-option { + width: 32px; + height: 32px; + padding: 0; + border: 2px solid transparent; + border-radius: var(--nq-a-radius-pill); + cursor: pointer; +} + +.settings-swatch-option[aria-pressed="true"] { + border-color: var(--nq-a-ink); + box-shadow: inset 0 0 0 2px var(--nq-a-surface); +} + +.settings-error { + margin: 0; + padding: 10px 12px; + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-danger-bg); + color: var(--nq-a-danger-strong); + font-size: 12.5px; + font-weight: 700; +} + +.settings-form-footer { + display: flex; + gap: 10px; +} + +.settings-cancel, +.settings-save { + padding: 15px 12px; + border-radius: 12px; + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.settings-cancel { + flex: 1; + border: 1px solid var(--nq-a-border); + background: var(--nq-a-surface); + color: var(--nq-a-ink); +} + +.settings-save { + flex: 2; + border: 0; + 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 new file mode 100644 index 0000000..94a0609 --- /dev/null +++ b/admin/src/settings/SettingsScreen.test.tsx @@ -0,0 +1,514 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +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, + display_name: string, + sort_order: number, + extra: Partial = {}, +): AdminChild { + return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true, ...extra }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +interface Call { + method: string; + path: string; + body: unknown; +} + +let children: AdminChild[]; +let settings: HouseholdSettings; +/** Overrides the next write's response (the store is left untouched). */ +let writeResponse: (() => Response) | null; +let fetchMock: ReturnType; + +function calls(): Call[] { + return fetchMock.mock.calls.map((args: unknown[]) => { + const [url, init] = args as [string, RequestInit | undefined]; + return { + method: init?.method ?? "GET", + path: new URL(String(url), "http://x").pathname, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + }); +} + +function writes(): Call[] { + return calls().filter((c) => c.method !== "GET"); +} + +function childGets(): number { + return calls().filter((c) => c.method === "GET" && c.path === CHILDREN_PATH).length; +} + +async function routeFetch(url: string, init: RequestInit = {}): Promise { + const path = new URL(url, "http://x").pathname; + const method = init.method ?? "GET"; + const body = init.body ? JSON.parse(String(init.body)) : undefined; + if (method !== "GET" && writeResponse) { + const respond = writeResponse; + writeResponse = null; + 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, + avatar_ref: body.avatar_ref ?? null, + }); + children = [...children, created]; + return jsonResponse(created, 201); + } + if (path === REORDER_PATH && method === "POST") { + const ids = body.ordered_ids as number[]; + children = children.map((c) => ({ ...c, sort_order: ids.indexOf(c.id) + 1 })); + return jsonResponse({ status: "ok" }); + } + const activeMatch = /\/children\/(\d+)\/active$/.exec(path); + if (activeMatch && method === "PATCH") { + const id = Number(activeMatch[1]); + children = children.map((c) => (c.id === id ? { ...c, is_active: body.is_active } : c)); + return jsonResponse(children.find((c) => c.id === id)); + } + const editMatch = /\/children\/(\d+)$/.exec(path); + if (editMatch && method === "PATCH") { + const id = Number(editMatch[1]); + children = children.map((c) => (c.id === id ? { ...c, ...body } : c)); + return jsonResponse(children.find((c) => c.id === id)); + } + if (path.endsWith("/admin/snapshot")) { + return jsonResponse({ today_iso: "2026-09-23", cycle_day: 5, children: [] }); + } + return jsonResponse({ detail: "unexpected" }, 500); +} + +function row(id: number) { + return within(screen.getByTestId(`settings-child-${id}`)); +} + +function listNames(): string[] { + return within(screen.getByRole("list", { name: "Children" })) + .getAllByRole("listitem") + .map((li) => li.querySelector(".settings-child-name")?.textContent ?? ""); +} + +function field(form: HTMLElement, label: string): HTMLInputElement { + return within(form).getByLabelText(label) as HTMLInputElement; +} + +async function renderScreen(onClose = vi.fn()) { + render(); + await screen.findByTestId("settings-child-1"); + return onClose; +} + +describe("SettingsScreen — children", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + children = [ + child(2, "Maeve", 2, { colour: "#16a34a" }), + 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); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("lists each child with name, colour swatch, active state and order", async () => { + await renderScreen(); + expect(listNames()).toEqual(["Declan", "Maeve", "Rory"]); + expect(row(1).getByText("Order 1 · #0ea5e9")).toBeTruthy(); + expect(screen.getByTestId("settings-swatch-1").style.background).toBe("rgb(14, 165, 233)"); + expect(screen.getByTestId("settings-active-1").textContent).toBe("Active"); + expect(row(3).getByText("Order 3 · no colour")).toBeTruthy(); + expect(screen.getByTestId("settings-swatch-3").style.background).toBe("var(--nq-a-border)"); + expect(screen.getByTestId("settings-active-3").textContent).toBe("Inactive"); + expect(screen.getByTestId("settings-scroll").style.paddingBottom).toBe(SCROLL_BOTTOM_PADDING); + }); + + it("Add posts exactly the entered values, then shows the new child", async () => { + await renderScreen(); + fireEvent.click(screen.getByRole("button", { name: "Add child" })); + const form = screen.getByRole("form", { name: "Add child" }); + expect(field(form, "Order").value).toBe("4"); + fireEvent.change(field(form, "Display name"), { target: { value: " Nora " } }); + fireEvent.click(within(form).getByRole("button", { name: "Colour #7c3aed" })); + fireEvent.change(field(form, "Avatar ref (optional)"), { target: { value: "owl" } }); + fireEvent.change(field(form, "Order"), { target: { value: "5" } }); + const gets = childGets(); + fireEvent.click(within(form).getByRole("button", { name: "Add child" })); + + await screen.findByTestId("settings-child-9"); + expect(writes()).toEqual([ + { + method: "POST", + path: CHILDREN_PATH, + body: { display_name: "Nora", colour: "#7c3aed", avatar_ref: "owl", sort_order: 5 }, + }, + ]); + expect(childGets()).toBe(gets + 1); + expect(listNames()).toEqual(["Declan", "Maeve", "Rory", "Nora"]); + expect(screen.queryByRole("form", { name: "Add child" })).toBeNull(); + }); + + it("Add omits optional fields left blank and rejects a missing name", async () => { + await renderScreen(); + fireEvent.click(screen.getByRole("button", { name: "Add child" })); + const form = screen.getByRole("form", { name: "Add child" }); + fireEvent.change(field(form, "Order"), { target: { value: "" } }); + fireEvent.click(within(form).getByRole("button", { name: "Add child" })); + expect(screen.getByTestId("child-form-error").textContent).toBe("Enter a display name."); + expect(writes()).toEqual([]); + + fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } }); + fireEvent.click(within(form).getByRole("button", { name: "Add child" })); + await screen.findByTestId("settings-child-9"); + expect(writes()[0].body).toEqual({ display_name: "Nora" }); + }); + + it("Edit patches only the changed fields", async () => { + await renderScreen(); + fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" })); + const form = screen.getByRole("form", { name: "Edit Declan" }); + expect(field(form, "Display name").value).toBe("Declan"); + expect(field(form, "Colour").value).toBe("#0ea5e9"); + fireEvent.change(field(form, "Display name"), { target: { value: "Dec" } }); + fireEvent.change(field(form, "Colour"), { target: { value: "#FF8800" } }); + fireEvent.click(within(form).getByRole("button", { name: "Save child" })); + + await waitFor(() => expect(row(1).getByText("Dec")).toBeTruthy()); + expect(writes()).toEqual([ + { method: "PATCH", path: `${CHILDREN_PATH}/1`, body: { display_name: "Dec", colour: "#FF8800" } }, + ]); + expect(row(1).getByText("Order 1 · #FF8800")).toBeTruthy(); + }); + + it("Edit refuses to clear a set colour instead of sending null", async () => { + await renderScreen(); + fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" })); + const form = screen.getByRole("form", { name: "Edit Declan" }); + fireEvent.change(field(form, "Colour"), { target: { value: "" } }); + fireEvent.click(within(form).getByRole("button", { name: "Save child" })); + expect(screen.getByTestId("child-form-error").textContent).toMatch(/cannot be removed/); + expect(writes()).toEqual([]); + }); + + it("the active toggle patches /children/{id}/active with the new value", async () => { + await renderScreen(); + fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" })); + await waitFor(() => expect(screen.getByTestId("settings-active-1").textContent).toBe("Inactive")); + fireEvent.click(row(3).getByRole("button", { name: "Reactivate Rory" })); + await waitFor(() => expect(screen.getByTestId("settings-active-3").textContent).toBe("Active")); + expect(writes()).toEqual([ + { method: "PATCH", path: `${CHILDREN_PATH}/1/active`, body: { is_active: false } }, + { method: "PATCH", path: `${CHILDREN_PATH}/3/active`, body: { is_active: true } }, + ]); + }); + + it("reorder posts the complete ordered id list, then shows the new order", async () => { + await renderScreen(); + expect((row(1).getByRole("button", { name: "Move Declan up" }) as HTMLButtonElement).disabled).toBe(true); + expect((row(3).getByRole("button", { name: "Move Rory down" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(row(3).getByRole("button", { name: "Move Rory up" })); + await waitFor(() => expect(listNames()).toEqual(["Declan", "Rory", "Maeve"])); + expect(writes()).toEqual([{ method: "POST", path: REORDER_PATH, body: { ordered_ids: [1, 3, 2] } }]); + }); + + it("disables the write controls while a request is in flight (no double submit)", async () => { + await renderScreen(); + let release!: () => void; + writeResponse = () => jsonResponse({ status: "ok" }); + const gate = new Promise((r) => { + release = r; + }); + fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => { + await gate; + return routeFetch(url, init); + }); + const up = row(2).getByRole("button", { name: "Move Maeve up" }) as HTMLButtonElement; + fireEvent.click(up); + fireEvent.click(up); + await waitFor(() => expect(up.disabled).toBe(true)); + expect((row(1).getByRole("button", { name: "Deactivate Declan" }) as HTMLButtonElement).disabled).toBe(true); + expect(writes()).toHaveLength(1); + release(); + await waitFor(() => expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(false)); + expect(writes()).toHaveLength(1); + }); + + it("a 422 shows the API detail and keeps the form and the screen open", async () => { + const onClose = await renderScreen(); + fireEvent.click(screen.getByRole("button", { name: "Add child" })); + const form = screen.getByRole("form", { name: "Add child" }); + fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } }); + writeResponse = () => jsonResponse({ detail: "display_name is required" }, 422); + fireEvent.click(within(form).getByRole("button", { name: "Add child" })); + + const error = await screen.findByTestId("child-form-error"); + expect(error.textContent).toBe("The child could not be added: display_name is required"); + expect(screen.getByRole("form", { name: "Add child" })).toBeTruthy(); + expect(field(form, "Display name").value).toBe("Nora"); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy(); + }); + + it("a 403 on a row action shows the refusal message and keeps the screen open", async () => { + const onClose = await renderScreen(); + writeResponse = () => jsonResponse({ detail: "forbidden" }, 403); + fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" })); + const error = await screen.findByTestId("settings-action-error"); + expect(error.textContent).toContain("Your account is not in the nestquest-admins group."); + expect(screen.getByTestId("settings-active-1").textContent).toBe("Active"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("a 403 on load shows the refusal message", async () => { + fetchMock.mockImplementation(async () => jsonResponse({ detail: "forbidden" }, 403)); + render(); + const error = await screen.findByTestId("settings-error"); + expect(error.textContent).toContain("Your account is not in the nestquest-admins group."); + }); + + it("Escape and Back close the screen", async () => { + const onClose = await renderScreen(); + fireEvent.keyDown(screen.getByRole("dialog", { name: "Settings" }), { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("the Today Settings button opens the screen; closing restores focus and the background", async () => { + render(); + const settings = await screen.findByRole("button", { name: "Settings" }); + settings.focus(); + fireEvent.click(settings); + + const dialog = await screen.findByRole("dialog", { name: "Settings" }); + await screen.findByTestId("settings-child-1"); + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Settings" })); + const tabbar = document.querySelector("nav.admin-tabbar") as HTMLElement; + expect(tabbar.hasAttribute("inert")).toBe(true); + expect(dialog.closest("[inert]")).toBeNull(); + + fireEvent.keyDown(dialog, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull()); + expect(tabbar.hasAttribute("inert")).toBe(false); + 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 new file mode 100644 index 0000000..57c46d4 --- /dev/null +++ b/admin/src/settings/SettingsScreen.tsx @@ -0,0 +1,811 @@ +/** + * Settings — a pushed screen opened from the Today header's Settings button. + * + * Children: list, add, edit, deactivate/reactivate and reorder the + * household's child profiles. Every write refetches the list. + * + * Preferences: the household settings (planning horizon, day rollover, + * notifications). Save patches only the changed fields. + * + * One write at a time across both sections. + */ +import { + useEffect, + useRef, + useState, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { ApiForbiddenError } from "../api/client"; +import { + createChild, + reorderChildren, + setChildActive, + updateChild, + type ChildCreateBody, + type ChildEditBody, +} from "../api/children"; +import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions"; +import { + fetchSettings, + updateSettings, + type HouseholdSettings, + type SettingsChanges, +} from "../api/settings"; +import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor"; +import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs"; +import "./SettingsScreen.css"; + +/** The swatch picker's presets; any other "#RRGGBB" can be typed. */ +export const COLOUR_PRESETS = ["#e11d48", "#f97316", "#eab308", "#16a34a", "#0ea5e9", "#7c3aed"]; + +/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ +export const SCROLL_BOTTOM_PADDING = "92px"; + +const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/; + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; children: AdminChild[] }; + +type FormTarget = null | "new" | AdminChild; + +interface Draft { + name: string; + colour: string; + avatar: string; + order: string; +} + +/** Display order: sort_order, ties broken by id (the API's own order). */ +function sortChildren(children: AdminChild[]): AdminChild[] { + return [...children].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id); +} + +function errorMessage(error: unknown, action: string): string { + if (error instanceof ApiForbiddenError) return error.message; + if (error instanceof ApiRequestError && error.detail) return `${action}: ${error.detail}`; + return `${action}. Check your connection and try again.`; +} + +function draftFor(target: "new" | AdminChild, siblings: AdminChild[]): Draft { + if (target === "new") { + const next = siblings.reduce((max, c) => Math.max(max, c.sort_order), 0) + 1; + return { name: "", colour: "", avatar: "", order: String(next) }; + } + return { + name: target.display_name, + colour: target.colour ?? "", + avatar: target.avatar_ref ?? "", + order: String(target.sort_order), + }; +} + +/** The request body for the draft, or a validation message. */ +function buildBody( + draft: Draft, + target: "new" | AdminChild, +): ChildCreateBody | ChildEditBody | string { + const name = draft.name.trim(); + const colour = draft.colour.trim(); + const avatar = draft.avatar.trim(); + const order = draft.order.trim(); + if (!name) return "Enter a display name."; + if (colour && !HEX_COLOUR.test(colour)) return "Colour must be a hex value like #0ea5e9."; + if (order && !/^-?\d+$/.test(order)) return "Order must be a whole number."; + + if (target === "new") { + const body: ChildCreateBody = { display_name: name }; + if (colour) body.colour = colour; + if (avatar) body.avatar_ref = avatar; + if (order) body.sort_order = Number(order); + return body; + } + + // The API rejects null, so a set colour, avatar or order cannot be cleared. + if (!colour && target.colour !== null) return "A colour, once set, cannot be removed. Pick another."; + if (!avatar && target.avatar_ref !== null) return "An avatar ref, once set, cannot be removed."; + if (!order) return "Enter an order."; + const body: ChildEditBody = {}; + if (name !== target.display_name) body.display_name = name; + if (colour && colour !== target.colour) body.colour = colour; + if (avatar && avatar !== target.avatar_ref) body.avatar_ref = avatar; + if (Number(order) !== target.sort_order) body.sort_order = Number(order); + if (Object.keys(body).length === 0) return "No changes to save."; + return body; +} + +function ChildForm({ + target, + siblings, + saving, + error, + onCancel, + onSubmit, +}: { + target: "new" | AdminChild; + /** The current list; a new child's order defaults to after the last. */ + siblings: AdminChild[]; + saving: boolean; + error: string | null; + onCancel: () => void; + onSubmit: (body: ChildCreateBody | ChildEditBody | string) => void; +}) { + const [draft, setDraft] = useState(() => draftFor(target, siblings)); + const nameRef = useRef(null); + const update = (patch: Partial) => 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 ( +
+

{title}

+ +
+ +
+ {COLOUR_PRESETS.map((hex) => ( +
+
+ + + {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+ ); +} + +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, + 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 ( +
  • +
  • + ); +} + +export default function SettingsScreen({ onClose }: { onClose: () => void }) { + const [state, setState] = useState({ kind: "loading" }); + const [attempt, setAttempt] = useState(0); + const [form, setForm] = useState(null); + 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); + const titleRef = useRef(null); + const addRef = useRef(null); + const formWasOpen = useRef(false); + + // Modal focus: move in on open, keep the background out of reach, and hand + // focus back to the opener (the Settings button) on close. + useEffect(() => { + const trigger = document.activeElement as HTMLElement | null; + titleRef.current?.focus(); + const restoreBackground = screenRef.current ? inertOutside(screenRef.current) : undefined; + return () => { + restoreBackground?.(); + if (trigger && trigger.isConnected) trigger.focus(); + }; + }, []); + + // The form's controls unmount when it closes; hand focus to Add child then + // (after the render that re-enables it). + useEffect(() => { + if (formWasOpen.current && form === null) addRef.current?.focus(); + formWasOpen.current = form !== null; + }, [form]); + + useEffect(() => { + let cancelled = false; + setState((current) => (current.kind === "ready" ? current : { kind: "loading" })); + fetchChildren() + .then((children) => { + if (!cancelled) setState({ kind: "ready", children: sortChildren(children) }); + }) + .catch((error: unknown) => { + if (cancelled) return; + setState({ kind: "error", message: errorMessage(error, "Children could not be loaded") }); + }); + return () => { + cancelled = true; + }; + }, [attempt]); + + useEffect(() => { + let cancelled = false; + setPreferences({ kind: "loading" }); + fetchSettings() + .then((settings) => { + if (!cancelled) setPreferences({ kind: "ready", settings }); + }) + .catch((error: unknown) => { + if (cancelled) return; + setPreferences({ kind: "error", message: errorMessage(error, "Preferences could not be loaded") }); + }); + return () => { + cancelled = true; + }; + }, [preferencesAttempt]); + + function onKeyDown(event: ReactKeyboardEvent) { + 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(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, action: string): Promise { + if (inFlight.current) return false; + inFlight.current = true; + setBusy(key); + setActionError(null); + setFormError(null); + try { + await write(); + } catch (error) { + const message = errorMessage(error, action); + if (key === "form") setFormError(message); + else setActionError(message); + inFlight.current = false; + setBusy(null); + return false; + } + try { + const children = await fetchChildren(); + setState({ kind: "ready", children: sortChildren(children) }); + } catch (error) { + setActionError(errorMessage(error, "Saved, but the children list could not be refreshed")); + } + inFlight.current = false; + setBusy(null); + return true; + } + + function closeForm() { + setForm(null); + setFormError(null); + } + + async function submitForm(body: ChildCreateBody | ChildEditBody | string) { + if (form === null) return; + if (typeof body === "string") { + setFormError(body); + return; + } + const saved = + form === "new" + ? await run("form", () => createChild(body as ChildCreateBody), "The child could not be added") + : await run("form", () => updateChild(form.id, body), "The child could not be saved"); + if (saved) closeForm(); + } + + async function savePreferences(changes: SettingsChanges | string) { + if (typeof changes === "string") { + setPreferencesMessage({ kind: changes === "No changes to save." ? "info" : "error", text: changes }); + return; + } + if (inFlight.current) return; + inFlight.current = true; + setBusy("preferences"); + setPreferencesMessage(null); + try { + const settings = await updateSettings(changes); + setPreferences({ kind: "ready", settings }); + setPreferencesMessage({ kind: "info", text: "Preferences saved." }); + } catch (error) { + setPreferencesMessage({ + kind: "error", + text: errorMessage(error, "The preferences could not be saved"), + }); + } + inFlight.current = false; + setBusy(null); + } + + function move(children: AdminChild[], index: number, delta: -1 | 1) { + const ids = children.map((c) => c.id); + const target = index + delta; + [ids[index], ids[target]] = [ids[target], ids[index]]; + void run(`move-${children[index].id}`, () => reorderChildren(ids), "The order could not be saved"); + } + + function toggleActive(child: AdminChild) { + void run( + `active-${child.id}`, + () => setChildActive(child.id, !child.is_active), + child.is_active ? "The child could not be deactivated" : "The child could not be reactivated", + ); + } + + let body; + if (state.kind === "loading") { + body = ( +
    + Loading children… +
    + ); + } else if (state.kind === "error") { + body = ( +
    +

    {state.message}

    + +
    + ); + } else { + const { children } = state; + body = ( + <> + {children.length > 0 ? ( +
      + {children.map((child, index) => ( + { + setFormError(null); + setForm(child); + }} + onMove={(delta) => move(children, index, delta)} + onToggleActive={() => toggleActive(child)} + /> + ))} +
    + ) : ( +
    No children yet.
    + )} + {form !== null ? ( + void submitForm(b)} + /> + ) : null} + + ); + } + + return ( +
    +
    + +
    +

    + Settings +

    +

    Household setup

    +
    +
    +
    +
    +

    Children

    + +
    + {actionError !== null ? ( +
    + {actionError} + +
    + ) : null} + {body} +
    +

    Preferences

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

    {preferences.message}

    + +
    + ) : ( + void savePreferences(changes)} + /> + )} +
    +
    + ); +} diff --git a/admin/src/settings/glyphs.tsx b/admin/src/settings/glyphs.tsx new file mode 100644 index 0000000..48dcedb --- /dev/null +++ b/admin/src/settings/glyphs.tsx @@ -0,0 +1,56 @@ +/** + * Inline stroke glyphs for the Settings screen (Lucide-style 24px grid), + * CDN-free like the tabs' glyphs. + */ +import type { ReactNode } from "react"; + +function Glyph({ size, children }: { size: number; children: ReactNode }) { + return ( + + ); +} + +export function ChevronLeftGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function ArrowUpGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function ArrowDownGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function PlusGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} diff --git a/admin/src/today/TodayTab.tsx b/admin/src/today/TodayTab.tsx index 9d4fedc..7646b87 100644 --- a/admin/src/today/TodayTab.tsx +++ b/admin/src/today/TodayTab.tsx @@ -12,6 +12,7 @@ import { ApiForbiddenError } from "../api/client"; import { ApiRequestError } from "../api/definitions"; import { uncompleteInstance } from "../api/instances"; import { useTransitions } from "../api/useTransitions"; +import SettingsScreen from "../settings/SettingsScreen"; import { formatClockTime, formatDueTime, @@ -43,15 +44,22 @@ function isOverdueOpen(instance: SnapshotInstance): boolean { return instance.overdue && instance.state === "open"; } -function ScreenHeader({ sub }: { sub: string }) { +function ScreenHeader({ sub, onSettings }: { sub: string; onSettings: () => void }) { return (

    Today

    {sub}

    - {/* Settings screen is a later task; the control is not wired yet. */} -
    @@ -221,6 +229,7 @@ export default function TodayTab() { const [undoingId, setUndoingId] = useState(null); const undoingRef = useRef(false); const [undoError, setUndoError] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); function refresh() { silentNext.current = true; @@ -326,7 +335,7 @@ export default function TodayTab() { return (
    - + setSettingsOpen(true)} />
    + {settingsOpen ? ( + { + setSettingsOpen(false); + // Child names and order feed the snapshot. + refresh(); + }} + /> + ) : null}
    ); } diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index 586303b..d8a94a4 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -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" } diff --git a/deploy/.env.example b/deploy/.env.example index 6c117b5..9b521cb 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -21,3 +21,14 @@ NESTQUEST_OIDC_JWKS_URL= # unset; on the API box set the LAN address the reverse proxy # forwards to, e.g. 10.60.1.14. NESTQUEST_BIND_ADDRESS= + +# Admin PWA build args (compose interpolation into the pwa service's +# VITE_* build args; baked into the bundle, so rebuild after changing). +# PUBLIC values, not secrets: the Authentik application is a PKCE client +# with no client secret. All three are required — the pwa image build +# fails when any is empty. +# NESTQUEST_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/ +# NESTQUEST_API_BASE_URL=https://nestquest.cubecraftlabs.com +NESTQUEST_AUTHENTIK_CLIENT_ID= +NESTQUEST_AUTHENTIK_ISSUER= +NESTQUEST_API_BASE_URL= diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index 5a0cc65..c818852 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -8,7 +8,7 @@ given, never their content. | Piece | Where | Real value | | --- | --- | --- | -| API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` | +| API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` (API), `8081` (admin PWA, section 10) | | Reverse proxy / TLS | shared Traefik on `nukapi-03` | `10.60.1.33` | | Public hostname | Cloudflare DNS | `nestquest.cubecraftlabs.com` | | Identity | Authentik | `https://auth.cubecraftlabs.com` | @@ -57,12 +57,13 @@ section 5 depend on uid 999): ## 2. Compose -`deploy/compose.yaml` defines two services and one named volume: +`deploy/compose.yaml` defines three services and one named volume: | Service | Image | Role | | --- | --- | --- | | `api` | `nestquest-api:latest` (built, section 1) | FastAPI service on `8080`, healthcheck on `/health` | | `litestream` | `litestream/litestream:0.5.17` | Continuous SFTP backup of the database (section 7); starts only once `api` is healthy | +| `pwa` | `nestquest-pwa:latest` (built from `deploy/pwa`, section 10) | Admin PWA served by nginx on `8081`; independent of the other two | - **Volume `nestquest-db`** is mounted at `/var/lib/nestquest` in both services. The database is `NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db` @@ -96,14 +97,18 @@ repository. What it must contain: | Router | Rule | Middleware | Service | | --- | --- | --- | --- | -| panel | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `http://10.60.1.14:8080` | -| admin (everything else: `/health`, `/docs`, `/openapi.json`, `/api/v1/admin/*`) | ``Host(`nestquest.cubecraftlabs.com`)`` | none — public; the API itself enforces Authentik JWTs (section 4) | `http://10.60.1.14:8080` | +| `nestquest-panel` (priority 200) | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `nestquest-panel-lan`: `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `nestquest-api` → `http://10.60.1.14:8080` | +| `nestquest-admin` (priority 100: `/api/v1/admin`, `/health`, `/openapi.json`, `/docs`) | ``Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) \|\| Path(`/health`) \|\| Path(`/openapi.json`) \|\| PathPrefix(`/docs`))`` | `nestquest-headers` — no source restriction; the API itself enforces Authentik JWTs (section 4) | `nestquest-api` → `http://10.60.1.14:8080` | +| `nestquest-app` (priority 50: everything else — the admin PWA) | see section 10 | none at Traefik; nginx allow-lists the proxy | `nestquest-pwa` → `http://10.60.1.14:8081` | -Both routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver` -(HTTP is redirected to HTTPS by the shared Traefik). The panel router's -rule is longer, so Traefik's default rule-length priority evaluates it -before the catch-all admin router; if you set explicit priorities, keep -panel higher. +All routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver` +(HTTP is redirected to HTTPS by the shared Traefik). Priorities are set +explicitly so the order never depends on rule length: panel (200) above +admin (100), and the PWA's `nestquest-app` router (50) below both. The +admin router is scoped to `/api/v1/admin`, `/health`, `/openapi.json` +and `/docs` — a bare ``Host(...)`` rule at a higher priority would +swallow `/` and every PWA route. Any other `/api/*` path matches no +router (the app router excludes `/api`), so Traefik answers `404`. Reference shape, to rebuild the file if it is ever lost (entrypoint names must match the shared Traefik's static config on nukapi-03): @@ -113,26 +118,42 @@ http: routers: nestquest-panel: rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)" + priority: 200 entryPoints: [websecure] - middlewares: [nestquest-panel-allowlist] + middlewares: [nestquest-panel-lan] service: nestquest-api tls: certResolver: myresolver nestquest-admin: - rule: "Host(`nestquest.cubecraftlabs.com`)" + rule: "Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) || Path(`/health`) || Path(`/openapi.json`) || PathPrefix(`/docs`))" + priority: 100 entryPoints: [websecure] + middlewares: [nestquest-headers] service: nestquest-api tls: certResolver: myresolver + nestquest-app: + rule: "Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)" + priority: 50 + entryPoints: [websecure] + service: nestquest-pwa + tls: + certResolver: myresolver middlewares: - nestquest-panel-allowlist: + nestquest-panel-lan: ipAllowList: sourceRange: ["10.60.1.80/32"] + nestquest-headers: + # headers middleware; copy its definition from the live file services: nestquest-api: loadBalancer: servers: - url: "http://10.60.1.14:8080" + nestquest-pwa: + loadBalancer: + servers: + - url: "http://10.60.1.14:8081" ``` **Certificates.** `myresolver` is the shared Traefik's ACME (Let's @@ -453,3 +474,80 @@ To obtain `$ADMIN_JWT`: sign in to the admin PWA as the user, open the browser developer tools, and copy the session-storage value `nestquest.access_token`. Tokens are short-lived and are credentials — do not store them. + +## 10. Admin PWA hosting + +The admin PWA (`admin/`, a Vite + React single-page app) is served from +the API box by the compose service **`pwa`**: nginx on container port +`80`, published on `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081` (on the API +box `10.60.1.14:8081`). Traefik sends every path of +`nestquest.cubecraftlabs.com` that is not the API to it. + +**Image** — `deploy/pwa/Dockerfile`, built from the **repository root** +(it needs `admin/`); `deploy/pwa/Dockerfile.dockerignore` limits the +context to `admin/` (without `node_modules`, `dist` or `.env` files) and +`deploy/pwa/nginx.conf`. + +- Stage 1, `node:22-alpine` (pinned by digest): `npm ci`, then + `npm run build` with the three `VITE_*` build args below. Vite bakes + them into the bundle, so **changing any of them needs a rebuild**. The + build fails if any is empty. +- Stage 2, `nginx:alpine` pinned to + `sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f` + (nginx 1.31.6): the built `dist/` in `/usr/share/nginx/html` and + `deploy/pwa/nginx.conf` as `conf.d/default.conf`. + +**Build args** — compose reads them from `deploy/.env` (documented in +`deploy/.env.example`). They are **public values, not secrets**: the +Authentik application is a PKCE client with no client secret. + +| `deploy/.env` variable | Build arg | Production value | +| --- | --- | --- | +| `NESTQUEST_AUTHENTIK_CLIENT_ID` | `VITE_AUTHENTIK_CLIENT_ID` | client id of the Authentik NestQuest application (Authentik admin → Applications → Providers) | +| `NESTQUEST_AUTHENTIK_ISSUER` | `VITE_AUTHENTIK_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` | +| `NESTQUEST_API_BASE_URL` | `VITE_API_BASE_URL` | `https://nestquest.cubecraftlabs.com` | + +The Authentik application's redirect URI must include +`https://nestquest.cubecraftlabs.com/auth/callback`. + +**nginx** (`deploy/pwa/nginx.conf`): + +- `allow 10.60.1.33; allow 10.60.1.14; deny all;` — only Traefik on + nukapi-03 and the API box itself get content; any other source gets + `403`, even with the port reachable on the LAN. Checks run on the API + box must use its LAN address (`10.60.1.14:8081`), not `127.0.0.1`. +- SPA fallback: unknown paths (e.g. `/auth/callback`) serve `index.html`; + `/assets/*` (content-hashed, cached for a year) returns a real `404` + when missing. +- `/sw.js` and `/manifest.webmanifest` (`application/manifest+json`) are + served from the site root with `Cache-Control: no-cache`, as is + `index.html`, so a new release's service worker is picked up. + +Build and start (the `api` and `litestream` services are unaffected): + + dc build pwa + dc up -d pwa + dc ps pwa # "Up" + +**Traefik router** — in `config/dynamic/nestquest-ccl.yml` on nukapi-03 +(reference shape in section 3): + +| Router | Rule | Priority | Service | +| --- | --- | --- | --- | +| `nestquest-app` | ``Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)`` | `50` — below `nestquest-admin` (100) and `nestquest-panel` (200) | `nestquest-pwa` → `http://10.60.1.14:8081` | + +Same `websecure` entrypoint and `myresolver` certificate as the API +routers; no Traefik middleware (nginx enforces the source allow-list). +The file provider reloads the dynamic file on save. + +**Verification** (in addition to section 9): + +| # | Check | Command | Expect | +| --- | --- | --- | --- | +| 1 | PWA root over TLS | `curl -s -w '\n%{http_code}\n' https://nestquest.cubecraftlabs.com/ \| tail -3` | `200`, the `NestQuest Admin` shell | +| 2 | Client route fallback | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/auth/callback` | `200 text/html` | +| 3 | Manifest and worker | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/manifest.webmanifest` and the same for `/sw.js` | `200 application/manifest+json`, `200 application/javascript` | +| 4 | API still routed to the API | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` (`{"status":"ok"}`, not HTML) | +| 5 | Panel refused externally | section 9 check 4 | `403` | +| 6 | Non-Traefik source refused | from a LAN machine other than `10.60.1.33`/`10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `403` (nginx `deny all`) | +| 7 | Proxy source allowed | on `10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `200` | diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 692df23..c2a1ea7 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -42,6 +42,25 @@ services: retries: 5 start_period: 15s + # Admin PWA (Feature 24): the Vite bundle from admin/ served by nginx + # (deploy/pwa/). Traefik routes every non-API path of the public host + # here on port 8081; nginx itself only accepts the proxy (10.60.1.33) + # and this host (10.60.1.14). The three build args are PUBLIC values + # (the OIDC client is a PKCE client, no secret) read from deploy/.env + # by compose interpolation; the image build fails if any is empty. + pwa: + build: + context: .. + dockerfile: deploy/pwa/Dockerfile + args: + VITE_AUTHENTIK_CLIENT_ID: ${NESTQUEST_AUTHENTIK_CLIENT_ID:-} + VITE_AUTHENTIK_ISSUER: ${NESTQUEST_AUTHENTIK_ISSUER:-} + VITE_API_BASE_URL: ${NESTQUEST_API_BASE_URL:-} + image: nestquest-pwa:latest + restart: unless-stopped + ports: + - "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081:80" + # Continuous backup: streams the SQLite database on the shared named # volume to the Synology NAS over SFTP (config: deploy/litestream.yml). # The SFTP private key deploy/litestream_key is git-ignored and must be diff --git a/deploy/pwa/Dockerfile b/deploy/pwa/Dockerfile new file mode 100644 index 0000000..c3afd64 --- /dev/null +++ b/deploy/pwa/Dockerfile @@ -0,0 +1,29 @@ +# Admin PWA image (Feature 24): builds the Vite bundle from admin/ and +# serves it with nginx. Build context is the REPOSITORY ROOT (it needs +# admin/); deploy/pwa/Dockerfile.dockerignore limits that context to +# admin/ and this directory's nginx.conf. +# +# docker compose -f deploy/compose.yaml build pwa +# +# The VITE_* values are baked into the bundle at build time. None is a +# secret (the OIDC client is a public PKCE client), and all three are +# required: the build fails fast rather than relying on app defaults. + +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build + +ARG VITE_AUTHENTIK_CLIENT_ID +ARG VITE_AUTHENTIK_ISSUER +ARG VITE_API_BASE_URL + +WORKDIR /src/admin +COPY admin/package.json admin/package-lock.json ./ +RUN npm ci +COPY admin/ ./ +RUN test -n "$VITE_AUTHENTIK_CLIENT_ID" && test -n "$VITE_AUTHENTIK_ISSUER" && test -n "$VITE_API_BASE_URL" \ + || { echo "VITE_AUTHENTIK_CLIENT_ID, VITE_AUTHENTIK_ISSUER and VITE_API_BASE_URL build args are required" >&2; exit 1; } +RUN npm run build + +FROM nginx:alpine@sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f + +COPY deploy/pwa/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/admin/dist/ /usr/share/nginx/html/ diff --git a/deploy/pwa/Dockerfile.dockerignore b/deploy/pwa/Dockerfile.dockerignore new file mode 100644 index 0000000..ff55a9a --- /dev/null +++ b/deploy/pwa/Dockerfile.dockerignore @@ -0,0 +1,11 @@ +# Build-context filter for deploy/pwa/Dockerfile (BuildKit reads +# .dockerignore in place of the root .dockerignore, which +# excludes deploy/). Only admin/ sources and nginx.conf enter the +# build; local dependencies, build output and .env files never do. +* +!admin/ +!deploy/pwa/nginx.conf +admin/node_modules +admin/dist +**/.env +**/.env.* diff --git a/deploy/pwa/nginx.conf b/deploy/pwa/nginx.conf new file mode 100644 index 0000000..898ee60 --- /dev/null +++ b/deploy/pwa/nginx.conf @@ -0,0 +1,53 @@ +# nginx server for the NestQuest admin PWA (Feature 24). Replaces the +# image's /etc/nginx/conf.d/default.conf. The shared Traefik on +# nukapi-03 (10.60.1.33) terminates TLS for nestquest.cubecraftlabs.com +# and forwards every path that is not the API (/api, /health, +# /openapi.json, /docs) here; see deploy/RUNBOOK.md section 10. +server { + listen 80; + server_name _; + + # Only the reverse proxy and the API box itself may connect; every + # other source is refused (403) even if the port is reachable. + allow 10.60.1.33; + allow 10.60.1.14; + deny all; + + root /usr/share/nginx/html; + index index.html; + server_tokens off; + + # Content-hashed bundle files: cache forever, and a missing asset is + # a real 404 rather than the SPA shell. + location /assets/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # The service worker must be revalidated on every load so a new + # release's worker (fresh revision, see admin/vite-sw-plugin.ts) is + # picked up. + location = /sw.js { + try_files $uri =404; + add_header Cache-Control "no-cache"; + } + + # nginx's stock mime.types has no .webmanifest entry. + location = /manifest.webmanifest { + types { } + default_type application/manifest+json; + try_files $uri =404; + add_header Cache-Control "no-cache"; + } + + # SPA fallback: client routes such as /auth/callback resolve to the + # app shell, which is never cached so it always names the current + # hashed assets. + location / { + try_files $uri $uri/ /index.html; + } + + location = /index.html { + add_header Cache-Control "no-cache"; + } +} diff --git a/pyproject.toml b/pyproject.toml index 3a208c8..11e27f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/uv.lock b/uv.lock index 04389dd..1f99994 100644 --- a/uv.lock +++ b/uv.lock @@ -373,7 +373,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.7.0" +version = "0.7.1" source = { virtual = "." } [package.dev-dependencies]