Compare commits

..
14 changed files with 359 additions and 33 deletions
@@ -1118,3 +1118,95 @@ describe("DefinitionsTab icon picker", () => {
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull(); expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
}); });
}); });
/* ── Start date ─────────────────────────────────────────────────────── */
function startDateInput(s: HTMLElement): HTMLInputElement {
return within(s).getByLabelText("Start date", { selector: "input" }) as HTMLInputElement;
}
describe("DefinitionsTab start date", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("a new task defaults to today and POSTs the chosen start date", 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();
const input = startDateInput(s);
expect(input.type).toBe("date");
expect(input.value).toBe("2026-09-23");
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.click(within(s).getByRole("button", { name: "Evening" }));
fireEvent.change(input, { target: { value: "2026-10-15" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { rule: DefinitionRule }).rule.start_date).toBe("2026-10-15");
});
it("an edit pre-fills the definition's start date and PATCHes a changed one", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
const input = startDateInput(s);
expect(input.value).toBe("2026-01-01");
fireEvent.change(input, { target: { value: "2026-11-02" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toEqual([
expect.objectContaining({
method: "PATCH",
path: "/api/v1/admin/quest-definitions/10",
body: expect.objectContaining({ rule: rule({ start_date: "2026-11-02" }) }),
}),
]);
});
it("the occurrence preview requests the chosen start date", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
await waitFor(() => expect(api.previews()).toHaveLength(1));
fireEvent.change(startDateInput(s), { target: { value: "2027-03-01" } });
await settleDebounce();
await waitFor(() => expect(api.previews()).toHaveLength(2));
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.start_date).toBe(
"2027-03-01",
);
});
it("a cleared start date is not submitted", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
fireEvent.change(startDateInput(s), { target: { value: "" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(0);
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
});
});
+11
View File
@@ -177,6 +177,7 @@ function parseWhole(value: string, min: number, max: number): number | null {
function buildRule(draft: Draft): DefinitionRule | string { function buildRule(draft: Draft): DefinitionRule | string {
const interval = parseWhole(draft.interval, 1, 999); const interval = parseWhole(draft.interval, 1, 999);
if (interval === null) return "Interval must be a whole number of at least 1."; if (interval === null) return "Interval must be a whole number of at least 1.";
if (draft.startDate === "") return "Pick a start date.";
const rule: DefinitionRule = { const rule: DefinitionRule = {
rule_type: "daily", rule_type: "daily",
interval, interval,
@@ -740,6 +741,16 @@ function EditSheet({
<OccurrencePreview ruleKey={previewKey} /> <OccurrencePreview ruleKey={previewKey} />
</Field> </Field>
<Field label="Start date" id="defs-label-start">
<input
className="defs-input"
type="date"
aria-labelledby="defs-label-start"
value={draft.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
</Field>
<Field label="Due time" id="defs-label-due"> <Field label="Due time" id="defs-label-due">
{orderedWindows.length === 0 ? ( {orderedWindows.length === 0 ? (
<p className="defs-hint">Pick a window to set its due time.</p> <p className="defs-hint">Pick a window to set its due time.</p>
@@ -235,6 +235,11 @@ async def create_quest_definition(
transaction, so the returned bundle is a consistent creation transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing. failed create persists nothing.
After the write, :func:`~.materialize.regenerate_for_definition`
materializes the new definition's instances over the rolling horizon
(host clock, :data:`~.const.DEFAULT_HORIZON_DAYS`), so it reaches the
board without waiting for an unrelated write.
""" """
name = _validate_text(title, "title", required=True) name = _validate_text(title, "title", required=True)
assert name is not None assert name is not None
@@ -269,6 +274,9 @@ async def create_quest_definition(
) from error ) from error
raise raise
decoded_rule = schedule_rule_from_storage(storage) decoded_rule = schedule_rule_from_storage(storage)
await regenerate_for_definition(database, snapshot.definition.id)
return CreatedQuestDefinition( return CreatedQuestDefinition(
definition=snapshot.definition, definition=snapshot.definition,
rule=decoded_rule, rule=decoded_rule,
+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` | | 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`) | | 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` | | 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 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 `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 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) ### 2.4 Roomy variant (1h)
Header becomes a hero: kicker 11px/700 uppercase, `8 of 12 done` 32px/800, 10px gradient 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) ## 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 - **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 — 10px/700 `--nq-a-ink-tertiary`, **Sunday first** (`S M T W T F S`); day cells
rotation-home days `--nq-a-selected` with `#201F7A` numerals, away days `aspect-ratio: 1`, radius 6px — home days `--nq-a-selected` with `#201F7A` numerals,
`--nq-a-page` with `--nq-a-ink-secondary`, override days `--nq-a-warning-bg` with away days `--nq-a-page` with `--nq-a-ink-secondary`, override days
`--nq-a-warning-ink` and a 1px warning border, today filled with the brand gradient in `--nq-a-warning-bg` with `--nq-a-warning-ink` and a 1px warning border, today filled
white 800. Legend row beneath, 10.5px/600, one swatch per state. with the brand gradient in white 800. Legend row beneath, 10.5px/600, one swatch per
- **Presence pattern** card: one row per child, name 13px/700 + rule 12px/600 state (`Home`, `Away`, `Override · away`, `Override · home`, `Today`), then the note
(`Weeks 1 & 3 of cycle`, `Every day`). "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`, - **Overrides** card: `Add` text action; rows with `calendar-x` / `calendar-check`,
title `<Child> away · <Mon D – D>`, meta reason, trailing `trash-2`. 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 - Cycle position is always computed from each pattern's own anchor date. **Never** from
or week parity (D-004). 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) ## 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 | | `sensor.nestquest_cycle_day` | int | 1d sub-line, 1f header |
| *configured* `weather.<entity>` | HA core | panel dock | | *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 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 — from an attribute payload on the per-child sensor (or a websocket command —
implementer's call), shaped: implementer's call), shaped:
@@ -45,12 +53,17 @@ implementer's call), shaped:
| `nestquest.create_quest_definition` | **yes** | 1e `New` | | `nestquest.create_quest_definition` | **yes** | 1e `New` |
| `nestquest.update_quest_definition` | **yes** | 1e sheet `Save changes` | | `nestquest.update_quest_definition` | **yes** | 1e sheet `Save changes` |
| `nestquest.set_quest_definition_active` | **yes** | 1e row action | | `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.create_presence_override` | **yes** | 1i `Save override` |
| `nestquest.delete_presence_override` | **yes** | 1f override row `trash-2` | | `nestquest.delete_presence_override` | **yes** | 1f override row `trash-2` |
| `nestquest.export_history_csv` | **yes** | 1g `CSV` | | `nestquest.export_history_csv` | **yes** | 1g `CSV` |
| `nestquest.manage_child` | **yes** | settings | | `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: `complete_quest` payload from the panel:
```yaml ```yaml
+6 -3
View File
@@ -11,7 +11,9 @@ rather than aliasing.
- `schema.py`: `task_definitions` → `quest_definitions`, `task_instances` → - `schema.py`: `task_definitions` → `quest_definitions`, `task_instances` →
`quest_instances`. Keep `completion_events`, `children`, `admin_users`, `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. - `db.py` / `store.py`: rename DAO methods and any SQL literals.
- `const.py`: no table names live there today; keep it that way. - `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. - `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 ## 2. Engines and materialisation
Unchanged in intent: pure recurrence engine, pure presence engine with anchor-date Unchanged in intent: pure recurrence engine, pure presence engine with anchor-date
arithmetic, daily materialiser over `horizon_days` at `day_rollover_time`. Only the arithmetic (per pattern, each with its own anchor; since schema 9 it combines a child's
instance fan-out changes (assignees × windows). 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 ## 3. Services, permissions, entities
Implement the service table in `ENTITIES-AND-SERVICES.md` §2 behind the permission 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. - **Backups:** continuous replication (e.g. Litestream) plus a proven restore.
A configured-but-untested backup is not a backup. 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 ## 4. Authentication: two planes
The kid panel and the admin PWA have opposite trust levels, so they use The kid panel and the admin PWA have opposite trust levels, so they use
+12 -10
View File
@@ -514,8 +514,10 @@ async def test_regenerate_household_never_duplicates(
client = admin_household.client client = admin_household.client
database = admin_household.database database = admin_household.database
before = await _instance_count(database) # The seed's creates materialized the horizon BEFORE Ada's custody
assert before > 0 # the seed already materialized a few days # pattern existed, so the first household walk may legitimately
# drop her away-day rows; only the second run must be stable.
assert await _instance_count(database) > 0
first = await client.post( first = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers() "/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -525,9 +527,8 @@ async def test_regenerate_household_never_duplicates(
assert body["scope"] == "household" assert body["scope"] == "household"
assert body["definition_id"] is None assert body["definition_id"] is None
assert body["child_id"] is None assert body["child_id"] is None
assert body["count"] >= before assert body["count"] > 0
after_first = await _instance_count(database) after_first = await _instance_count(database)
assert after_first >= before
second = await client.post( second = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers() "/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -544,17 +545,18 @@ async def test_regenerate_definition_scope_rematerializes(
) -> None: ) -> None:
"""The definition scope rebuilds that definition's horizon, twice, stably. """The definition scope rebuilds that definition's horizon, twice, stably.
Creating a definition through the admin route materializes NOTHING Creating a definition through the admin route already materializes
(the core's create path does not regenerate); the scoped regenerate its horizon (the core's create path regenerates); the scoped
call is what materializes its instances, and re-running it leaves regenerate call rebuilds the same rows, and re-running it leaves
the row count exactly where the first run left it. the row count exactly where the create left it.
""" """
client = admin_api.client client = admin_api.client
database = admin_api.database database = admin_api.database
ada = await _create_child(client, "Ada") ada = await _create_child(client, "Ada")
definition_id = await _create_daily_definition(client, [ada]) definition_id = await _create_daily_definition(client, [ada])
assert await _instance_count(database) == 0 created_count = await _instance_count(database)
assert created_count > 0
first = await client.post( first = await client.post(
"/api/v1/admin/regenerate", "/api/v1/admin/regenerate",
@@ -568,7 +570,7 @@ async def test_regenerate_definition_scope_rematerializes(
assert body["child_id"] is None assert body["child_id"] is None
assert body["count"] > 0 assert body["count"] > 0
after_first = await _instance_count(database) after_first = await _instance_count(database)
assert after_first == body["count"] assert after_first == body["count"] == created_count
second = await client.post( second = await client.post(
"/api/v1/admin/regenerate", "/api/v1/admin/regenerate",
+7 -2
View File
@@ -151,12 +151,17 @@ async def _seed_missed_household(
database, yesterday_iso, yesterday_iso, today=yesterday database, yesterday_iso, yesterday_iso, today=yesterday
) )
# Today's quest: created after the yesterday walk so it has no # Today's quest: created after the yesterday walk so it has no
# past-due instance, then materialized for today only. # past-due instance; ``end_date`` keeps it (and create's horizon
# regeneration) to today only.
await _quest_definitions.create_quest_definition( await _quest_definitions.create_quest_definition(
database, database,
"Recoverable chore", "Recoverable chore",
_recurrence.ScheduleRule.from_dict( _recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso} {
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
), ),
[ada.id], [ada.id],
[("afternoon", "17:00")], [("afternoon", "17:00")],
+8 -3
View File
@@ -149,13 +149,18 @@ async def _seed_household(
(today + datetime.timedelta(days=3)).isoformat(), (today + datetime.timedelta(days=3)).isoformat(),
today=today, today=today,
) )
# The stale quest is created AFTER the today walk, so its only # The stale quest's rule ends YESTERDAY, so create's own
# instance is the yesterday one written below. # regeneration writes nothing and its only instance is the
# yesterday one written below.
await _quest_definitions.create_quest_definition( await _quest_definitions.create_quest_definition(
database, database,
"Stale chore", "Stale chore",
_recurrence.ScheduleRule.from_dict( _recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": yesterday_iso} {
"rule_type": "daily",
"start_date": yesterday_iso,
"end_date": yesterday_iso,
}
), ),
[ada.id], [ada.id],
[("morning", "09:00")], [("morning", "09:00")],
+8 -3
View File
@@ -102,15 +102,20 @@ async def _seed_missed_household(
database, "Old chore", rule, [ada.id], [("evening", "18:30")] database, "Old chore", rule, [ada.id], [("evening", "18:30")]
) )
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday) await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
# A quest due TODAY (created after the yesterday walk so it has # A quest due TODAY only (created after the yesterday walk so it
# no past-due instance): the post-downtime catch-up run's # has no past-due instance; ``end_date`` keeps create's horizon
# regeneration to today): the post-downtime catch-up run's
# target — inside [watermark=today, later_today) but not # target — inside [watermark=today, later_today) but not
# inside the day-one sweep's window. # inside the day-one sweep's window.
await create_quest_definition( await create_quest_definition(
database, database,
"Recoverable chore", "Recoverable chore",
ScheduleRule.from_dict( ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso} {
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
), ),
[ada.id], [ada.id],
[("afternoon", "17:00")], [("afternoon", "17:00")],
+4
View File
@@ -468,7 +468,11 @@ async def _materialize_with_change(
proceeds with the snapped presence). This deterministically proceeds with the snapped presence). This deterministically
exercises the snapshot-to-insert gap that a config edit can exercises the snapshot-to-insert gap that a config edit can
interleave into. interleave into.
``create_quest_definition`` already materializes its horizon, so the
table is cleared first: the gated walk must be the only writer.
""" """
await database.execute("DELETE FROM quest_instances")
original_upsert = QuestInstancesDao.upsert_if_valid original_upsert = QuestInstancesDao.upsert_if_valid
entered = asyncio.Event() entered = asyncio.Event()
release = asyncio.Event() release = asyncio.Event()
+36
View File
@@ -168,6 +168,42 @@ def test_create_several_children_several_windows_with_due_times(
_with_db(tmp_path, "create-many.db")(_body) _with_db(tmp_path, "create-many.db")(_body)
def test_create_materializes_future_instances(tmp_path) -> None:
"""A fresh create regenerates its rolling horizon immediately.
Without this the new definition has no ``quest_instances`` rows until
some unrelated write regenerates it, so it never reaches the board.
"""
assert DEFAULT_HORIZON_DAYS >= 7
async def _body(database, children, rules, definitions, child):
today = _today_iso()
created = await create_quest_definition(
database,
"Take out bins",
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={0},
start_date=today,
),
[child.id],
["morning"],
)
rows = await database.fetch_all(
"SELECT child_id, due_date FROM quest_instances "
"WHERE definition_id = ?",
(created.definition.id,),
)
assert rows, "create materialized no instances"
assert {row[0] for row in rows} == {child.id}
for _child_id, due_date in rows:
assert due_date >= today
assert datetime.date.fromisoformat(due_date).weekday() == 0
return None
_with_db(tmp_path, "create-materializes.db")(_body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# create_quest_definition: argument rejections # create_quest_definition: argument rejections
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------