Document the multi-pattern presence model and migration 9

Describe presence_patterns (zero or more home/away patterns per child,
each with its own cycle length and anchor date), the first-match-wins
evaluation order, and migration 9 replacing the one-per-child schedule
table. Update the Admin Schedule spec for the patterns list, pattern
editor, combined month grid and Sunday-first week; replace the
set_presence_pattern service row with the admin pattern routes; and add
the pre-upgrade backup and rollback step for migration 9 to the runbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 20:30:20 -04:00
co-authored by Claude Opus 5.5
parent 4172b286ff
commit 94c4617459
5 changed files with 173 additions and 15 deletions
+69
View File
@@ -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-<ts>.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-<ts>.db`.
This rollback has not yet been rehearsed.
+46 -11
View File
@@ -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 <Mon D>`) · 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 <weekday>, <Mon D>`.
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` / `<Child> · N patterns` (`<Child> · 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 <Child> — home every day."
- **Overrides** card: `Add` text action; rows with `calendar-x` / `calendar-check`,
title `<Child> away · <Mon D – D>`, 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 <Child>". 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 <Mon D>` (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)
+14 -1
View File
@@ -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.<entity>` | 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
+6 -3
View File
@@ -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
+38
View File
@@ -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