diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index c818852..c18d6f8 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -551,3 +551,72 @@ The file provider reloads the dynamic file on save. | 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` | + +## 11. Upgrades and rollback + +The API applies pending schema migrations when it starts (FastAPI +lifespan, before `/health` answers), so **the first start of a new +version is the point of no return** for any migration it carries. A +build refuses a database whose schema version is newer than it +understands ("newer than this integration understands … downgrades are +not supported") and does not start. + +### Migration 9 (multiple presence patterns) + +Migration 9 creates `presence_patterns`, copies every row of the old +one-per-child `presence_schedules` table into it as a `home` pattern +named `Home schedule`, and **drops `presence_schedules`**. Version 0.7.1 +refuses the resulting version-9 database, and could not read +`presence_patterns` anyway, so the only way back to 0.7.1 is a copy of +the database taken **before** the first start on the new version. + +**Before the first start on the new version**, with the old version +still deployed: + +1. Stop writers (as section 8 step 1): + + dc stop api litestream + +2. Copy the database file beside the live one with SQLite's online + backup (includes anything still in the WAL), then check it: + + ts=$(date -u +%Y%m%dT%H%M%SZ) + dc run --rm --no-deps --entrypoint sqlite3 litestream \ + /var/lib/nestquest/nestquest.db \ + ".backup /var/lib/nestquest/pre-v9-$ts.db" + dc run --rm --no-deps --entrypoint sqlite3 litestream \ + /var/lib/nestquest/pre-v9-$ts.db \ + 'PRAGMA integrity_check;' \ + 'SELECT version FROM nestquest_schema_version;' \ + 'SELECT count(*) FROM presence_schedules;' + + Expect `ok`, schema version `8`, and the number of presence + schedules. Write down `$ts`. + +3. Also record the Litestream replica position and the UTC time, so a + point-in-time restore (section 8, `-timestamp`) can reach the + pre-upgrade state even if the local copy is lost: + + dc up -d litestream + dc exec litestream litestream status # note the txid + date -u +%Y-%m-%dT%H:%M:%SZ # note the time + dc stop litestream + + The replica only covers the 7-day retention window (section 7). + +4. Deploy the new version (sections 1–2) and run section 9. Confirm the + live database is at version `9` (the section 8 step 3 `sqlite3` + command against `nestquest.db`, with + `'SELECT version FROM nestquest_schema_version;'`) and that the + Schedule tab lists each child's old schedule as a `Home schedule` + pattern. + +**Rollback to 0.7.1:** check out and build 0.7.1 (section 1), then put +the pre-upgrade database back with section 8: either steps 1 and 4–6 +with `pre-v9-.db` in place of `nestquest.db.restored`, or the full +restore with `-timestamp` set to the time noted in step 3. Presence +pattern changes made after the upgrade are not in that copy and are +lost. Once the new version has proven itself, remove the copy: +`dc run --rm --no-deps --entrypoint rm litestream -f /var/lib/nestquest/pre-v9-.db`. + +This rollback has not yet been rehearsed. diff --git a/design/ADMIN-SPEC.md b/design/ADMIN-SPEC.md index 1cb74a7..17dc1a1 100644 --- a/design/ADMIN-SPEC.md +++ b/design/ADMIN-SPEC.md @@ -37,7 +37,7 @@ One card, rows divided by `--nq-a-divider`, row padding 11px 13px: meta 11px/600 (`3 of 7 · 1 overdue`; all-done in `--nq-a-success`; away reads `Away · returns `) · 82×6px progress track with gradient fill. An away child's avatar is flat `#9898A8` and the row carries a lowercase `override` pill when the absence -comes from a presence override rather than the pattern. +comes from a presence override rather than a presence pattern. ### 2.4 Roomy variant (1h) Header becomes a hero: kicker 11px/700 uppercase, `8 of 12 done` 32px/800, 10px gradient @@ -89,19 +89,54 @@ yet defined** — pick 24–40 household-relevant glyphs and ship them as a grid ## 4. Schedule (1f) -- Header `Schedule` / `2-week cycle · anchor , `. +A child has **zero or more** presence patterns (schema 9). Each pattern has a name, a +kind (`Home` or `Away`), its own cycle length (1–4 weeks), its own anchor date, and a +weekday set per cycle week. The screen shows one child at a time. + +- Header `Schedule` / ` · N patterns` (` · no patterns, home every day` + when the child has none). +- **Child** chip row: one chip per child; the selected chip drives everything below. - **Month grid** card: month label 11px/700 uppercase + chevron pair; weekday initials - 10px/700 `--nq-a-ink-tertiary`; day cells `aspect-ratio: 1`, radius 6px — - rotation-home days `--nq-a-selected` with `#201F7A` numerals, away days - `--nq-a-page` with `--nq-a-ink-secondary`, override days `--nq-a-warning-bg` with - `--nq-a-warning-ink` and a 1px warning border, today filled with the brand gradient in - white 800. Legend row beneath, 10.5px/600, one swatch per state. -- **Presence pattern** card: one row per child, name 13px/700 + rule 12px/600 - (`Weeks 1 & 3 of cycle`, `Every day`). + 10px/700 `--nq-a-ink-tertiary`, **Sunday first** (`S M T W T F S`); day cells + `aspect-ratio: 1`, radius 6px — home days `--nq-a-selected` with `#201F7A` numerals, + away days `--nq-a-page` with `--nq-a-ink-secondary`, override days + `--nq-a-warning-bg` with `--nq-a-warning-ink` and a 1px warning border, today filled + with the brand gradient in white 800. Legend row beneath, 10.5px/600, one swatch per + state (`Home`, `Away`, `Override · away`, `Override · home`, `Today`), then the note + "Combined from every pattern: away beats home; overrides beat both." +- The grid shows the child's **combined** presence, first match wins: an override + covering the date; else away if any Away pattern covers it; else home if any Home + pattern covers it; else away when the child has at least one Home pattern (a Home + pattern lists the days the child is here); else home (only Away patterns, or none). +- **Presence patterns** card: `Add` text action; one row per pattern with + `calendar-check` (Home) / `calendar-x` (Away), title = pattern name 13px/700, meta + `Home · Every 2 weeks` 12px/600, and the weekday set (`Thu, Fri` for a 1-week cycle, + otherwise `Week 1: Sat, Sun · Week 2: No days`). Trailing `pencil` opens the pattern + editor (§4.1); trailing `trash-2` asks `Delete pattern?` inline before deleting. With + no patterns the card reads "No patterns for — home every day." - **Overrides** card: `Add` text action; rows with `calendar-x` / `calendar-check`, title ` away · `, meta reason, trailing `trash-2`. -- Cycle position is always computed from the anchor date. **Never** from ISO week numbers - or week parity (D-004). +- Cycle position is always computed from each pattern's own anchor date. **Never** from + ISO week numbers or week parity (D-004). +- Weekday numbering on the wire stays **Monday=0 .. Sunday=6**; Sunday-first is display + order only. + +### 4.1 Pattern editor + +Pushed screen with a back chevron; title `New pattern` / `Edit pattern`, sub "Repeating +presence for ". Form card fields: + +- **Name** text field (required, not blank). +- **Covered days are** 2-up segmented (`Away` / `Home`), selected = `#18181D`. +- **Cycle length** select (`1 week` … `4 weeks`) and **Anchor date** side by side. + Changing the cycle length keeps existing weeks and adds new weeks covering no day. +- One **weekday picker** per cycle week, labelled `Week N · from ` (the date is + anchor + (N−1) weeks): seven toggle cells in **Sunday-first** order + (`Su Mo Tu We Th Fr Sa`). A week may cover no day. + +Footer: `Cancel` (flex 1, white, bordered) + `Save pattern` (flex 2, gradient), as the +override editor (§6). Save stays disabled until the name is set, the anchor is a valid +date, and something changed. ## 5. History (1g) diff --git a/design/ENTITIES-AND-SERVICES.md b/design/ENTITIES-AND-SERVICES.md index 71fce72..35cb6df 100644 --- a/design/ENTITIES-AND-SERVICES.md +++ b/design/ENTITIES-AND-SERVICES.md @@ -19,6 +19,14 @@ Planned surface from CTXD CONTEXT.MD, adjusted for the design decisions in | `sensor.nestquest_cycle_day` | int | 1d sub-line, 1f header | | *configured* `weather.` | HA core | panel dock | +`present_today` is the child's **combined** presence across all of their presence patterns +(a child has zero or more, each `home` or `away`) and overrides, first match wins: an +override covering the date; else absent if any `away` pattern covers it; else present if +any `home` pattern covers it; else absent when the child has at least one `home` pattern; +else present. `cycle_day` is the 1-based day in the first patterned child's lowest-id +pattern, from that pattern's anchor date (never ISO week numbers or parity); `0` when no +child has a pattern. + Per-instance data is too granular for entity state. The cards read the day's instance list from an attribute payload on the per-child sensor (or a websocket command — implementer's call), shaped: @@ -45,12 +53,17 @@ implementer's call), shaped: | `nestquest.create_quest_definition` | **yes** | 1e `New` | | `nestquest.update_quest_definition` | **yes** | 1e sheet `Save changes` | | `nestquest.set_quest_definition_active` | **yes** | 1e row action | -| `nestquest.set_presence_pattern` | **yes** | 1f presence card | | `nestquest.create_presence_override` | **yes** | 1i `Save override` | | `nestquest.delete_presence_override` | **yes** | 1f override row `trash-2` | | `nestquest.export_history_csv` | **yes** | 1g `CSV` | | `nestquest.manage_child` | **yes** | settings | +Presence patterns have no HA service: the admin surface is the PWA (ARCHITECTURE §10), +which manages a child's patterns through the admin API — +`GET`/`POST /api/v1/admin/children/{child_id}/presence-patterns` to list and add, and +`PATCH`/`DELETE /api/v1/admin/presence-patterns/{pattern_id}` to edit and delete one +pattern (1f patterns card and pattern editor). + `complete_quest` payload from the panel: ```yaml diff --git a/design/IMPLEMENTATION-ORDER.md b/design/IMPLEMENTATION-ORDER.md index 8764254..ce5a2d9 100644 --- a/design/IMPLEMENTATION-ORDER.md +++ b/design/IMPLEMENTATION-ORDER.md @@ -11,7 +11,9 @@ rather than aliasing. - `schema.py`: `task_definitions` → `quest_definitions`, `task_instances` → `quest_instances`. Keep `completion_events`, `children`, `admin_users`, - `schedule_rules`, `presence_schedules`, `presence_overrides`. + `schedule_rules`, `presence_patterns`, `presence_overrides`. (`presence_patterns` — + zero or more `home`/`away` patterns per child — replaced the retired one-per-child + schedule table in schema 9; migration 9 converts its rows into `home` patterns.) - `db.py` / `store.py`: rename DAO methods and any SQL literals. - `const.py`: no table names live there today; keep it that way. - `tests/test_schema.py`, `tests/test_store.py`, `tests/test_db.py`: update expectations. @@ -43,8 +45,9 @@ Nullable int. Set for panel completions, null for admin ones. Still append-only. ## 2. Engines and materialisation Unchanged in intent: pure recurrence engine, pure presence engine with anchor-date -arithmetic, daily materialiser over `horizon_days` at `day_rollover_time`. Only the -instance fan-out changes (assignees × windows). +arithmetic (per pattern, each with its own anchor; since schema 9 it combines a child's +patterns and overrides first-match-wins), daily materialiser over `horizon_days` at +`day_rollover_time`. Only the instance fan-out changes (assignees × windows). ## 3. Services, permissions, entities Implement the service table in `ENTITIES-AND-SERVICES.md` §2 behind the permission diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 054b20e..0f78653 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,6 +61,44 @@ two-writer problem entirely. - **Backups:** continuous replication (e.g. Litestream) plus a proven restore. A configured-but-untested backup is not a backup. +### Presence model (schema 9) + +A child has **zero or more** presence patterns, stored one row each in +`presence_patterns` (`child_id` is not unique): + +| Column | Meaning | +|---|---| +| `name` | Label shown in the admin PWA, not blank | +| `kind` | `home` or `away` — what the pattern claims on the days it covers | +| `cycle_length_weeks` | 1–4; the pattern's own repeating cycle | +| `anchor_date` | ISO date that starts week 1 of the pattern's cycle | +| `pattern` | Pipe-separated weekday sets, one CSV segment per cycle week, weekdays **Monday=0 .. Sunday=6** (e.g. `0,2,4\|1,3`); an empty segment covers no day that week | + +Cycle position is anchor-date arithmetic per pattern — **never** ISO week +numbers or week parity (D-004). A child's presence on a date is decided +first match wins: + +1. A presence override covering the date — its flag is the answer. +2. Any `away` pattern covers the date — **away**. +3. Any `home` pattern covers the date — **present**. +4. The child has at least one `home` pattern — **away** (a home pattern lists + the days the child is home; every other day is away). +5. Otherwise (only `away` patterns, or no patterns) — **present**. + +The core presence engine, the snapshot's `present_today`, the materialiser's +skip-on-away-days rule, and the admin PWA's month grid all apply this order. +The admin plane manages patterns with +`GET`/`POST /api/v1/admin/children/{child_id}/presence-patterns` and +`PATCH`/`DELETE /api/v1/admin/presence-patterns/{pattern_id}`. + +**Migration 9** replaced the older one-schedule-per-child table: it creates +`presence_patterns`, copies every old schedule row into a `home` pattern +named `Home schedule` (cycle length, anchor date and weekday sets +unchanged, row id kept), and drops the old table, all in one transaction. +A lone `home` pattern answers exactly as the old schedule did, so no +child's presence changes. The drop is one-way and 0.7.1 refuses a +version-9 database; see the rollback step in `deploy/RUNBOOK.md` §11. + ## 4. Authentication: two planes The kid panel and the admin PWA have opposite trust levels, so they use