Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 235c327608 Keep the quest-log meta line's class when a quest is not late
The open quest card built its meta line as
class="meta ${meta.late ? "late" : nothing}"; Lit drops the whole
attribute when any interpolated part is nothing, so every on-time quest
lost the meta class and its styling. Choose the full class string
instead, rebuild the panel bundle, and add a rendered regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:39:04 -04:00
dev_agent 744d80e645 Quest bugs: household-local overdue clock and the panel Complete no-op (#254) 2026-09-26 09:37:01 -04:00
dev_agent 8f38946c04 Update NestQuest logo on the party board (new monogram wordmark) (#253) 2026-09-26 09:18:38 -04:00
dev_agent 9916d83138 Fix the Quest Log header crest/shield to match the design (resubmission of PR #247) (#252) 2026-09-26 09:08:59 -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
28 changed files with 1357 additions and 430 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>
+41 -13
View File
@@ -867,7 +867,7 @@ class AdminSettingsResponse(BaseModel):
"""The effective settings as the admin plane serializes them.
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
— all TEN documented fields, resolved (every absent stored field
— all ELEVEN documented fields, resolved (every absent stored field
already fell back to its default in the core). The same shape both
the GET and the PATCH route answer with.
"""
@@ -882,6 +882,9 @@ class AdminSettingsResponse(BaseModel):
afternoon_reminder_enabled: bool
end_of_day_report_enabled: bool
celebration_enabled: bool
#: The household IANA time zone the API reads its clock in; ``""``
#: means the API host's local time.
timezone: str
class AdminMissedSweepResponse(BaseModel):
@@ -1178,6 +1181,26 @@ def _local_now() -> datetime.datetime:
return datetime.datetime.now().astimezone()
async def _household_clock(
database: object,
) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
zone keeps the host's local time.
"""
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
async def _household_today(database: object) -> datetime.date:
"""Return the household-local date of ONE :func:`_household_clock` read."""
_settings, now = await _household_clock(database)
return now.date()
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
"""Build the core ScheduleRule from the request's ``rule`` object.
@@ -1496,7 +1519,7 @@ async def admin_create_quest_definition(
response_model=AdminOccurrencesPreviewResponse,
)
async def admin_preview_occurrences(
body: AdminOccurrencesPreviewRequest,
body: AdminOccurrencesPreviewRequest, request: Request
) -> AdminOccurrencesPreviewResponse:
"""Return the first ``count`` dates the supplied rule fires on.
@@ -1509,12 +1532,15 @@ async def admin_preview_occurrences(
call over the bounded window ``[start_date, start_date + 366
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
read — truncated to ``count``. Every core ``ValueError`` is 422
(:func:`_raise_preview_error`). No database access.
(:func:`_raise_preview_error`). The only database access is the
settings read behind the household clock.
"""
start_date = (
body.start_date
if body.start_date is not None
else _local_now().date().isoformat()
else (
await _household_today(request.app.state.db.database)
).isoformat()
)
try:
rule = _rule_from_request(body.rule)
@@ -1839,7 +1865,7 @@ async def admin_presence_override_consequence(
body.start_date,
body.end_date,
body.is_present,
today=_local_now().date(),
today=await _household_today(state.database),
)
)
except ValueError as error:
@@ -1926,7 +1952,7 @@ async def admin_uncomplete_instance(
"""
state: DatabaseState = request.app.state.db
database = state.database
now = _local_now()
_settings, now = await _household_clock(database)
sub = claims.get("sub")
try:
result = await core_completion.uncomplete_instance(
@@ -1992,7 +2018,7 @@ async def admin_regenerate(
"""
state: DatabaseState = request.app.state.db
database = state.database
today = _local_now().date()
today = await _household_today(state.database)
if body.scope == "household":
end_date = today + datetime.timedelta(
@@ -2077,7 +2103,7 @@ def _settings_response(
"""Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the ten documented fields even if the core
stays pinned to the eleven documented fields even if the core
dataclass later grows another one.
"""
return AdminSettingsResponse(
@@ -2091,6 +2117,7 @@ def _settings_response(
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled,
timezone=settings.timezone,
)
@@ -2122,7 +2149,7 @@ async def admin_history_query(
``ValueError`` is 422 (:func:`_raise_history_error`).
"""
state: DatabaseState = request.app.state.db
today = _local_now().date()
today = await _household_today(state.database)
try:
rows = await core_history.query_history(
state.database, filter, start, end, today=today
@@ -2165,7 +2192,7 @@ async def admin_history_csv(
JSON route's (:func:`_raise_history_error`).
"""
state: DatabaseState = request.app.state.db
today = _local_now().date()
today = await _household_today(state.database)
try:
csv_text = await core_history.export_history_csv(
state.database, filter, start, end, today=today
@@ -2188,7 +2215,7 @@ async def admin_history_csv(
response_model=AdminSettingsResponse,
)
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
"""Return the current effective settings (all ten documented fields).
"""Return the current effective settings (all eleven documented fields).
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
@@ -2280,7 +2307,7 @@ async def admin_run_missed_sweep(
"""
state: DatabaseState = request.app.state.db
database = state.database
today = _local_now().date()
today = await _household_today(state.database)
try:
events = await core_sweep.run_missed_sweep(database, today=today)
except ValueError as error:
@@ -2309,8 +2336,9 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
state.database, settings, now
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
+21 -6
View File
@@ -8,9 +8,9 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the API host's local time — the
API is the household's "local" host now that the domain no longer lives
inside Home Assistant), calls the pure
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
stored household ``timezone`` setting — the API host may run UTC while
the household does not), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -63,7 +63,7 @@ from api.dependencies import require_panel_token
from api.nestquest_core import (
core_completion,
core_events,
core_settings,
core_settings_store,
core_snapshot,
)
from api import transitions as api_transitions
@@ -178,6 +178,20 @@ def _local_now() -> datetime.datetime:
return datetime.datetime.now().astimezone()
async def _household_clock(
database: object,
) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
zone keeps the host's local time.
"""
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
@router.get(
"/snapshot",
summary="Today's household snapshot",
@@ -196,8 +210,9 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
state.database, settings, now
)
return PanelSnapshotResponse(
today_iso=snapshot.today_iso,
@@ -282,7 +297,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child",
)
now = _local_now()
_settings, now = await _household_clock(database)
try:
result = await core_completion.complete_instance(
database,
+10 -3
View File
@@ -8,7 +8,8 @@ configured ``day_rollover_time`` — read fresh from the settings store
at the top of EVERY cycle, so an admin's settings change applies on
the next scheduling decision without a restart — then runs the
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
API host's local date and publishes each returned
household-local date (the stored ``timezone`` setting; the API host's
local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
@@ -130,7 +131,11 @@ class MissedSweepScheduler:
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
continue
delay = _delay_seconds(rollover, self._clock())
# The host may run UTC: the rollover is a household wall-clock
# time, so the clock read is re-expressed in the stored zone.
delay = _delay_seconds(
rollover, settings.household_now(self._clock())
)
LOGGER.info(
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
settings.day_rollover_time,
@@ -138,7 +143,9 @@ class MissedSweepScheduler:
)
await self._sleep(delay)
try:
await self._run_once(self._clock().date())
await self._run_once(
settings.household_now(self._clock()).date()
)
except Exception:
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
@@ -112,6 +112,13 @@ CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string keeps the historical
# behaviour — the API host's own local time — so an existing install is
# unchanged until the owner sets it. The integration itself always uses
# Home Assistant's configured ``time_zone`` and ignores this field.
CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = ""
# Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database.
@@ -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,
@@ -35,6 +35,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Mapping
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .const import (
CONF_AFTERNOON_REMINDER_ENABLED,
@@ -47,12 +48,14 @@ from .const import (
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_TIMEZONE,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_TIMEZONE,
LOGGER,
TIME_PATTERN,
)
@@ -153,6 +156,29 @@ def _resolve_optional_str(value: Any) -> str:
return value.strip()
def _resolve_timezone(value: Any) -> str:
"""Validate a household time zone and return it.
The empty string means "the API host's local time" (the historical
behaviour); anything else must be an IANA key :class:`ZoneInfo`
resolves (e.g. ``America/New_York``). A non-str (including
``None``) is rejected; the ``None``-as-absent mapping lives ONLY in
:meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
"""
if not isinstance(value, str):
raise ValueError(f"timezone must be a string, got {value!r}")
if value == "":
return value
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as error:
raise ValueError(
f"timezone must be an IANA time zone name such as "
f"'America/New_York', got {value!r}"
) from error
return value
#: The single field table the two builders share. Each row is
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
#: applies every validator (raising on the first invalid value) while
@@ -214,6 +240,7 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
)
@@ -243,6 +270,7 @@ class NestQuestSettings:
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
timezone: str = DEFAULT_TIMEZONE
def __post_init__(self) -> None:
# Validate STRICTLY at construction so a hand-built instance
@@ -281,6 +309,21 @@ class NestQuestSettings:
raise ValueError(
f"{name} must be a boolean, got {value!r}"
)
_resolve_timezone(self.timezone)
def household_now(self, instant: datetime.datetime) -> datetime.datetime:
"""Return the aware ``instant`` expressed in the household's zone.
The API service has no Home Assistant ``time_zone``, so its one
clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set the SAME instant is re-expressed in that
zone, so ``.date()`` and ``.time()`` are the household's wall
clock even when the host runs UTC. With :attr:`timezone` empty
the instant is returned unchanged (the host's local time).
"""
if not self.timezone:
return instant
return instant.astimezone(ZoneInfo(self.timezone))
@property
def day_rollover_hour_minute(self) -> tuple[int, int]:
@@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither
side reads the other's values.
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
object holding exactly the TEN settings fields, keyed by field name
object holding exactly the ELEVEN settings fields, keyed by field name
(which equals the ``CONF_*`` option key for every field), complete
after the first update:
@@ -30,7 +30,10 @@ after the first update:
core's normalizer),
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
``end_of_day_report_enabled``, ``celebration_enabled`` — real
booleans.
booleans,
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
zone name such as ``America/New_York``. A document stored before
this field existed simply lacks it and resolves to ``""``.
Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders.
@@ -172,7 +175,7 @@ async def update_settings(
update can never leave a half-applied document behind.
The validated changes are merged onto the current effective
settings and the merged (complete, ten-field) document is persisted
settings and the merged (complete, eleven-field) document is persisted
in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving.
+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"
}
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -22,10 +22,14 @@ Screens `1a`, `1b`, `1j`, `1c` of the mockups. Two cards:
Default view on the wall. Layout top to bottom:
1. **Wordmark row** at y=64. Rule · d20 hexagon · `NestQuest` (66px/900 display, brand
gradient via `background-clip: text`) · d20 · rule. d20 marks are 34px hexagons
(`clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)`) filled with
the brand gradient, numeral 15px/700 Cinzel.
1. **Wordmark row** at y=64. Rule · monogram · `NestQuest` (66px/900 Cinzel Decorative
display, brand gradient via `background-clip: text`) · rule — the horizontal lockup of
`design/logos/NestQuest_Logo.png`. The monogram is an 80px inline SVG (no raster, no
remote asset): a compass rose — four long cardinal points, four short diagonal points,
a ring broken by the points — around a d20 hexagon with facet lines and a knocked-out
Cinzel Decorative "N", filled with the brand gradient (`--nq-brand-blue` →
`--nq-brand-purple`, 135°). Gaps are SVG masks, so the mark stays transparent over the
parchment.
2. **Kicker** `The Party · <weekday>, <Month D>` — 21px/600 Cinzel, tracking .30em, uppercase.
3. **Adventurer plates**, 3-up grid, 56px gap, page inset 110px, top y=262. One plate per
child returned by the party sensor, in configured order.
+78 -26
View File
@@ -231,8 +231,76 @@ function conditionPhrase(condition: string, label: string): string {
}
const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)";
const HEXAGON_CLIP =
"polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
// The brand monogram (design/logos/NestQuest_Logo.png): a compass rose —
// four long cardinal points, four short diagonal points, a ring broken by
// the points — around a d20 hexagon carrying a knocked-out "N". Drawn on
// a 100-unit grid and filled with the brand gradient; the masks cut the
// parchment gaps so the mark stays transparent on any backdrop.
const MONOGRAM_POINTS =
"M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z " +
"M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z " +
"M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z " +
"M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z";
const MONOGRAM_HEX = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z";
const MONOGRAM_FACETS =
"M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2" +
"M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8" +
"M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8" +
"M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8";
const MONOGRAM = html`<svg
class="monogram"
viewBox="0 0 100 100"
aria-hidden="true"
focusable="false"
>
<defs>
<linearGradient
id="nq-monogram-fill"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="100"
y2="100"
>
<stop offset="0" style="stop-color: var(--nq-brand-blue)" />
<stop offset="1" style="stop-color: var(--nq-brand-purple)" />
</linearGradient>
<mask id="nq-monogram-points-gap" maskUnits="userSpaceOnUse">
<rect width="100" height="100" fill="#fff" />
<path d=${MONOGRAM_POINTS} stroke="#000" stroke-width="3.5" />
</mask>
<mask id="nq-monogram-facets" maskUnits="userSpaceOnUse">
<rect width="100" height="100" fill="#fff" />
<path d=${MONOGRAM_FACETS} stroke="#000" stroke-width="1.6" />
<text
x="50"
y="59.4"
text-anchor="middle"
font-family="'Cinzel Decorative', Cinzel, serif"
font-weight="900"
font-size="25"
fill="#fff"
stroke="#000"
stroke-width="2"
paint-order="stroke"
>N</text>
</mask>
</defs>
<g fill="url(#nq-monogram-fill)">
<path d=${MONOGRAM_POINTS} />
<g mask="url(#nq-monogram-points-gap)">
<circle
cx="50"
cy="50"
r="34.25"
fill="none"
stroke="url(#nq-monogram-fill)"
stroke-width="6.5"
/>
<path mask="url(#nq-monogram-facets)" d=${MONOGRAM_HEX} />
</g>
</g>
</svg>`;
const boardStyles = css`
* {
@@ -299,23 +367,11 @@ const boardStyles = css`
background: rgba(92, 62, 26, 0.35);
}
.d20 {
.monogram {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
clip-path: ${unsafeCSS(HEXAGON_CLIP)};
background: var(--nq-brand-gradient);
}
.d20-numeral {
font-family: var(--nq-p-font-heading);
font-size: 15px;
font-weight: 700;
line-height: 1;
color: #ffffff;
display: block;
width: 80px;
height: 80px;
}
.wordmark {
@@ -497,11 +553,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;
@@ -861,13 +918,8 @@ export class NestQuestPartyBoardCard extends LitElement {
<div class="frame frame-inner"></div>
<div class="wordmark-row">
<span class="rule"></span>
<span class="d20" aria-hidden="true">
<span class="d20-numeral">20</span>
</span>
${MONOGRAM}
<span class="wordmark">NestQuest</span>
<span class="d20" aria-hidden="true">
<span class="d20-numeral">20</span>
</span>
<span class="rule"></span>
</div>
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
+3 -3
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>
@@ -2096,7 +2096,7 @@ export class NestQuestQuestLogCard extends LitElement {
<span class="tile" aria-hidden="true">${ICON_STAR}</span>
<div class="body">
<span class="quest-title">${instance.title}</span>
<span class="meta ${meta.late ? "late" : nothing}">${meta.text}</span>
<span class="${meta.late ? "meta late" : "meta"}">${meta.text}</span>
</div>
<button
class="complete"
+28 -3
View File
@@ -10,7 +10,10 @@
* receives one JSON object {location, headline, countdown, html, probe} —
* the final window pathname, the rendered title, the complete-screen
* countdown text, the rendered shadow DOM, and the probe snapshot when
* probe_ms was given. The Python panel tests drive this harness so the
* probe_ms was given. With tap_complete: <instance id> the harness taps that
* card's Complete button, records whether the confirm dialog opened, taps
* the dialog's Complete, and reports every hass.callService call as
* {dialog_opened, service_calls}. The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
@@ -43,12 +46,34 @@ globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const serviceCalls = [];
const hass = {
...spec.hass,
callService(domain, service, data) {
serviceCalls.push({ domain, service, data });
return Promise.resolve();
},
};
const element = document.createElement("nestquest-quest-log-card");
element.setConfig(spec.config);
element.hass = spec.hass;
element.hass = hass;
document.body.appendChild(element);
await element.updateComplete;
let tap = null;
if (spec.tap_complete !== undefined) {
element.shadowRoot
.querySelector(`.quest[data-instance-id="${spec.tap_complete}"] button.complete`)
.click();
await element.updateComplete;
const confirm = element.shadowRoot.querySelector("button.do-complete");
confirm?.click();
await element.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 0));
tap = { dialog_opened: confirm !== null, service_calls: serviceCalls };
}
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
@@ -68,6 +93,6 @@ if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const payload = JSON.stringify({ ...snapshot(), probe });
const payload = JSON.stringify({ ...snapshot(), probe, tap });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+1
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false,
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
+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"
+5 -4
View File
@@ -13,7 +13,7 @@ proven against the DATABASE the routes wrote, not a parallel copy.
Proven per the done-condition, everything read back through the API:
- ``GET /api/v1/admin/settings`` returns the current effective settings
— the ten documented fields at their defaults on a fresh database.
— the eleven documented fields at their defaults on a fresh database.
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
NEXT read reflects them; a later partial update merges onto the
current effective settings (omitted fields keep their stored value),
@@ -60,7 +60,7 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
SETTINGS_META_KEY = "settings"
#: The all-defaults effective settings a fresh database reports — the
#: documented ten fields, asserted literally so the payload contract is
#: documented eleven fields, asserted literally so the payload contract is
#: pinned here, independent of the core constants.
DEFAULT_SETTINGS = {
"horizon_days": 14,
@@ -73,6 +73,7 @@ DEFAULT_SETTINGS = {
"afternoon_reminder_enabled": True,
"end_of_day_report_enabled": True,
"celebration_enabled": True,
"timezone": "",
}
@@ -103,7 +104,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
async def test_get_settings_returns_defaults_on_a_fresh_database(
settings_client: SimpleNamespace,
) -> None:
"""GET returns the ten documented fields at their defaults."""
"""GET returns the eleven documented fields at their defaults."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
@@ -139,7 +140,7 @@ async def test_update_persists_and_the_next_read_reflects_it(
assert reread.status_code == 200
assert reread.json() == expected
# ...and the DATABASE holds the merged document: the complete ten
# ...and the DATABASE holds the merged document: the complete eleven
# fields, keyed by field name, under the well-known meta_state key.
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
+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",
+272
View File
@@ -0,0 +1,272 @@
"""Household-local clock tests for the API service (task fa7475ba).
The deployed API container runs UTC while the household is US Eastern,
so reading the HOST clock marked every morning quest overdue from the
small hours. The API now re-expresses its ONE host clock read in the
stored ``timezone`` setting (``NestQuestSettings.household_now``).
Every test pins the host clock to a UTC instant (the deployed
container's zone) and sets the household zone through the real admin
``PATCH /api/v1/admin/settings`` route, then reads BOTH snapshot shapes:
- one second before the 10:00 due time: NOT overdue (previously
flagged, because 13:59:59 UTC > 10:00);
- exactly at 10:00:00: NOT overdue (the builder's strict
``now.time() > due`` convention);
- one second after: overdue;
- 23:30 household time, already the next day in UTC: ``today_iso`` is
still the household's day.
An unset zone keeps the historical host-local behaviour, and the
missed-sweep scheduler waits for the rollover in household time.
"""
from __future__ import annotations
import asyncio
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api.database import _executor
from api.scheduler import MissedSweepScheduler
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
_settings = importlib.import_module("nestquest_core.settings")
_settings_store = importlib.import_module("nestquest_core.settings_store")
_core_db = importlib.import_module("nestquest_core.db")
_core_migrations = importlib.import_module("nestquest_core.migrations")
#: The panel service token the admin harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
UTC = ZoneInfo("UTC")
HOUSEHOLD_ZONE = "America/New_York"
#: A fixed household day on Eastern Daylight Time (UTC-4).
TODAY = datetime.date(2026, 9, 26)
def _utc(hour: int, minute: int, second: int = 0, *, day: int = 26):
"""An aware UTC instant on September ``day`` 2026."""
return datetime.datetime(2026, 9, day, hour, minute, second, tzinfo=UTC)
def _admin_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""The seeded household on the admin-plane app, host clock pinnable.
Ada's open "Pack bag" quest is due 10:00 on :data:`TODAY`. Both
routes' host clock read returns ``clock["now"]`` (a UTC instant).
"""
seed = await _seed_household(temp_db_path, TODAY, _utc(16, 0))
clock = {"now": _utc(12, 0)}
monkeypatch.setattr(routes_admin, "_local_now", lambda: clock["now"])
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(client=client, seed=seed, clock=clock)
async def _set_zone(household: SimpleNamespace, zone: str) -> None:
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["timezone"] == zone
async def _pack_bag(household: SimpleNamespace, plane: str) -> tuple[str, dict]:
"""Return ``(today_iso, Ada's Pack bag instance)`` from one snapshot."""
headers = _admin_headers() if plane == "admin" else _panel_headers()
response = await household.client.get(
f"/api/v1/{plane}/snapshot", headers=headers
)
assert response.status_code == 200
body = response.json()
ada = next(
child
for child in body["children"]
if child["child_id"] == household.seed.ada.id
)
(instance,) = ada["instances"]
assert instance["title"] == "Pack bag"
assert instance["due_time"] == "10:00"
return body["today_iso"], instance
@pytest.mark.parametrize("plane", ["panel", "admin"])
@pytest.mark.parametrize(
("host_now", "overdue"),
[
# 09:59:59 EDT — the pre-fix code compared 13:59:59 (UTC) > 10:00.
(_utc(13, 59, 59), False),
# 10:00:00 EDT exactly — strict ``>``: not yet overdue.
(_utc(14, 0, 0), False),
# 10:00:01 EDT — overdue.
(_utc(14, 0, 1), True),
],
ids=["before-due", "at-due", "after-due"],
)
async def test_overdue_boundary_uses_household_zone(
household: SimpleNamespace,
plane: str,
host_now: datetime.datetime,
overdue: bool,
) -> None:
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = host_now
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["state"] == "open"
assert instance["overdue"] is overdue
@pytest.mark.parametrize("plane", ["panel", "admin"])
async def test_household_day_survives_utc_midnight(
household: SimpleNamespace, plane: str
) -> None:
"""23:30 EDT is already tomorrow in UTC; the household day is today."""
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = _utc(3, 30, day=27)
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["overdue"] is True
@pytest.mark.parametrize("plane", ["panel", "admin"])
async def test_unset_zone_keeps_host_local_time(
household: SimpleNamespace, plane: str
) -> None:
"""Backward compatible: no stored zone reads the host clock as-is."""
household.clock["now"] = _utc(13, 59, 59)
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["overdue"] is True
async def test_panel_completion_on_time_uses_household_zone(
household: SimpleNamespace,
) -> None:
"""A 09:30 EDT tap on a 10:00 quest is on time (13:30 UTC is not)."""
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = _utc(13, 30)
seed = household.seed
response = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=_panel_headers(),
json={"actor_child_id": seed.ada.id},
)
assert response.status_code == 200
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["state"] == "completed"
assert instance["on_time"] is True
async def test_invalid_zone_is_rejected(household: SimpleNamespace) -> None:
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": "Mars/Olympus_Mons"},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone" in str(response.json()["detail"])
reread = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json()["timezone"] == ""
# --- the settings object ---------------------------------------------------
def test_settings_timezone_defaults_to_host_local() -> None:
settings = _settings.NestQuestSettings()
assert settings.timezone == ""
instant = _utc(13, 0)
assert settings.household_now(instant) is instant
def test_settings_household_now_converts_the_same_instant() -> None:
settings = _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
local = settings.household_now(_utc(13, 0))
assert local == _utc(13, 0)
assert (local.date(), local.time()) == (TODAY, datetime.time(9, 0))
@pytest.mark.parametrize("value", ["Mars/Olympus_Mons", "../etc/passwd", 5, None])
def test_settings_rejects_invalid_timezone(value: object) -> None:
with pytest.raises(ValueError, match="timezone"):
_settings.NestQuestSettings(timezone=value)
def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
assert (
_settings.NestQuestSettings.from_options(
{"timezone": HOUSEHOLD_ZONE}
).timezone
== HOUSEHOLD_ZONE
)
assert _settings.NestQuestSettings.from_options({}).timezone == ""
assert (
_settings.NestQuestSettings.from_options_resilient(
{"timezone": "Nowhere/Land", "horizon_days": 9}
).timezone
== ""
)
# --- the missed-sweep scheduler ----------------------------------------------
async def test_scheduler_waits_for_rollover_in_household_time(
temp_db_path: str,
) -> None:
"""09:00 EDT (13:00 UTC) to a 10:05 rollover is 65 minutes away."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
await _settings_store.update_settings(
database,
{"day_rollover_time": "10:05", "timezone": HOUSEHOLD_ZONE},
)
delays: list[float] = []
first_sleep = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
first_sleep.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(first_sleep.wait(), timeout=5)
finally:
await scheduler.stop()
assert delays == [pytest.approx(65 * 60)]
finally:
await database.close()
+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()
+254
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
@@ -570,6 +600,40 @@ def _render_party_board(
return json.loads(completed.stdout)
def test_party_board_wordmark_row_is_the_monogram_lockup() -> None:
"""PANEL-SPEC §2 item 1: the wordmark row is rule · compass-rose
monogram · NestQuest · rule. The monogram is an inline SVG filled
with the brand gradient — the old d20 hexagon pair and its "20"
numerals are gone, and nothing is fetched from a remote URL or
embedded as a raster logo."""
source = _read(PARTY_BOARD)
assert "d20" not in _style_literal(source, STYLE_CONSTANTS[PARTY_BOARD])
assert 'class="d20' not in source
assert "HEXAGON_CLIP" not in source
html = _render_party_board(
{"type": "custom:nestquest-party-board-card"},
_three_child_states(),
)["html"]
row = re.search(r'<div class="wordmark-row">(.*?)</div>', html, re.DOTALL)
assert row is not None
row_html = row.group(1)
classes = re.findall(r'<(?:span|svg) class="([^"]+)"', row_html)
assert classes == ["rule", "monogram", "wordmark", "rule"]
assert '<svg class="monogram" viewBox="0 0 100 100" aria-hidden="true"' in row_html
assert "var(--nq-brand-blue)" in row_html
assert "var(--nq-brand-purple)" in row_html
assert ">N</text>" in row_html
assert ">NestQuest</span>" in row_html
assert "d20" not in row_html
assert "<img" not in row_html
bundle = _read(BUNDLE_PATH)
assert "d20-numeral\">20<" not in bundle
assert "data:image/png" not in bundle
assert not re.search(r"https?://", bundle)
def test_party_board_renders_discovered_children_in_sort_order() -> None:
"""No child_order key: three plates render in the roster's sort
order (the coordinator snapshot's order), one per child."""
@@ -764,6 +828,7 @@ def _render_quest_log(
url: str,
idle_ms: int | None = None,
probe_ms: int | None = None,
tap_complete: int | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
@@ -787,6 +852,8 @@ def _render_quest_log(
spec["idle_ms"] = idle_ms
if probe_ms is not None:
spec["probe_ms"] = probe_ms
if tap_complete is not None:
spec["tap_complete"] = tap_complete
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
@@ -886,3 +953,190 @@ 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_tapping_complete_confirms_and_calls_the_service() -> None:
"""Tapping an open quest's Complete opens the confirm dialog, and the
dialog's Complete calls nestquest.complete_quest with the panel actor.
With ``useDefineForClassFields`` on (the ES2022 default) the card's
reactive fields were emitted as native class fields that shadowed
Lit's accessors: ``_confirm`` changed but no re-render was scheduled,
so the dialog never opened and the tap did nothing."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
open_quest = {
"id": 42,
"definition_id": 7,
"child_id": 1,
"title": "Brush Teeth",
"icon": None,
"window": "morning",
"due_time": "08:15",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"1",
child_name="Ada",
child_id=1,
present=True,
instances=[open_quest],
),
}
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
tap_complete=open_quest["id"],
)
assert result["tap"]["dialog_opened"] is True
assert result["tap"]["service_calls"] == [
{
"domain": "nestquest",
"service": "complete_quest",
"data": {
"instance_id": open_quest["id"],
"actor": "panel",
"actor_child_id": open_quest["child_id"],
},
}
]
def test_card_build_keeps_lit_reactive_accessors() -> None:
"""The frontend build must not emit class fields that shadow Lit's
reactive property accessors (Lit's documented TypeScript setting)."""
tsconfig = json.loads((FRONTEND_DIR / "tsconfig.json").read_text())
assert tsconfig["compilerOptions"]["useDefineForClassFields"] is False
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_meta_line_keeps_its_meta_class() -> None:
"""An open quest's meta line is styled by ``.quest .meta`` (and
``.quest .meta.late`` when overdue). Written as
``meta ${late ? "late" : nothing}``, Lit dropped the whole class
attribute for every quest that was not late, leaving the due line
unstyled. The rendered markup must carry ``class="meta"`` on time and
``class="meta late"`` overdue."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
"child_id": 1,
"title": title,
"icon": None,
"window": "morning",
"due_time": "08:15",
"state": "open",
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"2",
child_name="Ada",
child_id=1,
present=True,
instances=[
quest(41, "Make Bed", overdue=False),
quest(42, "Brush Teeth", overdue=True),
],
),
}
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
)
metas = {}
for card in result["html"].split('data-instance-id="')[1:]:
meta = re.search(
r'<span class="quest-title">.*?</span>'
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>",
card,
re.S,
)
assert meta is not None, card[:800]
metas[card[: card.index('"')]] = meta.group(1)
assert set(metas) == {"41", "42"}, metas
assert metas["41"].strip() == 'class="meta"', metas["41"]
assert metas["42"].strip() == 'class="meta late"', metas["42"]
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]