Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d763519a0 | ||
|
|
b91ecbd5f6 | ||
|
|
39417615a2 | ||
|
|
1c2a7222b3 | ||
|
|
ef6940d937 |
@@ -1118,95 +1118,3 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ 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,
|
||||||
@@ -741,16 +740,6 @@ 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,11 +235,6 @@ 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
|
||||||
@@ -274,9 +269,6 @@ 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,
|
||||||
|
|||||||
@@ -551,72 +551,3 @@ 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.
|
|
||||||
|
|||||||
+11
-46
@@ -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 a presence pattern.
|
comes from a presence override rather than the 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,54 +89,19 @@ yet defined** — pick 24–40 household-relevant glyphs and ship them as a grid
|
|||||||
|
|
||||||
## 4. Schedule (1f)
|
## 4. Schedule (1f)
|
||||||
|
|
||||||
A child has **zero or more** presence patterns (schema 9). Each pattern has a name, a
|
- Header `Schedule` / `2-week cycle · anchor <weekday>, <Mon D>`.
|
||||||
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`, **Sunday first** (`S M T W T F S`); day cells
|
10px/700 `--nq-a-ink-tertiary`; day cells `aspect-ratio: 1`, radius 6px —
|
||||||
`aspect-ratio: 1`, radius 6px — home days `--nq-a-selected` with `#201F7A` numerals,
|
rotation-home days `--nq-a-selected` with `#201F7A` numerals, away days
|
||||||
away days `--nq-a-page` with `--nq-a-ink-secondary`, override days
|
`--nq-a-page` with `--nq-a-ink-secondary`, override days `--nq-a-warning-bg` with
|
||||||
`--nq-a-warning-bg` with `--nq-a-warning-ink` and a 1px warning border, today filled
|
`--nq-a-warning-ink` and a 1px warning border, today filled with the brand gradient in
|
||||||
with the brand gradient in white 800. Legend row beneath, 10.5px/600, one swatch per
|
white 800. Legend row beneath, 10.5px/600, one swatch per state.
|
||||||
state (`Home`, `Away`, `Override · away`, `Override · home`, `Today`), then the note
|
- **Presence pattern** card: one row per child, name 13px/700 + rule 12px/600
|
||||||
"Combined from every pattern: away beats home; overrides beat both."
|
(`Weeks 1 & 3 of cycle`, `Every day`).
|
||||||
- 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 each pattern's own anchor date. **Never** from
|
- Cycle position is always computed from the anchor date. **Never** from ISO week numbers
|
||||||
ISO week numbers or week parity (D-004).
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,6 @@ 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:
|
||||||
@@ -53,17 +45,12 @@ 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
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ 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_patterns`, `presence_overrides`. (`presence_patterns` —
|
`schedule_rules`, `presence_schedules`, `presence_overrides`.
|
||||||
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.
|
||||||
@@ -45,9 +43,8 @@ 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 (per pattern, each with its own anchor; since schema 9 it combines a child's
|
arithmetic, daily materialiser over `horizon_days` at `day_rollover_time`. Only the
|
||||||
patterns and overrides first-match-wins), daily materialiser over `horizon_days` at
|
instance fan-out changes (assignees × windows).
|
||||||
`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
|
||||||
|
|||||||
@@ -61,44 +61,6 @@ 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
|
||||||
|
|||||||
@@ -514,10 +514,8 @@ async def test_regenerate_household_never_duplicates(
|
|||||||
client = admin_household.client
|
client = admin_household.client
|
||||||
database = admin_household.database
|
database = admin_household.database
|
||||||
|
|
||||||
# The seed's creates materialized the horizon BEFORE Ada's custody
|
before = await _instance_count(database)
|
||||||
# pattern existed, so the first household walk may legitimately
|
assert before > 0 # the seed already materialized a few days
|
||||||
# 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()
|
||||||
@@ -527,8 +525,9 @@ 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"] > 0
|
assert body["count"] >= before
|
||||||
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()
|
||||||
@@ -545,18 +544,17 @@ 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 already materializes
|
Creating a definition through the admin route materializes NOTHING
|
||||||
its horizon (the core's create path regenerates); the scoped
|
(the core's create path does not regenerate); the scoped regenerate
|
||||||
regenerate call rebuilds the same rows, and re-running it leaves
|
call is what materializes its instances, and re-running it leaves
|
||||||
the row count exactly where the create left it.
|
the row count exactly where the first run 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])
|
||||||
|
|
||||||
created_count = await _instance_count(database)
|
assert await _instance_count(database) == 0
|
||||||
assert created_count > 0
|
|
||||||
|
|
||||||
first = await client.post(
|
first = await client.post(
|
||||||
"/api/v1/admin/regenerate",
|
"/api/v1/admin/regenerate",
|
||||||
@@ -570,7 +568,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"] == created_count
|
assert after_first == body["count"]
|
||||||
|
|
||||||
second = await client.post(
|
second = await client.post(
|
||||||
"/api/v1/admin/regenerate",
|
"/api/v1/admin/regenerate",
|
||||||
|
|||||||
@@ -151,17 +151,12 @@ 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; ``end_date`` keeps it (and create's horizon
|
# past-due instance, then materialized for today only.
|
||||||
# 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")],
|
||||||
|
|||||||
@@ -149,18 +149,13 @@ async def _seed_household(
|
|||||||
(today + datetime.timedelta(days=3)).isoformat(),
|
(today + datetime.timedelta(days=3)).isoformat(),
|
||||||
today=today,
|
today=today,
|
||||||
)
|
)
|
||||||
# The stale quest's rule ends YESTERDAY, so create's own
|
# The stale quest is created AFTER the today walk, so its only
|
||||||
# regeneration writes nothing and its only instance is the
|
# instance is the yesterday one written below.
|
||||||
# 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")],
|
||||||
|
|||||||
@@ -102,20 +102,15 @@ 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 only (created after the yesterday walk so it
|
# A quest due TODAY (created after the yesterday walk so it has
|
||||||
# has no past-due instance; ``end_date`` keeps create's horizon
|
# no past-due instance): the post-downtime catch-up run's
|
||||||
# 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")],
|
||||||
|
|||||||
@@ -468,11 +468,7 @@ 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()
|
||||||
|
|||||||
@@ -168,42 +168,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user