Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 6e6e25f7d3 Fix the missing shield crest in the child's Quest Log header
Lit removes an attribute entirely when any interpolated part of it is
`nothing`, so `class="crest${away ? " away" : nothing}"` rendered a
present child's crest as a bare, unstyled span: an 11x51 sliver with a
16px black initial instead of the 96x110 brand-gradient shield. Use an
empty string for the present case, and widen the ring from 3px to the
spec's 4px.

Adds a jsdom test on the rendered crest classes (present and away) and
a stylesheet test pinning the PANEL-SPEC crest geometry, and rebuilds
the committed bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:27:05 -04:00
dev_agent c5b313a5e3 Fix hidden hint text behind the party-board dock (#244) 2026-09-26 08:09:51 -04:00
orchestrator-agent fb9907ae7f Merge pull request Reconcile dev with main after the 0.8.0 release 2026-09-25 14:53:18 -04:00
overseer dbf410ada3 Merge pull request 'Release: multi-pattern presence, Sunday-first week and admin fixes (0.8.0)' (#239) from release/2026-09-25-presence-patterns-and-start-date into main
Reviewed-on: #239
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-25 12:52:31 -04:00
overseer 2906da7ed1 release: clarify the 0.8.0 scope wording 2026-09-25 07:42:04 -04:00
overseer 4f609d7bb9 release: advance version to 0.8.0 and add the 0.8.0 release notes 2026-09-25 07:39:18 -04:00
dev_agent 39e0d715fc Feature: definition sheet start-date field (#238)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:38:14 -04:00
dev_agent d248b9cb32 Fix: create quest definition materializes its instances (#236)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:26:33 -04:00
dev_agent 64a56389ad Feature docs: multi-pattern presence, Sunday-first week and the migration-9 rollback (#234)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 20:41:06 -04:00
dev_agent c313e1c56f Feature: multiple presence patterns per child (schema 9) (#232)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 20:15:27 -04:00
22 changed files with 506 additions and 44 deletions
+36
View File
@@ -1,5 +1,41 @@
# NestQuest Release Notes
## Version 0.8.0 — 2026-09-25
### Scope
Minor release carrying the multi-pattern presence model (schema 9), the
Sunday-first calendar week, and the create-materializes fix, plus the
definition start-date field, onto `main`.
- **Multiple presence patterns per child (schema 9):** a child now has zero or
more presence patterns instead of at most one. Each pattern has a name, a kind
(`home` or `away`), its own cycle length (1–4 weeks), its own anchor date and a
per-week weekday set. Evaluation is first-match-wins — overrides, then away,
then home, then absent when the child has at least one home pattern, else
present. Migration 9 copies each existing schedule into a `home` pattern named
"Home schedule" and drops the old `presence_schedules` table. (PRs #231, #232.)
- **Sunday-first calendar week:** the admin Schedule month grid and the
Definitions weekday picker now render weeks Sunday-first; the stored/API
weekday numbering is unchanged (Monday=0..Sunday=6). (PR #230.)
- **Fix — newly created tasks materialize immediately:** creating a quest
definition now regenerates its future instances over the rolling horizon, so a
new task reaches the panel without waiting for an unrelated write. (PRs #235,
#236.)
- **Definition start date:** the definition edit sheet gains a Start date field
so a task can be scheduled to begin on a future date. (PRs #237, #238.)
- **Docs:** the multi-pattern decision (D-016), the migration-9 rollback step in
the runbook, and the ADMIN-SPEC Schedule section. (PRs #233, #234.)
### Upgrade note
This release runs schema version **9**. Version 0.7.1 refuses a version-9
database, so take a copy of the SQLite file (or note the Litestream generation)
before the first start on 0.8.0 if you need a rollback path.
### Requirements
## Version 0.7.1 — 2026-09-24
### Scope
@@ -1118,3 +1118,95 @@ describe("DefinitionsTab icon picker", () => {
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 {
const interval = parseWhole(draft.interval, 1, 999);
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 = {
rule_type: "daily",
interval,
@@ -740,6 +741,16 @@ function EditSheet({
<OccurrencePreview ruleKey={previewKey} />
</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">
{orderedWindows.length === 0 ? (
<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
snapshot. Raises ValueError on any rejected argument; a rejected or
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)
assert name is not None
@@ -269,6 +274,9 @@ async def create_quest_definition(
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
await regenerate_for_definition(database, snapshot.definition.id)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.7.1"
"version": "0.8.0"
}
@@ -906,11 +906,12 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", $e = "polygon(50%
.hint {
position: absolute;
top: 930px;
top: 896px;
left: 0;
right: 0;
margin: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-size: 27px;
font-weight: 600;
line-height: 1.4;
text-align: center;
@@ -1764,7 +1765,7 @@ const Nt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ye = "polygon(50%
flex: none;
width: 96px;
height: 110px;
padding: 3px;
padding: 4px;
clip-path: ${v(Nt)};
background: rgba(255, 255, 255, 0.22);
}
@@ -3090,7 +3091,7 @@ class un extends A {
const e = this._childSlug(), n = t.kind === "away", i = n ? t.name : this._childName(e) ?? (e ? Y(e) : null), { due: r, completed: o } = this._childCounts(e), l = this._remaining(), a = this._partyCounts(), p = i ? `${i}'s Quest Log` : "Quest Log", u = (i?.charAt(0) || "?").toUpperCase();
return d`
<header class="header">
<span class="crest${n ? " away" : g}" aria-hidden="true">
<span class="crest${n ? " away" : ""}" aria-hidden="true">
<span class="crest-face">
<span class="initial">${u}</span>
</span>
+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
+3 -2
View File
@@ -497,11 +497,12 @@ const boardStyles = css`
.hint {
position: absolute;
top: 930px;
top: 896px;
left: 0;
right: 0;
margin: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-size: 27px;
font-weight: 600;
line-height: 1.4;
text-align: center;
+2 -2
View File
@@ -378,7 +378,7 @@ const logStyles = css`
flex: none;
width: 96px;
height: 110px;
padding: 3px;
padding: 4px;
clip-path: ${unsafeCSS(SHIELD_CLIP)};
background: rgba(255, 255, 255, 0.22);
}
@@ -1938,7 +1938,7 @@ export class NestQuestQuestLogCard extends LitElement {
const initial = (name?.charAt(0) || "?").toUpperCase();
return html`
<header class="header">
<span class="crest${away ? " away" : nothing}" aria-hidden="true">
<span class="crest${away ? " away" : ""}" aria-hidden="true">
<span class="crest-face">
<span class="initial">${initial}</span>
</span>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.7.1"
version = "0.8.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+12 -10
View File
@@ -514,8 +514,10 @@ async def test_regenerate_household_never_duplicates(
client = admin_household.client
database = admin_household.database
before = await _instance_count(database)
assert before > 0 # the seed already materialized a few days
# The seed's creates materialized the horizon BEFORE Ada's custody
# 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(
"/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["definition_id"] is None
assert body["child_id"] is None
assert body["count"] >= before
assert body["count"] > 0
after_first = await _instance_count(database)
assert after_first >= before
second = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -544,17 +545,18 @@ async def test_regenerate_definition_scope_rematerializes(
) -> None:
"""The definition scope rebuilds that definition's horizon, twice, stably.
Creating a definition through the admin route materializes NOTHING
(the core's create path does not regenerate); the scoped regenerate
call is what materializes its instances, and re-running it leaves
the row count exactly where the first run left it.
Creating a definition through the admin route already materializes
its horizon (the core's create path regenerates); the scoped
regenerate call rebuilds the same rows, and re-running it leaves
the row count exactly where the create left it.
"""
client = admin_api.client
database = admin_api.database
ada = await _create_child(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(
"/api/v1/admin/regenerate",
@@ -568,7 +570,7 @@ async def test_regenerate_definition_scope_rematerializes(
assert body["child_id"] is None
assert body["count"] > 0
after_first = await _instance_count(database)
assert after_first == body["count"]
assert after_first == body["count"] == created_count
second = await client.post(
"/api/v1/admin/regenerate",
+7 -2
View File
@@ -151,12 +151,17 @@ async def _seed_missed_household(
database, yesterday_iso, yesterday_iso, today=yesterday
)
# 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(
database,
"Recoverable chore",
_recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
{
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
),
[ada.id],
[("afternoon", "17:00")],
+8 -3
View File
@@ -149,13 +149,18 @@ async def _seed_household(
(today + datetime.timedelta(days=3)).isoformat(),
today=today,
)
# The stale quest is created AFTER the today walk, so its only
# instance is the yesterday one written below.
# The stale quest's rule ends YESTERDAY, so create's own
# regeneration writes nothing and its only instance is the
# yesterday one written below.
await _quest_definitions.create_quest_definition(
database,
"Stale chore",
_recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": yesterday_iso}
{
"rule_type": "daily",
"start_date": yesterday_iso,
"end_date": yesterday_iso,
}
),
[ada.id],
[("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")]
)
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
# A quest due TODAY (created after the yesterday walk so it has
# no past-due instance): the post-downtime catch-up run's
# A quest due TODAY only (created after the yesterday walk so it
# 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
# inside the day-one sweep's window.
await create_quest_definition(
database,
"Recoverable chore",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
{
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
),
[ada.id],
[("afternoon", "17:00")],
+4
View File
@@ -468,7 +468,11 @@ async def _materialize_with_change(
proceeds with the snapped presence). This deterministically
exercises the snapshot-to-insert gap that a config edit can
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
entered = asyncio.Event()
release = asyncio.Event()
+98
View File
@@ -411,6 +411,36 @@ def test_panel_hard_rules_hold_in_the_card_sources() -> None:
assert "@click" not in sealed_template
def test_party_board_hint_clears_the_dock() -> None:
"""PANEL-SPEC §2/§2.3: the hint line reads fully above the dock at
1920x1080. The hint's line box (top + font-size x line-height) must
end above the dock's top edge (1080 - bottom inset - dock height)
with a visible gap, at the 27px/600 design size. The hint is a <p>,
so its user-agent 1em top margin must be reset or it shifts the box
down by a whole font-size."""
style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
rules = {selector.strip(): body for selector, body in _css_rules(style)}
sizes = _size_tokens(_read(REWARD_TOKENS))
def px(body: str, prop: str) -> float:
declared = re.search(rf"(?<![-\w]){prop}:\s*([^;]+);", body)
assert declared is not None, prop
value = _font_size_px(declared.group(1), sizes)
assert value is not None, f"{prop}: {declared.group(1)}"
return value
board, hint, dock = rules[".board"], rules[".hint"], rules[".dock"]
assert "margin: 0;" in hint
line_height = float(re.search(r"line-height:\s*([\d.]+);", hint).group(1))
hint_bottom = px(hint, "top") + px(hint, "font-size") * line_height
dock_top = px(board, "height") - px(dock, "bottom") - px(dock, "height")
assert px(dock, "height") == 84
assert px(dock, "bottom") == 46
assert hint_bottom + 12 <= dock_top, (hint_bottom, dock_top)
assert px(hint, "font-size") == 27
assert "font-weight: 600;" in hint
def test_panel_text_sizes_respect_the_22px_floor() -> None:
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
panel styles resolves against the panel size tokens or an explicit
@@ -886,3 +916,71 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
# …and at zero the card has returned to the party board.
assert result["location"] == "/nestquest"
def test_quest_log_crest_keeps_its_shield_classes() -> None:
"""PANEL-SPEC §3 header crest: a present child's crest renders as the
styled shield, not a bare span. Lit removes an attribute outright when
any interpolated part of it is ``nothing``, so a class written as
``crest${away ? " away" : nothing}`` lost ``crest`` for every present
child and the header showed an unstyled sliver. The rendered markup
must carry ``class="crest"`` (present) and ``class="crest away"``
(away), each wrapping the face and initial."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = generated["views"][1]["cards"][0]
for slug, crest_class in (("ada", "crest"), ("bo", "crest away")):
result = _render_quest_log(
log_config,
_three_child_states(),
url=f"http://homeassistant.local/nestquest/{slug}",
)
header = result["html"][result["html"].index('<header class="header">') :]
crest = re.match(
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*<span ([^>]*)>\s*'
r'<span class="crest-face">\s*<span class="initial">',
header,
)
assert crest is not None, header[:600]
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
def test_quest_log_crest_matches_the_spec_geometry() -> None:
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
initial in full-opacity white; away swaps only the face gradient.
jsdom cannot lay out, so this pins the stylesheet the rendered test
above shows is applied."""
source = QUEST_LOG.read_text(encoding="utf-8")
def rule(selector: str) -> str:
found = re.search(
r"\n " + re.escape(selector) + r" \{(.*?)\n \}", source, re.DOTALL
)
assert found is not None, selector
return found.group(1)
crest = rule(".crest")
for decl in (
"width: 96px;",
"height: 110px;",
"padding: 4px;",
"clip-path: ${unsafeCSS(SHIELD_CLIP)};",
"background: rgba(255, 255, 255, 0.22);",
):
assert decl in crest, decl
face = rule(".crest-face")
assert "background: var(--nq-p-crest);" in face
assert "clip-path: ${unsafeCSS(SHIELD_CLIP)};" in face
initial = rule(".crest .initial")
for decl in (
"font-family: var(--nq-p-font-display);",
"font-size: 44px;",
"font-weight: 700;",
"color: #ffffff;",
):
assert decl in initial, decl
assert "opacity" not in initial
away = rule(".crest.away .crest-face")
assert away.strip() == "background: var(--nq-p-crest-away);"
+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)
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
# ---------------------------------------------------------------------------
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.7.1"
version = "0.8.0"
source = { virtual = "." }
[package.dev-dependencies]