Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75e65f7bbf | ||
|
|
5650a1f5a2 | ||
|
|
7acc6501d9 | ||
|
|
345a5129b6 | ||
|
|
3d293e0431 | ||
|
|
9cdc69ea72 | ||
|
|
1eb136fc67 | ||
|
|
ee6c1d6bc4 | ||
|
|
87e3f12f4c | ||
|
|
9d741e75d0 | ||
|
|
7887d16f11 | ||
|
|
03ee131c6e | ||
|
|
2c6234be48 | ||
|
|
7969c164c4 | ||
|
|
4b80fffe67 |
@@ -0,0 +1,27 @@
|
||||
# Keep the image build context minimal: only api/ and the bundled
|
||||
# custom_components/nestquest/core/ are copied (see the Dockerfile);
|
||||
# everything else — VCS state, virtualenvs, tests, the frontend, and
|
||||
# any local secrets or database files — never enters the build.
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
.pytest_cache
|
||||
**/*.db
|
||||
**/*.db-wal
|
||||
**/*.db-shm
|
||||
# Local configuration — never baked into an image.
|
||||
.env
|
||||
**/.env
|
||||
deploy
|
||||
design
|
||||
docs
|
||||
frontend
|
||||
orchestration_tests
|
||||
scripts
|
||||
templates
|
||||
tests
|
||||
uv.lock
|
||||
hacs.json
|
||||
*.md
|
||||
+13
@@ -5,3 +5,16 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
# Feature 17 (API deployment): local secret config and runtime data
|
||||
# are never committed — compose reads deploy/.env; SQLite/WAL sidecar
|
||||
# files are container/volume artifacts.
|
||||
.env
|
||||
**/.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Dedicated Litestream SFTP backup keypair — lives only on the API box.
|
||||
deploy/*key*
|
||||
deploy/litestream_key*
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Container image for the NestQuest API service (Feature 17).
|
||||
#
|
||||
# Build context is the REPOSITORY ROOT (compose sets it), because the
|
||||
# API loads the bundled domain layer from
|
||||
# custom_components/nestquest/core/ by file location
|
||||
# (api/nestquest_core.py): the image must carry both api/ and the
|
||||
# bundled core at their relative positions. Everything else in the
|
||||
# repository is excluded via .dockerignore.
|
||||
#
|
||||
# Multi-stage so no pip/build toolchain survives into the runtime:
|
||||
# the builder installs the deployable's own manifest
|
||||
# (api/requirements.txt) into a staging prefix and the runtime copies
|
||||
# only the installed packages. The service runs as a non-root user;
|
||||
# the SQLite database lives on the mounted volume at
|
||||
# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing
|
||||
# there — configuration is environment-supplied, never baked in).
|
||||
#
|
||||
# Both stages use the same base image pinned by digest, and
|
||||
# api/requirements.txt pins exact versions, so a rebuild reproduces
|
||||
# the deployed image instead of floating to whatever is newest.
|
||||
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS builder
|
||||
|
||||
COPY api/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt
|
||||
|
||||
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
|
||||
|
||||
# The staged dependency install from the builder (no pip, no wheels).
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# /app is the repository-root equivalent: api/ resolves imports as
|
||||
# ``api.*`` and nestquest_core.py finds the bundled core one level up.
|
||||
WORKDIR /app
|
||||
COPY api/ ./api/
|
||||
COPY custom_components/nestquest/core/ ./custom_components/nestquest/core/
|
||||
|
||||
# Non-root runtime user; owns the database volume mount point.
|
||||
RUN useradd --system --create-home --home-dir /home/nestquest nestquest \
|
||||
&& mkdir -p /var/lib/nestquest \
|
||||
&& chown nestquest:nestquest /var/lib/nestquest
|
||||
USER nestquest
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# The database path and the service token / OIDC settings are supplied
|
||||
# by the environment (compose env_file), never committed into the image.
|
||||
ENV NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db
|
||||
VOLUME /var/lib/nestquest
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "--factory", "api.app:create_app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -1,5 +1,204 @@
|
||||
# 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
|
||||
|
||||
Seventh release of NestQuest, promoting the re-imagined architecture's admin
|
||||
surface from `dev` onto 0.6.0: the API deployment and identity layer, the
|
||||
admin-plane API work, and the standalone admin PWA that uses them.
|
||||
|
||||
- **Feature 17 — API Deployment and Identity:** the NestQuest API is packaged as
|
||||
a container (`Dockerfile`, `deploy/compose.yaml`, pinned base image and exact
|
||||
dependencies) and deployed behind a TLS reverse proxy (the shared Traefik, ACME
|
||||
via the Cloudflare DNS-01 challenge); the Authentik application, OIDC provider
|
||||
and `nestquest-admins` group are wired and the API validates the provider's
|
||||
issuer/audience/JWKS (RS256); the panel plane is restricted to the Home
|
||||
Assistant box while the admin plane is internet-facing behind Authentik;
|
||||
continuous backup runs as a contained Litestream sidecar over SFTP to a NAS and
|
||||
a restore is proven; a deployment runbook (`deploy/RUNBOOK.md`) and the
|
||||
host-firewall unit (`deploy/systemd/nestquest-api-firewall.service`) ship.
|
||||
(PRs #183, #214, #215, #216, #218.)
|
||||
- **Feature 19 — Admin PWA:** the parent-facing admin surface is now a
|
||||
standalone mobile-first React progressive web app (`admin/`), implementing
|
||||
`design/ADMIN-SPEC.md`: Authentik OIDC authorization-code + PKCE login with
|
||||
an explicit refusal for a user outside `nestquest-admins`; an installable
|
||||
PWA (manifest + service worker); and the Today, Definitions, Schedule and
|
||||
History screens. Every mutating action goes through the API admin routes;
|
||||
the API remains the security boundary (D-003/D-012). Today shows the
|
||||
household snapshot; Definitions supports create/edit with multi-select
|
||||
assignees, a recurrence rule with interval and per-window due times, a
|
||||
skip-on-away toggle, a shipped Lucide icon subset and a backend-computed
|
||||
occurrence preview; Schedule has a presence month grid (cycle position from
|
||||
the anchor date, never ISO parity, D-004) and an override editor with a live
|
||||
"removes N upcoming tasks" consequence count; History has All/Reversals/
|
||||
Missed filters, day-grouped rows, per-period stats, CSV export and the
|
||||
append-only footer. Undo reverses a completion, and live updates arrive over
|
||||
the admin SSE stream without a manual reload. (PRs #187–#210, plus the
|
||||
feature-review fix #212.)
|
||||
- **Feature 21 — Admin Today Snapshot API:** `GET /api/v1/admin/snapshot`
|
||||
serves the Today view's household snapshot on the admin plane (reusing the
|
||||
core snapshot builder with missed instances included, D-009). (PRs #190/#191.)
|
||||
- **Feature 22 — Admin Definitions Read and skip_on_away:**
|
||||
`GET /api/v1/admin/quest-definitions` lists all definitions (active and
|
||||
inactive), and a persisted per-definition `skip_on_away` boolean is accepted
|
||||
by create/update and honoured by materialization. Adds an optional
|
||||
`assignee_child_ids` to the edit route so the PWA can reassign children.
|
||||
(PRs #193, #194, #196, #197.)
|
||||
- **Feature 23 — Admin API Completion (preview, presence, live):** a bounded
|
||||
rule occurrence-preview route; presence-schedule and presence-override
|
||||
reads; a live override consequence count computed from the same core
|
||||
generator the materializer uses; and an admin-plane transition SSE stream
|
||||
(`GET /api/v1/admin/events`) off the one in-process publisher. (PRs
|
||||
#199–#203.)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
- A running NestQuest API service, reachable at the base URL and panel service
|
||||
token configured in the integration options. The deployable ships in this
|
||||
release: `Dockerfile`, `deploy/compose.yaml` (API + a contained Litestream
|
||||
backup sidecar), `api/requirements.txt`, the host-firewall unit and
|
||||
`deploy/RUNBOOK.md`. Deploying it needs a host, the DNS/TLS path and the
|
||||
Authentik/Synology services described in the runbook.
|
||||
- The admin PWA is built to run at `https://nestquest.cubecraftlabs.com` behind
|
||||
Authentik. Serving the built `admin/` static bundle at that hostname is part of
|
||||
the deployment described in the runbook.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **Schema version advances 7 → 8.** Migration 8 adds the additive
|
||||
`skip_on_away` column to `quest_definitions` (default 1, so every existing
|
||||
definition keeps today's skip-when-absent behaviour). The migration runs on
|
||||
the first start of this version.
|
||||
- **Once migrated, a plain downgrade to 0.6.0 is impossible** — older builds
|
||||
refuse a newer schema version. **Back up `nestquest.db` (plus `-wal`/`-shm`)
|
||||
before upgrading**; see Rollback.
|
||||
- No Home Assistant entity, service, event or automation surface changes in
|
||||
this release. The API's new admin routes are additive.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **The API and its identity/backup layer are deployed by the owner's
|
||||
infrastructure, not by this release alone.** The deployment artifacts and
|
||||
runbook ship here, but a running service still requires the host, the shared
|
||||
reverse proxy, the Authentik application/provider/`nestquest-admins` group, the
|
||||
backup target and the panel/admin network separation to be in place as the
|
||||
runbook describes.
|
||||
- **Serving the admin PWA is still outstanding.** `admin/` builds and is
|
||||
unit/integration tested and the API it talks to is live, but the built static
|
||||
bundle must still be hosted at `https://nestquest.cubecraftlabs.com` behind
|
||||
Authentik; until then the PWA is not reachable end-to-end.
|
||||
- **Feature 12's TouchHub/DAKOS runbook task remains physically unverified**
|
||||
(needs the wall panel).
|
||||
- Moving the household database from Home Assistant to the API box is a manual
|
||||
file copy (the schema is unchanged by that move beyond the v8 migration).
|
||||
- Single household; single API service.
|
||||
|
||||
### Rollback
|
||||
|
||||
**Schema version advances 7 → 8.** Migration 8 is additive, but a migrated
|
||||
database **cannot** be read by 0.6.0 or earlier (they refuse a newer schema
|
||||
version). The database is owned and migrated by the **NestQuest API service**,
|
||||
not by Home Assistant: it is the file at the API's configured
|
||||
`NESTQUEST_DB_PATH` on the API host, which need not be anywhere near the Home
|
||||
Assistant config directory. To return to 0.6.0 you must restore a backup of
|
||||
that file taken before the upgrade; a plain downgrade is impossible once
|
||||
migrated.
|
||||
|
||||
**API host (database):**
|
||||
|
||||
1. Before upgrading, stop the NestQuest API service and back up the database
|
||||
file at its configured `NESTQUEST_DB_PATH`, plus its `-wal` and `-shm`
|
||||
sidecars (`<NESTQUEST_DB_PATH>-wal`, `<NESTQUEST_DB_PATH>-shm`) if present.
|
||||
Only then deploy 0.7.0 and start the API service, which applies migration 8.
|
||||
2. To roll back: stop the API service, restore the backed-up database and
|
||||
sidecars to `NESTQUEST_DB_PATH`, redeploy the API service at 0.6.0, and roll
|
||||
back the Home Assistant integration to 0.6.0 (below). Then restart the API
|
||||
service and Home Assistant.
|
||||
|
||||
**Home Assistant integration — HACS installs** (integration component only;
|
||||
the database lives with the API, see above):
|
||||
|
||||
1. To roll back: HACS > Integrations > NestQuest > Redownload > 0.6.0, then
|
||||
restart Home Assistant.
|
||||
|
||||
**Home Assistant integration — Manual installs** (integration component only;
|
||||
the database lives with the API, see above):
|
||||
|
||||
1. To roll back: replace `custom_components/nestquest/` with the 0.6.0 tree,
|
||||
then restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.7.0 release merge on `main`.
|
||||
|
||||
## Version 0.6.0 — 2026-09-23
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -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<ChildCreateBody>;
|
||||
|
||||
export const CHILDREN_REORDER_PATH = `${CHILDREN_PATH}/reorder`;
|
||||
|
||||
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
|
||||
return apiFetch(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createChild(body: ChildCreateBody): Promise<AdminChild> {
|
||||
return readJson<AdminChild>(await sendJson(CHILDREN_PATH, "POST", body));
|
||||
}
|
||||
|
||||
export async function updateChild(id: number, body: ChildEditBody): Promise<AdminChild> {
|
||||
return readJson<AdminChild>(await sendJson(`${CHILDREN_PATH}/${id}`, "PATCH", body));
|
||||
}
|
||||
|
||||
export async function setChildActive(id: number, isActive: boolean): Promise<AdminChild> {
|
||||
return readJson<AdminChild>(
|
||||
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<void> {
|
||||
await readJson<{ status: string }>(
|
||||
await sendJson(CHILDREN_REORDER_PATH, "POST", { ordered_ids: orderedIds }),
|
||||
);
|
||||
}
|
||||
@@ -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(", ");
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
* <body>), 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)) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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<typeof vi.fn>;
|
||||
|
||||
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<Response> {
|
||||
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(<SettingsScreen onClose={onClose} />);
|
||||
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<void>((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(<SettingsScreen onClose={vi.fn()} />);
|
||||
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(<App />);
|
||||
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(<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" });
|
||||
});
|
||||
});
|
||||
@@ -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<Draft>(() => draftFor(target, siblings));
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
const isNew = target === "new";
|
||||
const title = isNew ? "Add child" : `Edit ${target.display_name}`;
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
onSubmit(buildBody(draft, target));
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="settings-card settings-form" aria-label={title} onSubmit={submit} noValidate>
|
||||
<h4 className="settings-form-title">{title}</h4>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Display name</span>
|
||||
<input
|
||||
ref={nameRef}
|
||||
className="settings-input"
|
||||
type="text"
|
||||
required
|
||||
value={draft.name}
|
||||
disabled={saving}
|
||||
onChange={(event) => update({ name: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-field">
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Colour</span>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
placeholder="#0ea5e9"
|
||||
value={draft.colour}
|
||||
disabled={saving}
|
||||
onChange={(event) => update({ colour: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-swatches" role="group" aria-label="Colour presets">
|
||||
{COLOUR_PRESETS.map((hex) => (
|
||||
<button
|
||||
key={hex}
|
||||
type="button"
|
||||
className="settings-swatch-option"
|
||||
style={{ background: hex }}
|
||||
aria-label={`Colour ${hex}`}
|
||||
aria-pressed={draft.colour.toLowerCase() === hex}
|
||||
disabled={saving}
|
||||
onClick={() => update({ colour: hex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Avatar ref (optional)</span>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={draft.avatar}
|
||||
disabled={saving}
|
||||
onChange={(event) => update({ avatar: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Order</span>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draft.order}
|
||||
disabled={saving}
|
||||
onChange={(event) => update({ order: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<p className="settings-error" role="alert" data-testid="child-form-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="settings-form-footer">
|
||||
<button type="button" className="settings-cancel" disabled={saving} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="settings-save" disabled={saving} aria-busy={saving}>
|
||||
{saving ? "Saving…" : isNew ? "Add child" : "Save child"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
count,
|
||||
busy,
|
||||
onEdit,
|
||||
onMove,
|
||||
onToggleActive,
|
||||
}: {
|
||||
child: AdminChild;
|
||||
index: number;
|
||||
count: number;
|
||||
/** The in-flight action key, or null. Every write control waits on it. */
|
||||
busy: string | null;
|
||||
onEdit: () => void;
|
||||
onMove: (delta: -1 | 1) => void;
|
||||
onToggleActive: () => void;
|
||||
}) {
|
||||
const locked = busy !== null;
|
||||
const toggling = busy === `active-${child.id}`;
|
||||
return (
|
||||
<li
|
||||
className={child.is_active ? "settings-child" : "settings-child settings-child--inactive"}
|
||||
data-testid={`settings-child-${child.id}`}
|
||||
>
|
||||
<span
|
||||
className="settings-swatch"
|
||||
data-testid={`settings-swatch-${child.id}`}
|
||||
style={{ background: child.colour ?? "var(--nq-a-border)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="settings-child-text">
|
||||
<div className="settings-child-name">{child.display_name}</div>
|
||||
<div className="settings-child-meta">
|
||||
Order {child.sort_order} · {child.colour ?? "no colour"}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={child.is_active ? "settings-pill settings-pill--active" : "settings-pill"}
|
||||
data-testid={`settings-active-${child.id}`}
|
||||
>
|
||||
{child.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<div className="settings-child-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-icon-button"
|
||||
aria-label={`Move ${child.display_name} up`}
|
||||
aria-busy={busy === `move-${child.id}`}
|
||||
disabled={locked || index === 0}
|
||||
onClick={() => onMove(-1)}
|
||||
>
|
||||
<ArrowUpGlyph />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-icon-button"
|
||||
aria-label={`Move ${child.display_name} down`}
|
||||
aria-busy={busy === `move-${child.id}`}
|
||||
disabled={locked || index === count - 1}
|
||||
onClick={() => onMove(1)}
|
||||
>
|
||||
<ArrowDownGlyph />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-action"
|
||||
aria-label={`Edit ${child.display_name}`}
|
||||
disabled={locked}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-action"
|
||||
aria-label={`${child.is_active ? "Deactivate" : "Reactivate"} ${child.display_name}`}
|
||||
aria-busy={toggling}
|
||||
disabled={locked}
|
||||
onClick={onToggleActive}
|
||||
>
|
||||
{toggling ? "Saving…" : child.is_active ? "Deactivate" : "Reactivate"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [form, setForm] = useState<FormTarget>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
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);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const addRef = useRef<HTMLButtonElement>(null);
|
||||
const formWasOpen = useRef(false);
|
||||
|
||||
// Modal focus: move in on open, keep the background out of reach, and hand
|
||||
// focus back to the opener (the Settings button) on close.
|
||||
useEffect(() => {
|
||||
const trigger = document.activeElement as HTMLElement | null;
|
||||
titleRef.current?.focus();
|
||||
const restoreBackground = screenRef.current ? inertOutside(screenRef.current) : undefined;
|
||||
return () => {
|
||||
restoreBackground?.();
|
||||
if (trigger && trigger.isConnected) trigger.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The form's controls unmount when it closes; hand focus to Add child then
|
||||
// (after the render that re-enables it).
|
||||
useEffect(() => {
|
||||
if (formWasOpen.current && form === null) addRef.current?.focus();
|
||||
formWasOpen.current = form !== null;
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
|
||||
fetchChildren()
|
||||
.then((children) => {
|
||||
if (!cancelled) setState({ kind: "ready", children: sortChildren(children) });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({ kind: "error", message: errorMessage(error, "Children could not be loaded") });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
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();
|
||||
if (!inFlight.current) onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !screenRef.current) return;
|
||||
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const current = document.activeElement;
|
||||
const outside = !focusable.includes(current as HTMLElement);
|
||||
if (event.shiftKey && (current === first || outside)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (current === last || outside)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one write, then refetch the list. One write at a time; a failure
|
||||
* keeps the screen (and any open form) as it was and says why.
|
||||
*/
|
||||
async function run(key: string, write: () => Promise<unknown>, action: string): Promise<boolean> {
|
||||
if (inFlight.current) return false;
|
||||
inFlight.current = true;
|
||||
setBusy(key);
|
||||
setActionError(null);
|
||||
setFormError(null);
|
||||
try {
|
||||
await write();
|
||||
} catch (error) {
|
||||
const message = errorMessage(error, action);
|
||||
if (key === "form") setFormError(message);
|
||||
else setActionError(message);
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const children = await fetchChildren();
|
||||
setState({ kind: "ready", children: sortChildren(children) });
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error, "Saved, but the children list could not be refreshed"));
|
||||
}
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setForm(null);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function submitForm(body: ChildCreateBody | ChildEditBody | string) {
|
||||
if (form === null) return;
|
||||
if (typeof body === "string") {
|
||||
setFormError(body);
|
||||
return;
|
||||
}
|
||||
const saved =
|
||||
form === "new"
|
||||
? await run("form", () => createChild(body as ChildCreateBody), "The child could not be added")
|
||||
: await run("form", () => updateChild(form.id, body), "The child could not be saved");
|
||||
if (saved) closeForm();
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="settings-status" role="status" data-testid="settings-loading">
|
||||
Loading children…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="settings-status settings-status--error" role="alert" data-testid="settings-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="settings-action" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const { children } = state;
|
||||
body = (
|
||||
<>
|
||||
{children.length > 0 ? (
|
||||
<ul className="settings-card settings-list" aria-label="Children">
|
||||
{children.map((child, index) => (
|
||||
<ChildRow
|
||||
key={child.id}
|
||||
child={child}
|
||||
index={index}
|
||||
count={children.length}
|
||||
busy={busy}
|
||||
onEdit={() => {
|
||||
setFormError(null);
|
||||
setForm(child);
|
||||
}}
|
||||
onMove={(delta) => move(children, index, delta)}
|
||||
onToggleActive={() => toggleActive(child)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="settings-card settings-empty">No children yet.</div>
|
||||
)}
|
||||
{form !== null ? (
|
||||
<ChildForm
|
||||
key={form === "new" ? "new" : form.id}
|
||||
target={form}
|
||||
siblings={children}
|
||||
saving={busy === "form"}
|
||||
error={formError}
|
||||
onCancel={closeForm}
|
||||
onSubmit={(b) => void submitForm(b)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
className="settings-screen"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-title"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<header className="settings-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-back"
|
||||
aria-label="Back"
|
||||
disabled={busy !== null}
|
||||
onClick={onClose}
|
||||
>
|
||||
<ChevronLeftGlyph size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="settings-title" id="settings-title" ref={titleRef} tabIndex={-1}>
|
||||
Settings
|
||||
</h2>
|
||||
<p className="settings-sub">Household setup</p>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className="settings-scroll"
|
||||
data-testid="settings-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
<div className="settings-section-head">
|
||||
<h3 className="settings-section-label">Children</h3>
|
||||
<button
|
||||
ref={addRef}
|
||||
type="button"
|
||||
className="settings-add"
|
||||
disabled={state.kind !== "ready" || busy !== null || form === "new"}
|
||||
onClick={() => {
|
||||
setFormError(null);
|
||||
setForm("new");
|
||||
}}
|
||||
>
|
||||
<PlusGlyph />
|
||||
Add child
|
||||
</button>
|
||||
</div>
|
||||
{actionError !== null ? (
|
||||
<div className="settings-action-error" role="alert" data-testid="settings-action-error">
|
||||
<span>{actionError}</span>
|
||||
<button type="button" className="settings-action" onClick={() => setActionError(null)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{body}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m5 12 7-7 7 7M12 19V5" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M12 5v14M19 12l-7 7-7-7" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<header className="today-header">
|
||||
<div>
|
||||
<h2 className="today-title">Today</h2>
|
||||
<p className="today-sub">{sub}</p>
|
||||
</div>
|
||||
{/* Settings screen is a later task; the control is not wired yet. */}
|
||||
<button type="button" className="today-icon-button" aria-label="Settings">
|
||||
{/* Opens the Settings screen. Only its Children section exists so far.
|
||||
TODO: horizon days, day rollover time and notification config are a
|
||||
separate later task. */}
|
||||
<button
|
||||
type="button"
|
||||
className="today-icon-button"
|
||||
aria-label="Settings"
|
||||
onClick={onSettings}
|
||||
>
|
||||
<SettingsGlyph />
|
||||
</button>
|
||||
</header>
|
||||
@@ -221,6 +229,7 @@ export default function TodayTab() {
|
||||
const [undoingId, setUndoingId] = useState<number | null>(null);
|
||||
const undoingRef = useRef(false);
|
||||
const [undoError, setUndoError] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
silentNext.current = true;
|
||||
@@ -326,7 +335,7 @@ export default function TodayTab() {
|
||||
|
||||
return (
|
||||
<div className="today-screen">
|
||||
<ScreenHeader sub={sub} />
|
||||
<ScreenHeader sub={sub} onSettings={() => setSettingsOpen(true)} />
|
||||
<div
|
||||
className="today-scroll"
|
||||
data-testid="today-scroll"
|
||||
@@ -342,6 +351,15 @@ export default function TodayTab() {
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
{settingsOpen ? (
|
||||
<SettingsScreen
|
||||
onClose={() => {
|
||||
setSettingsOpen(false);
|
||||
// Child names and order feed the snapshot.
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# NestQuest API
|
||||
|
||||
Standalone FastAPI service owning the NestQuest database (Feature 16).
|
||||
Served with ``uvicorn --factory api.app:create_app``; configuration is
|
||||
environment-supplied (``api/config.py``) and every variable is required.
|
||||
|
||||
## Runtime dependencies
|
||||
|
||||
``api/requirements.txt`` is the API deployable's OWN dependency
|
||||
manifest (fastapi, uvicorn, pyjwt[crypto], httpx) — deliberately kept
|
||||
out of the integration's ``pyproject.toml`` dev group, which exists only
|
||||
so the monorepo test venv can run the API tests.
|
||||
|
||||
## Container deployment (scaffold)
|
||||
|
||||
The image is built from the repository ROOT context (the API loads the
|
||||
bundled domain core from ``custom_components/nestquest/core/`` by file
|
||||
location, so both trees must be in the build context):
|
||||
|
||||
docker build -t nestquest-api:latest .
|
||||
|
||||
Run it with compose — the SQLite file lives on the named volume
|
||||
``nestquest-db`` so recreating the container keeps household data:
|
||||
|
||||
cp deploy/.env.example deploy/.env # then fill in real values
|
||||
docker compose -f deploy/compose.yaml up -d --build
|
||||
|
||||
``deploy/.env`` is git-ignored and supplies the secrets (panel service
|
||||
token, Authentik OIDC settings); ``NESTQUEST_DB_PATH`` is fixed to
|
||||
``/var/lib/nestquest/nestquest.db`` by compose.
|
||||
|
||||
The published port binds ``NESTQUEST_BIND_ADDRESS`` from
|
||||
``deploy/.env`` and defaults to ``127.0.0.1`` (localhost only). In
|
||||
production TLS is terminated by the shared Traefik reverse proxy,
|
||||
which serves ``https://nestquest.cubecraftlabs.com`` (Let's Encrypt,
|
||||
auto-renewed, HTTP redirected to HTTPS) and forwards to the API box
|
||||
on port 8080 — so on that box set ``NESTQUEST_BIND_ADDRESS`` to its
|
||||
LAN address (``10.60.1.14``). Traefik only allows
|
||||
``/api/v1/panel`` from the Home Assistant host. The full deployment,
|
||||
restore and verification procedure is in ``deploy/RUNBOOK.md``.
|
||||
|
||||
## Continuous backup (Litestream)
|
||||
|
||||
Compose also runs a ``litestream`` sidecar
|
||||
(``litestream/litestream:0.5.17``, config ``deploy/litestream.yml``).
|
||||
It mounts the same ``nestquest-db`` volume and continuously replicates
|
||||
``/var/lib/nestquest/nestquest.db`` over SFTP to the Synology NAS
|
||||
``CubeCraft-SRVR1`` (``joshua@10.60.1.50``), directory
|
||||
``/volume1/homes/Joshua/nestquest-backups`` (``/homes/Joshua/...`` in
|
||||
the NAS's chrooted SFTP view) — so the backup is outside the Docker
|
||||
host and its volume. A snapshot is taken daily and a week of
|
||||
snapshots plus incremental changes is retained; the NAS host key is
|
||||
pinned in the config.
|
||||
|
||||
The SFTP login uses a dedicated keypair that lives only on the API
|
||||
box and is git-ignored. Create it once, before ``up``:
|
||||
|
||||
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
|
||||
|
||||
then append ``restrict `` + the contents of ``deploy/litestream_key.pub``
|
||||
to ``joshua@10.60.1.50:~/.ssh/authorized_keys``. Compose mounts the
|
||||
private key read-only at ``/etc/litestream/sftp_key``. The sidecar runs
|
||||
as uid/gid 999 (the API's ``nestquest`` user), so give it the key:
|
||||
|
||||
sudo chown 999:999 deploy/litestream_key && chmod 600 deploy/litestream_key
|
||||
|
||||
The sidecar needs WRITE access to the database volume: Litestream adds
|
||||
its own ``_litestream_seq``/``_litestream_lock`` tables to the database,
|
||||
writes the ``-wal``/``-shm`` files and checkpoints the WAL, and fails
|
||||
with "unable to open database file" on a read-only mount. That access
|
||||
is contained rather than removed — the sidecar runs as the non-root
|
||||
volume owner with a read-only root filesystem, ``no-new-privileges``
|
||||
and all Linux capabilities dropped.
|
||||
|
||||
Check replication with
|
||||
``docker compose -f deploy/compose.yaml logs litestream`` or
|
||||
``docker compose -f deploy/compose.yaml exec litestream litestream ltx /var/lib/nestquest/nestquest.db``.
|
||||
To restore (high level): stop the ``api`` service, run
|
||||
``litestream restore -config /etc/litestream.yml -o <new path>
|
||||
/var/lib/nestquest/nestquest.db`` from a litestream container with the
|
||||
same mounts, verify the restored file, move it into place on the
|
||||
volume (removing any stale ``-wal``/``-shm`` files), and start ``api``
|
||||
again.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Runtime dependency manifest for the NestQuest API deployable.
|
||||
#
|
||||
# The monorepo's pyproject.toml carries fastapi/uvicorn/pyjwt/httpx in
|
||||
# the INTEGRATION's dev group only (so the test venv can exercise the
|
||||
# API); they are deliberately NOT integration runtime dependencies.
|
||||
# Per that manifest's own comment, the API deployable declares its own
|
||||
# runtime dependencies HERE — this file is what the container image
|
||||
# installs (see the Dockerfile at the repository root).
|
||||
#
|
||||
# EXACT pins: the versions installed in the deployed image
|
||||
# (nestquest-api:latest on the API box, read with importlib.metadata),
|
||||
# so a rebuild resolves the same packages. The four direct
|
||||
# dependencies come first; the rest is their resolved transitive
|
||||
# closure, pinned too so nothing floats. The dev group's floors in
|
||||
# pyproject.toml are unchanged. Bump deliberately, together with the
|
||||
# Dockerfile's digest-pinned base image.
|
||||
fastapi==0.141.1
|
||||
uvicorn==0.53.0
|
||||
pyjwt[crypto]==2.15.0
|
||||
httpx==0.28.1
|
||||
|
||||
# Transitive dependencies of the above.
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
certifi==2026.7.22
|
||||
cffi==2.1.1
|
||||
click==8.5.0
|
||||
cryptography==50.0.1
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
idna==3.20
|
||||
pycparser==3.0
|
||||
pydantic==2.13.5
|
||||
pydantic_core==2.46.5
|
||||
starlette==1.7.0
|
||||
typing-inspection==0.4.4
|
||||
typing_extensions==4.16.0
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.6.0"
|
||||
"version": "0.7.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Example environment for the NestQuest API container deployment.
|
||||
#
|
||||
# Copy to ``deploy/.env`` (git-ignored) and fill in REAL values there;
|
||||
# this example carries placeholder names only and is never a source of
|
||||
# secrets. Compose loads ``deploy/.env`` into the api service; the
|
||||
# API fails fast on startup when any of these is unset or empty
|
||||
# (api/config.py — there are no silent defaults).
|
||||
|
||||
# The static panel-plane service token the HA integration presents as
|
||||
# a Bearer credential on /api/v1/panel/* routes. Generate one, e.g.:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
NESTQUEST_PANEL_TOKEN=
|
||||
|
||||
# The Authentik OIDC settings the admin plane verifies JWTs against.
|
||||
NESTQUEST_OIDC_ISSUER=
|
||||
NESTQUEST_OIDC_AUDIENCE=
|
||||
NESTQUEST_OIDC_JWKS_URL=
|
||||
|
||||
# The host interface the API's port 8080 is published on (compose
|
||||
# interpolation, not read by the API). Defaults to 127.0.0.1 when
|
||||
# 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=
|
||||
@@ -0,0 +1,553 @@
|
||||
# NestQuest API — deployment and restore runbook
|
||||
|
||||
Operator procedure to rebuild the production NestQuest API deployment end
|
||||
to end from this repository, restore its database from backup, and verify
|
||||
it. Every value below is the real, non-secret production value. **No
|
||||
secret appears in this file** — secrets are named and their location is
|
||||
given, never their content.
|
||||
|
||||
| Piece | Where | Real value |
|
||||
| --- | --- | --- |
|
||||
| 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` |
|
||||
| Home Assistant box (panel client) | LAN | `10.60.1.80` |
|
||||
| Local DNS | Pi-hole | `10.60.1.101` |
|
||||
| Backup target | Synology NAS `CubeCraft-SRVR1`, SFTP | `10.60.1.50:/homes/Joshua/nestquest-backups` |
|
||||
|
||||
All `docker compose` commands below are run **on the API box, from the
|
||||
repository root**. The shorthand `dc` means:
|
||||
|
||||
alias dc='docker compose -f deploy/compose.yaml'
|
||||
|
||||
Compose v2.24 or newer is required (`env_file` uses the `path`/`required`
|
||||
form).
|
||||
|
||||
---
|
||||
|
||||
## 1. Image build
|
||||
|
||||
The image is defined by the root `Dockerfile` and is built from the
|
||||
**repository root** as the build context: the API (`api/`) loads the
|
||||
bundled domain core from `custom_components/nestquest/core/` by file
|
||||
location, so both trees must be in the context. `.dockerignore` keeps
|
||||
everything else (including `deploy/`, `.env` files and `*.db`) out of the
|
||||
build.
|
||||
|
||||
- Multi-stage on `python:3.12-slim`: the builder installs
|
||||
`api/requirements.txt`, the runtime copies only the installed packages
|
||||
plus `api/` and `custom_components/nestquest/core/` into `/app`.
|
||||
- Runs as the non-root system user `nestquest` (uid/gid `999`), which owns
|
||||
`/var/lib/nestquest`.
|
||||
- Command: `uvicorn --factory api.app:create_app --host 0.0.0.0 --port 8080`.
|
||||
|
||||
Get the code and build:
|
||||
|
||||
git clone ssh://code.cubecraftlabs.com:2288/Maniacs_Mansion/NestQuest.git
|
||||
cd NestQuest
|
||||
git checkout <release branch or tag being deployed>
|
||||
dc build api # equivalent: docker build -t nestquest-api:latest .
|
||||
|
||||
Confirm the runtime user (the Litestream sidecar and the key ownership in
|
||||
section 5 depend on uid 999):
|
||||
|
||||
docker run --rm --entrypoint id nestquest-api:latest
|
||||
# uid=999(nestquest) gid=999(nestquest) groups=999(nestquest)
|
||||
|
||||
## 2. Compose
|
||||
|
||||
`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`
|
||||
(fixed by compose, not a secret). `docker compose down` keeps named
|
||||
volumes; **never run `down -v`** on the API box. Docker prefixes the
|
||||
volume with the compose project name — with the default project
|
||||
(`deploy`, the compose file's directory) it is `deploy_nestquest-db`;
|
||||
confirm with `docker volume ls | grep nestquest-db`.
|
||||
- **Published port**: `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080`.
|
||||
The default binds localhost only, so an unconfigured host is never
|
||||
exposed. On the API box `deploy/.env` sets
|
||||
`NESTQUEST_BIND_ADDRESS=10.60.1.14` so Traefik on nukapi-03 can reach
|
||||
`http://10.60.1.14:8080`.
|
||||
- **Secrets** come from `deploy/.env` (git-ignored, section 5) via
|
||||
`env_file`.
|
||||
|
||||
Bring the stack up (after section 5's secrets exist):
|
||||
|
||||
dc up -d --build
|
||||
dc ps # api "healthy", litestream "Up"
|
||||
curl -s http://10.60.1.14:8080/health # {"status":"ok"}
|
||||
|
||||
## 3. TLS and reverse proxy (Traefik on nukapi-03)
|
||||
|
||||
TLS is terminated by the **shared** Traefik instance on `nukapi-03`
|
||||
(`10.60.1.33`), not by this repository. Its NestQuest routing is the
|
||||
owner-authored dynamic file-provider config
|
||||
**`config/dynamic/nestquest-ccl.yml`** in the Traefik deployment on
|
||||
nukapi-03 — that live file is authoritative; it is not stored in this
|
||||
repository. What it must contain:
|
||||
|
||||
| Router | Rule | Middleware | Service |
|
||||
| --- | --- | --- | --- |
|
||||
| `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` |
|
||||
|
||||
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):
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
nestquest-panel:
|
||||
rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)"
|
||||
priority: 200
|
||||
entryPoints: [websecure]
|
||||
middlewares: [nestquest-panel-lan]
|
||||
service: nestquest-api
|
||||
tls:
|
||||
certResolver: myresolver
|
||||
nestquest-admin:
|
||||
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-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
|
||||
Encrypt) resolver using the **Cloudflare DNS-01 challenge**; the
|
||||
Cloudflare API token is held by the Traefik deployment on
|
||||
nukapi-03, never in this repository. Because DNS-01 proves control
|
||||
through a DNS TXT record, issuance and renewal need no inbound HTTP and
|
||||
work regardless of the Cloudflare proxy setting. Certificates renew
|
||||
automatically.
|
||||
|
||||
**Cloudflare proxy note.** Public traffic for
|
||||
`nestquest.cubecraftlabs.com` resolves through Cloudflare, so a request
|
||||
from the internet reaches Traefik from a Cloudflare edge address, never
|
||||
from `10.60.1.80` — which is exactly why the panel allowlist refuses it
|
||||
(403). The HA box does not go through Cloudflare at all: the local DNS
|
||||
override in section 6 sends it straight to Traefik on the LAN, so Traefik
|
||||
sees its real address. Do **not** add Cloudflare ranges to the panel
|
||||
allowlist or make the allowlist trust `X-Forwarded-For`; that would let
|
||||
any internet client through to the panel plane.
|
||||
|
||||
## 4. Authentik (admin identity)
|
||||
|
||||
Admin-plane requests (`/api/v1/admin/*`) carry an Authentik OIDC access
|
||||
token (JWT). The API verifies issuer, audience, RS256 signature against
|
||||
the JWKS, and expiry, then requires the `groups` claim to contain
|
||||
`nestquest-admins` (`api/auth.py`). Configure in Authentik
|
||||
(`https://auth.cubecraftlabs.com`, admin interface):
|
||||
|
||||
1. **Provider** — *OAuth2/OpenID Provider*:
|
||||
- Client type: **Public** (no client secret; the admin PWA is a browser
|
||||
SPA).
|
||||
- PKCE: the PWA always uses the authorization-code flow with PKCE,
|
||||
`code_challenge_method=S256`, and never sends a client secret.
|
||||
- Redirect URI (strict): `https://nestquest.cubecraftlabs.com/auth/callback`.
|
||||
- Signing key: an **RSA** certificate (the API only accepts `RS256`).
|
||||
- Scopes: the default `openid`, `email`, `profile` mappings (the PWA
|
||||
requests `openid email profile`; Authentik's default `profile`
|
||||
mapping emits the `groups` claim the API checks).
|
||||
- Note the generated **Client ID** — it is the audience below and the
|
||||
PWA's `VITE_AUTHENTIK_CLIENT_ID`.
|
||||
2. **Application** — slug **`nestquest`**, bound to the provider above.
|
||||
The slug fixes the issuer URL.
|
||||
3. **Group** — **`nestquest-admins`**. Add each parent's Authentik user.
|
||||
Membership is the single source of truth for "who is an admin"; the
|
||||
API refuses a signed-in non-member with 403.
|
||||
|
||||
API settings (in `deploy/.env`, section 5):
|
||||
|
||||
| Variable | Value |
|
||||
| --- | --- |
|
||||
| `NESTQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` |
|
||||
| `NESTQUEST_OIDC_AUDIENCE` | the provider's Client ID |
|
||||
| `NESTQUEST_OIDC_JWKS_URL` | `https://auth.cubecraftlabs.com/application/o/nestquest/jwks/` |
|
||||
|
||||
Check them against Authentik's discovery document (the issuer must match
|
||||
byte for byte, including the trailing slash):
|
||||
|
||||
curl -s https://auth.cubecraftlabs.com/application/o/nestquest/.well-known/openid-configuration \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["issuer"]); print(d["jwks_uri"])'
|
||||
|
||||
## 5. Secrets
|
||||
|
||||
Nothing below is ever committed; `.gitignore` excludes `.env`,
|
||||
`**/.env`, `deploy/*key*` and `deploy/litestream_key*`.
|
||||
|
||||
**`deploy/.env`** — created on the API box from the committed example:
|
||||
|
||||
cp deploy/.env.example deploy/.env
|
||||
chmod 600 deploy/.env
|
||||
# edit deploy/.env and fill in:
|
||||
# NESTQUEST_PANEL_TOKEN = a new random token, e.g.
|
||||
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# (the same value is configured in the HA integration)
|
||||
# NESTQUEST_OIDC_ISSUER = section 4
|
||||
# NESTQUEST_OIDC_AUDIENCE = section 4 (Authentik Client ID)
|
||||
# NESTQUEST_OIDC_JWKS_URL = section 4
|
||||
# NESTQUEST_BIND_ADDRESS = 10.60.1.14
|
||||
|
||||
The API fails fast at startup if any of the four API
|
||||
variables is unset or empty (`api/config.py`); `dc logs api` names the
|
||||
missing one. `NESTQUEST_BIND_ADDRESS` is read by compose only.
|
||||
|
||||
**Litestream SFTP key `deploy/litestream_key`** — a dedicated keypair that
|
||||
lives only on the API box. Create it once, before the first `up`:
|
||||
|
||||
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
|
||||
sudo chown 999:999 deploy/litestream_key
|
||||
chmod 600 deploy/litestream_key
|
||||
|
||||
Then append `restrict ` followed by the contents of
|
||||
`deploy/litestream_key.pub` as one line to
|
||||
`joshua@10.60.1.50:~/.ssh/authorized_keys` on the NAS. Compose mounts the
|
||||
private key read-only at `/etc/litestream/sftp_key` in the sidecar, which
|
||||
runs as uid 999 — hence the ownership. The NAS host key is pinned in the
|
||||
committed `deploy/litestream.yml` (`host-key`, the NAS's ECDSA key), so no
|
||||
`known_hosts` is involved.
|
||||
|
||||
| Secret | Supplied by | Consumed by |
|
||||
| --- | --- | --- |
|
||||
| `NESTQUEST_PANEL_TOKEN` | `deploy/.env` → `env_file` | `api` (panel plane); also set in the HA integration |
|
||||
| `NESTQUEST_OIDC_*` (not secret, but environment-supplied) | `deploy/.env` → `env_file` | `api` (admin plane) |
|
||||
| SFTP private key | `deploy/litestream_key` → read-only bind mount | `litestream` |
|
||||
| Cloudflare DNS API token | Traefik deployment on nukapi-03 | Traefik ACME `myresolver` |
|
||||
|
||||
## 6. Network separation and firewall
|
||||
|
||||
- **Panel plane** (`/api/v1/panel/*`, static service token): reachable
|
||||
only from the HA box. Traefik's `ipAllowList` on the panel router allows
|
||||
`10.60.1.80/32` and returns **403** to every other source, before the
|
||||
request reaches the API. The API additionally requires the panel token
|
||||
(401 without it).
|
||||
- **Admin plane** (`/api/v1/admin/*`) and `/health`, `/docs`: public
|
||||
through Cloudflare and Traefik; admin routes are protected by the API's
|
||||
Authentik JWT check (401 without a valid token, 403 without
|
||||
`nestquest-admins` or when a panel token is presented).
|
||||
- **Local DNS override** so the HA box reaches Traefik directly, keeping
|
||||
its real source address: in Pi-hole (`10.60.1.101`) local DNS records,
|
||||
add `nestquest.cubecraftlabs.com` → `10.60.1.33`. The HA box
|
||||
must use Pi-hole as its resolver. Check from the HA box:
|
||||
|
||||
nslookup nestquest.cubecraftlabs.com 10.60.1.101 # Address: 10.60.1.33
|
||||
|
||||
- **Direct `:8080` access is firewalled.** The API box publishes `8080`
|
||||
on `10.60.1.14`, which alone would let any LAN host bypass Traefik and
|
||||
the panel `ipAllowList`. This is enforced on the API box in Docker's
|
||||
`DOCKER-USER` chain (which Docker evaluates before its own forwarding
|
||||
rules for published ports), in this order:
|
||||
|
||||
-A DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN # Traefik on nukapi-03
|
||||
-A DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN # the API box itself
|
||||
-A DOCKER-USER -p tcp --dport 8080 -j DROP # every other source
|
||||
|
||||
The rules are persisted by the systemd oneshot unit
|
||||
**`nestquest-api-firewall.service`**, committed as
|
||||
`deploy/systemd/nestquest-api-firewall.service` (ordered after
|
||||
`docker.service`, it idempotently applies the three rules at every
|
||||
boot). Install and enable it on the API box, from the repository
|
||||
checkout, **before** exposing the API:
|
||||
|
||||
sudo cp deploy/systemd/nestquest-api-firewall.service /etc/systemd/system/ \
|
||||
&& sudo systemctl daemon-reload \
|
||||
&& sudo systemctl enable --now nestquest-api-firewall.service
|
||||
|
||||
The HA box and every other client must use the Traefik hostname,
|
||||
never `:8080` directly. Check it:
|
||||
|
||||
sudo systemctl is-enabled nestquest-api-firewall.service # enabled
|
||||
sudo systemctl is-active nestquest-api-firewall.service # active
|
||||
sudo iptables -S DOCKER-USER # the three rules above, in that order
|
||||
|
||||
# on nukapi-03 (10.60.1.33) — allowed:
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 200
|
||||
# on any other LAN host, e.g. 10.60.1.101 — dropped:
|
||||
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 000, timeout
|
||||
# public path still works:
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health # 200
|
||||
|
||||
On a rebuilt API box, install the unit with the commands above before
|
||||
exposing the API. The committed file is the authoritative copy; the
|
||||
deployed unit must match it:
|
||||
|
||||
diff deploy/systemd/nestquest-api-firewall.service \
|
||||
/etc/systemd/system/nestquest-api-firewall.service # no output
|
||||
|
||||
## 7. Backup (Litestream to the Synology NAS)
|
||||
|
||||
The `litestream` sidecar (config `deploy/litestream.yml`, mounted at
|
||||
`/etc/litestream.yml`) replicates `/var/lib/nestquest/nestquest.db` to:
|
||||
|
||||
- SFTP `joshua@10.60.1.50`, path `/homes/Joshua/nestquest-backups` (the
|
||||
NAS's SFTP view is chrooted to the shared folders; over SSH the same
|
||||
directory is `/volume1/homes/Joshua/nestquest-backups`).
|
||||
- Every committed change is shipped as an LTX file within about a second.
|
||||
Litestream 0.5.17 compacts them into coarser levels on its built-in
|
||||
schedule (level 1 every 30s, level 2 every 5m, level 3 every 1h).
|
||||
- **Snapshots**: `snapshot.interval: 24h` (a full snapshot daily);
|
||||
**retention**: `snapshot.retention: 168h` — a week of snapshots plus the
|
||||
changes between them, i.e. a 7-day point-in-time restore window.
|
||||
|
||||
The sidecar runs as uid 999 with a read-only root filesystem,
|
||||
`no-new-privileges` and all capabilities dropped. It needs the database
|
||||
volume read-write (it keeps its `_litestream_*` tables and writes the
|
||||
WAL); its local state lives beside the database in
|
||||
`/var/lib/nestquest/.nestquest.db-litestream/`.
|
||||
|
||||
Check it:
|
||||
|
||||
dc ps litestream # Up
|
||||
dc logs --tail 50 litestream # no "error" lines
|
||||
dc exec litestream litestream status # status "ok", local txid advancing
|
||||
dc exec litestream litestream ltx /var/lib/nestquest/nestquest.db | tail # newest LTX files and timestamps
|
||||
|
||||
A change made in the app should appear as a new level-0 LTX file within
|
||||
seconds. On the NAS, the files are under
|
||||
`/volume1/homes/Joshua/nestquest-backups/ltx/`.
|
||||
|
||||
## 8. Restore
|
||||
|
||||
Restores the database from the SFTP replica into the `nestquest-db`
|
||||
volume at `NESTQUEST_DB_PATH` (`/var/lib/nestquest/nestquest.db`). The
|
||||
same procedure serves both a damaged database on the existing box and a
|
||||
rebuilt box. The restore runs in one-off `litestream` containers so it
|
||||
has the same user (999), volume, config and SFTP key as the sidecar.
|
||||
|
||||
**0. Fresh box only.** Complete sections 1 and 5, then start **only the
|
||||
API** once so the volume exists and is owned by uid 999:
|
||||
|
||||
dc up -d api
|
||||
|
||||
Do not start `litestream` yet — it would begin replicating the new,
|
||||
empty database the API just created. The swap in step 4 moves that empty
|
||||
file aside, and step 5 creates and starts the sidecar with
|
||||
`dc up -d litestream` once the API is healthy.
|
||||
|
||||
**1. Stop writers** — both the API and the sidecar (Litestream must not
|
||||
replicate while the file is being replaced):
|
||||
|
||||
dc stop api litestream
|
||||
|
||||
**2. Restore beside the live database** (never over it), with a full
|
||||
integrity check:
|
||||
|
||||
dc run --rm --no-deps litestream restore \
|
||||
-config /etc/litestream.yml \
|
||||
-integrity-check full \
|
||||
-o /var/lib/nestquest/nestquest.db.restored \
|
||||
/var/lib/nestquest/nestquest.db
|
||||
|
||||
Expect `post-restore integrity check passed` and exit code 0. For a
|
||||
point in time within the retention window, add
|
||||
`-timestamp 2026-09-24T12:00:00Z` (UTC). To preview without writing, add
|
||||
`-dry-run`.
|
||||
|
||||
**3. Inspect the restored file** before switching to it — confirm known
|
||||
children and definitions are present:
|
||||
|
||||
dc run --rm --no-deps --entrypoint sqlite3 litestream \
|
||||
/var/lib/nestquest/nestquest.db.restored \
|
||||
'PRAGMA integrity_check;' \
|
||||
'SELECT id, display_name FROM children;' \
|
||||
'SELECT id, title FROM quest_definitions;'
|
||||
|
||||
Expect `ok`, then the household's children and quest definitions.
|
||||
|
||||
**4. Swap it into place**, keeping the old files (database, its
|
||||
`-wal`/`-shm`, and Litestream's local state) in a timestamped directory
|
||||
on the volume:
|
||||
|
||||
dc run --rm --no-deps --entrypoint sh litestream -c '
|
||||
set -e
|
||||
cd /var/lib/nestquest
|
||||
ts=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
mkdir -p "pre-restore-$ts"
|
||||
for f in nestquest.db nestquest.db-wal nestquest.db-shm .nestquest.db-litestream; do
|
||||
if [ -e "$f" ]; then mv "$f" "pre-restore-$ts/"; fi
|
||||
done
|
||||
mv nestquest.db.restored nestquest.db
|
||||
ls -la'
|
||||
|
||||
**5. Start the API, then the sidecar:**
|
||||
|
||||
dc start api
|
||||
dc ps api # wait for "healthy"
|
||||
dc up -d litestream
|
||||
dc ps litestream # Up
|
||||
dc logs --tail 20 litestream
|
||||
|
||||
Use `dc up -d litestream`, not `dc start`: on a fresh box step 0 never
|
||||
created the sidecar container, and `start` cannot start a container that
|
||||
does not exist. `up -d` creates it if needed (or starts the existing one),
|
||||
so continuous replication is back online on both paths.
|
||||
|
||||
The sidecar resumes replication from the restored position (it logs
|
||||
`detected database behind replica` / `fetched latest L0 file from
|
||||
replica`, then continues normally).
|
||||
|
||||
**6. Verify through the API** — the panel snapshot lists the restored
|
||||
children. On the API box against `10.60.1.14:8080` directly (other LAN
|
||||
hosts, including the HA box, are dropped on `:8080` by the section 6
|
||||
firewall; from the HA box use the Traefik hostname instead):
|
||||
|
||||
PANEL_TOKEN=$(grep '^NESTQUEST_PANEL_TOKEN=' deploy/.env | cut -d= -f2-)
|
||||
curl -s -H "Authorization: Bearer $PANEL_TOKEN" \
|
||||
http://10.60.1.14:8080/api/v1/panel/snapshot
|
||||
|
||||
Expect HTTP 200 and `children` naming the known child(ren). Then run
|
||||
section 9.
|
||||
|
||||
Once satisfied, remove the old copy:
|
||||
`dc run --rm --no-deps --entrypoint rm litestream -rf /var/lib/nestquest/pre-restore-<ts>`.
|
||||
|
||||
This exact sequence (stop, restore with integrity check, inspect, swap,
|
||||
start, snapshot shows the restored child, replication resumes) was
|
||||
rehearsed against a local file replica with the committed compose file;
|
||||
the live proof against the SFTP replica is recorded separately by the
|
||||
controller.
|
||||
|
||||
## 9. Verification checklist
|
||||
|
||||
Run after every deploy and every restore. `$PANEL_TOKEN` is the value from
|
||||
`deploy/.env`; never paste it into shared logs.
|
||||
|
||||
| # | Check | Command | Expect |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Health over TLS | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` |
|
||||
| 2 | Valid certificate | `curl -sv https://nestquest.cubecraftlabs.com/health 2>&1 \| grep -E 'issuer\|expire date'` | Let's Encrypt issuer, future expiry (no `-k` needed) |
|
||||
| 3 | Docs public | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/docs` | `200` |
|
||||
| 4 | Panel refused externally | from any host other than `10.60.1.80` (e.g. a phone off Wi-Fi, or a LAN machine): `curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $PANEL_TOKEN" https://nestquest.cubecraftlabs.com/api/v1/panel/snapshot` | `403` (Traefik allowlist) |
|
||||
| 5 | Panel allowed from HA box | on `10.60.1.80`: same command | `200`, snapshot JSON |
|
||||
| 6 | Panel needs the token | on `10.60.1.80`, without the header | `401` |
|
||||
| 7 | Admin JWT accepted | `curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $ADMIN_JWT" https://nestquest.cubecraftlabs.com/api/v1/admin/ping` with a token of a `nestquest-admins` member | `200`, `{"status":"ok","sub":...}` |
|
||||
| 8 | Non-admin refused | same, with a token of a user **not** in `nestquest-admins` | `403`, "Admin access requires membership in the nestquest-admins group" |
|
||||
| 9 | No/invalid token refused | same, without a header or with garbage | `401` |
|
||||
| 10 | Panel token refused on admin | same, with `$PANEL_TOKEN` | `403` |
|
||||
| 11 | Backup running | `dc exec litestream litestream status` | status `ok` |
|
||||
|
||||
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` |
|
||||
@@ -0,0 +1,98 @@
|
||||
# Compose deployment scaffold for the NestQuest API service (Feature
|
||||
# 17). The SQLite database lives on the NAMED VOLUME ``nestquest-db``
|
||||
# mounted at /var/lib/nestquest — recreating the container keeps the
|
||||
# household data (``docker compose down`` leaves named volumes alone).
|
||||
#
|
||||
# Configuration is supplied by the environment: the fixed database
|
||||
# path here, and the secret values (panel service token, Authentik
|
||||
# OIDC settings) from ``deploy/.env`` — a git-ignored file you create
|
||||
# from ``deploy/.env.example`` (placeholder names only; never commit
|
||||
# real values).
|
||||
#
|
||||
# TLS is terminated by the shared Traefik reverse proxy on another
|
||||
# host, which forwards to this service on port 8080. The published
|
||||
# port binds NESTQUEST_BIND_ADDRESS (read from deploy/.env, the
|
||||
# project directory's env file): it defaults to 127.0.0.1 so an
|
||||
# unconfigured deployment is never exposed; set it to the host's LAN
|
||||
# address on the API box so the proxy can reach it.
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
image: nestquest-api:latest
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080"
|
||||
environment:
|
||||
# The SQLite file inside the named volume; fixed, not a secret.
|
||||
NESTQUEST_DB_PATH: /var/lib/nestquest/nestquest.db
|
||||
env_file:
|
||||
# Secret values (NESTQUEST_PANEL_TOKEN, NESTQUEST_OIDC_*); absent
|
||||
# until you create deploy/.env from the example.
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- nestquest-db:/var/lib/nestquest
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
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
|
||||
# created on the API box before starting (see api/README.md).
|
||||
#
|
||||
# The database volume is mounted READ-WRITE on purpose: Litestream
|
||||
# cannot run against a read-only mount ("unable to open database file
|
||||
# (14)", even with its metadata moved elsewhere) because it writes its
|
||||
# _litestream_seq/_litestream_lock tables into the database, opens the
|
||||
# -wal/-shm files for writing, and checkpoints the WAL. The write
|
||||
# access is contained instead: it runs as the API's own non-root user
|
||||
# (uid/gid 999, owner of the volume) with a read-only root filesystem,
|
||||
# no privilege escalation and no Linux capabilities.
|
||||
litestream:
|
||||
image: litestream/litestream:0.5.17@sha256:4b02b9859a6b6b4087d8b8944e15f7e984bd7957cba322bbeee38b0e27b9656a
|
||||
restart: unless-stopped
|
||||
command: ["replicate", "-config", "/etc/litestream.yml"]
|
||||
user: "999:999"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- nestquest-db:/var/lib/nestquest
|
||||
- ./litestream.yml:/etc/litestream.yml:ro
|
||||
- ./litestream_key:/etc/litestream/sftp_key:ro
|
||||
|
||||
volumes:
|
||||
nestquest-db:
|
||||
@@ -0,0 +1,32 @@
|
||||
# Litestream continuous backup of the NestQuest SQLite database (Feature
|
||||
# 17). The ``litestream`` compose service mounts the same named volume
|
||||
# as the API and streams every committed change to the Synology NAS
|
||||
# over SFTP, so the backup lives OUTSIDE the container's volume.
|
||||
#
|
||||
# Nothing here is secret. The SFTP private key is a dedicated,
|
||||
# git-ignored keypair (deploy/litestream_key) mounted read-only by
|
||||
# compose at /etc/litestream/sftp_key; its public half is in
|
||||
# joshua@10.60.1.50:~/.ssh/authorized_keys. ``host-key`` pins the
|
||||
# NAS's host key so the connection is verified; it must be the ECDSA
|
||||
# key, because that is the algorithm Litestream's SSH client negotiates
|
||||
# with this NAS (pinning the ed25519 key fails with "host key mismatch").
|
||||
#
|
||||
# The Synology SFTP server is chrooted to the shared folders, so the
|
||||
# directory seen over SSH as /volume1/homes/Joshua/nestquest-backups is
|
||||
# /homes/Joshua/nestquest-backups over SFTP.
|
||||
|
||||
# Take a full snapshot daily and keep a week of snapshots plus the
|
||||
# incremental changes between them (point-in-time restore window).
|
||||
snapshot:
|
||||
interval: 24h
|
||||
retention: 168h
|
||||
|
||||
dbs:
|
||||
- path: /var/lib/nestquest/nestquest.db
|
||||
replicas:
|
||||
- type: sftp
|
||||
host: 10.60.1.50
|
||||
user: joshua
|
||||
path: /homes/Joshua/nestquest-backups
|
||||
key-path: /etc/litestream/sftp_key
|
||||
host-key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY=
|
||||
@@ -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/
|
||||
@@ -0,0 +1,11 @@
|
||||
# Build-context filter for deploy/pwa/Dockerfile (BuildKit reads
|
||||
# <Dockerfile>.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.*
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Restrict NestQuest API port 8080 to Traefik (DOCKER-USER)
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/bin/sh -c "iptables -C DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN 2>/dev/null || iptables -I DOCKER-USER 1 -s 10.60.1.33 -p tcp --dport 8080 -j RETURN; iptables -C DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN 2>/dev/null || iptables -I DOCKER-USER 2 -s 10.60.1.14 -p tcp --dport 8080 -j RETURN; iptables -C DOCKER-USER -p tcp --dport 8080 -j DROP 2>/dev/null || iptables -A DOCKER-USER -p tcp --dport 8080 -j DROP"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
Reference in New Issue
Block a user