Compare commits

...
Author SHA1 Message Date
overseer 418fc69f36 Remove removed SensorDeviceClass.PERCENTAGE 2026-09-21 10:02:08 -04:00
overseer b6b1ba65b4 Merge pull request 'Point HACS URLs at Maniacs-Mansion/NestQuest' (#136) from fix/hacs-repo-urls into main
Reviewed-on: #136
2026-09-21 07:52:09 -04:00
overseer 37f7d25cd6 Point HACS URLs at Maniacs-Mansion/NestQuest 2026-09-21 07:47:52 -04:00
overseer fe71d37bb0 Merge pull request 'Promote Feature 12 to main' (#135) from dev into main
Reviewed-on: #135
2026-09-21 06:57:15 -04:00
orchestrator-agent 1c08c793e3 Feature 12: Kid panel Lovelace cards
Feature 12: Kid panel Lovelace cards

Squash-merged PRs #125-#133. All Codex-reviewed. pytest 1496 passed.
2026-09-21 06:48:53 -04:00
dev_agent 7386999cf7 TouchHub setup runbook
TouchHub setup runbook
2026-09-20 20:20:52 -04:00
dev_agent 43ad44ee48 Panel card tests including the no-admin-controls assertion
Panel card tests including the no-admin-controls assertion
2026-09-20 19:41:57 -04:00
dev_agent 4cb07fbca4 Record 1920x1080 panel layout and TouchHub dock behaviour
Record 1920x1080 panel layout and TouchHub dock behaviour
2026-09-20 19:29:00 -04:00
dev_agent 030ba83b14 Subscribe panel cards to live NestQuest events (#130) 2026-09-20 19:17:52 -04:00
dev_agent a2c027deca Add the quest complete screen (1c)
Add the quest complete screen (1c)
2026-09-20 18:39:11 -04:00
dev_agent 2b45ebf035 Wire complete_quest with optimistic seal and parchment toast
Wire complete_quest with optimistic seal and parchment toast
2026-09-20 14:19:39 -04:00
dev_agent 028215fa38 Define and implement the panel's empty and error states
Define and implement the panel's empty and error states
2026-09-20 14:04:58 -04:00
dev_agent 0d2e0d6f26 Add the quest log card (1b) read-only
Add the quest log card (1b) read-only
2026-09-20 13:54:14 -04:00
dev_agent 1911b2bf6e Add the party board card (1a) read-only
Add the party board card (1a) read-only
2026-09-20 12:39:47 -04:00
grok 89f517b3ad Add bundled fonts, token CSS, and local font-face loading
Add bundled fonts, token CSS, and local font-face loading

Co-authored-by: grok <24+grok@code.cubecraftcreations.com>
Co-committed-by: grok <24+grok@code.cubecraftcreations.com>
2026-09-20 11:16:17 -04:00
overseer edca25affa Add the Lit card toolchain and auto-register the Lovelace bundle 2026-09-20 10:08:38 -04:00
orchestrator-agent 7c62c50526 Feature 11: Progress Automations and Notifications 2026-09-19 14:54:08 -04:00
overseer a6d9ec927b Merge pull request 'Release: Features 06, 07, 08 (2026-09-18)' (#97) from release/2026-09-18-f06-f07-f08 into main
Reviewed-on: #97
2026-09-18 14:54:48 -04:00
overseer bcdf03dac7 merge: origin/main into 0.5.0 release (keep 0.5.0 versions and notes) 2026-09-18 14:15:15 -04:00
overseer 52cbf33f33 release: advance version to 0.5.0 and add Features 06/07/08 release notes 2026-09-18 14:14:36 -04:00
overseer 43a7a722b9 Merge pull request 'Release: Features 14, 03, 05 — quest model, children registry, presence engine (2026-09-15)' (#65) from release/2026-09-15-f14-f03-f05 into main
Reviewed-on: #65
2026-09-16 08:01:05 -04:00
Joshua King b2f55ffb90 release: advance version to 0.4.0 and add Features 14/03/05 release notes 2026-09-15 19:05:37 -04:00
overseer cdfb681f46 Merge pull request 'Dev' (#44) from dev into main
Reviewed-on: #44
2026-09-14 21:36:40 -04:00
overseer 0637245d33 Merge pull request 'Release: Feature 04 — Recurrence Rule Engine (2026-09-14)' (#34) from release/2026-09-14-f04-recurrence into main
Reviewed-on: #34
2026-09-14 15:14:22 -04:00
Joshua King c157f6cb9e Merge remote-tracking branch 'origin/feature/ff8e0866-061f-411f-9de7-1728754ca01d-recurrence-rule-engine' into release/2026-09-14-f04-recurrence 2026-09-14 15:04:32 -04:00
Joshua King 21948fdee4 release: advance version to 0.3.0 and add Feature 04 release notes 2026-09-14 15:02:21 -04:00
orchestrator-agent 8c99024dd0 Full recurrence test suite across year boundaries and leap years (#33) 2026-09-14 15:01:02 -04:00
orchestrator-agent b4c9ad35c3 Implement occurrences_between range expansion (#32) 2026-09-14 14:54:26 -04:00
orchestrator-agent 4230c1d0d4 Enforce start_date and end_date bounds across all rule types (#31) 2026-09-14 14:49:46 -04:00
orchestrator-agent 5a962e0ed7 Implement yearly recurrence with leap-day policy (#30) 2026-09-14 14:39:52 -04:00
orchestrator-agent db4f695541 Implement monthly nth-weekday recurrence (#29) 2026-09-14 14:35:53 -04:00
orchestrator-agent d81f38528a Implement monthly day-of-month recurrence with month-end policy (#28) 2026-09-14 11:15:49 -04:00
orchestrator-agent 23a52707af Implement weekly and custom day-set recurrence (#27) 2026-09-14 11:10:39 -04:00
orchestrator-agent 3b638cae6f Implement daily recurrence with interval support (#26) 2026-09-14 11:00:45 -04:00
orchestrator-agent ceb32074e7 Define the ScheduleRule model and serialization (#25) 2026-09-14 10:54:45 -04:00
overseer a30bafcb68 Merge pull request 'Release: Feature 02 — SQLite Data Layer (2026-09-14)' (#24) from release/2026-09-14-f02-data-layer into main
Reviewed-on: #24
Reviewed-by: The Approver <19+approver_agent@code.cubecraftcreations.com>
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-14 10:22:17 -04:00
Joshua King c90892f082 release: zero-byte db files are corrupt and untouched; README describes 0.2.0 deliverables
- preflight now treats an existing zero-byte file as corrupt (retryable
  refusal, file left at zero bytes) instead of silently initializing
  over it; regression test added
- README overview updated for 0.2.0: database, schema, migrations,
  DAOs, append-only events, and corrupt-file startup behavior
2026-09-14 09:33:02 -04:00
Joshua King 1e1444d1a0 release: advance version to 0.2.0 and add Feature 02 release notes 2026-09-14 09:30:03 -04:00
Joshua King c6203327bd Merge remote-tracking branch 'origin/main' into release/2026-09-14-f02-data-layer 2026-09-14 09:29:06 -04:00
orchestrator-agent 0db7d4d519 DAO unit test suite covering all eight tables (#23) 2026-09-14 09:12:38 -04:00
orchestrator-agent 353f01e98c Handle missing or corrupt database on startup (#22) 2026-09-14 08:47:22 -04:00
orchestrator-agent 12bb4d58b4 DAO layer for task_instances and append-only completion_events (#21) 2026-09-14 08:28:00 -04:00
orchestrator-agent 29c058a045 DAO layer for presence schedules and overrides (#20) 2026-09-14 08:08:39 -04:00
orchestrator-agent c27ed24020 DAO layer for schedule_rules and task_definitions (#19) 2026-09-14 07:48:01 -04:00
orchestrator-agent 43b20f2b12 DAO layer for children and admin_users (#18) 2026-09-14 07:26:34 -04:00
orchestrator-agent 2c8c90a24e Implement the versioned migration runner (#17) 2026-09-14 07:10:23 -04:00
orchestrator-agent e98642e236 Schema v1: task_instances and completion_events tables (#16) 2026-09-14 06:53:14 -04:00
dev_agent 01905ec9ee feat: add schema v1 ddl for presence schedules and overrides (#15) 2026-09-13 21:59:59 -04:00
dev_agent 30748f786a feat: add schema v1 ddl for schedule_rules and task_definitions (#14) 2026-09-13 21:50:18 -04:00
dev_agent 3dae2eadd7 feat: add schema v1 ddl for children and admin_users (#13) 2026-09-13 21:39:18 -04:00
dev_agent eaf2e45e9a feat: add async sqlite connection wrapper with executor-backed queries (#12) 2026-09-13 21:35:36 -04:00
overseer 5e65cde078 Merge pull request 'Release: Feature 01 — Integration Scaffold (2026-09-13)' (#11) from release/2026-09-13-f01-scaffold into main
Reviewed-on: #11
2026-09-13 19:54:16 -04:00
Joshua King 748c8242c3 Merge release-fix: correct rollback instructions (RLS-004) 2026-09-13 19:48:17 -04:00
Joshua King 0f4b53fd4d docs: correct rollback instructions for the initial release 2026-09-13 19:48:12 -04:00
Joshua King 22f8b5dc55 Merge release-fix: address RLS-001..003 documentation findings 2026-09-13 19:46:42 -04:00
Joshua King cb2be413bd docs: fix release documentation findings (RLS-001..003) 2026-09-13 19:46:34 -04:00
45 changed files with 9590 additions and 23 deletions
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
.venv/
.pytest_cache/
*.metadata.json
frontend/node_modules/
+103 -2
View File
@@ -13,14 +13,14 @@ NestQuest helps households coordinate daily life, chore tracking, custody schedu
1. Ensure [HACS (Home Assistant Community Store)](https://hacs.xyz/) is installed.
2. In Home Assistant, open **HACS** > **Integrations**.
3. Select the three-dots menu in the top right and click **Custom repositories**.
4. Enter the repository URL: `https://github.com/talon2king/NestQuest`
4. Enter the repository URL: `https://github.com/Maniacs-Mansion/NestQuest`
5. Select category **Integration** and click **Add**.
6. Search for **NestQuest** and click **Download**.
7. Restart Home Assistant.
> **Note:** Development happens in the private Gitea repository
> (`https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`). The GitHub
> repository is a public mirror maintained for HACS distribution.
> repository is a private mirror maintained for HACS distribution.
### Manual Installation
@@ -155,6 +155,85 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
`..._quests_completed_today`, and
`binary_sensor.nestquest_<child>_present_today`.
## TouchHub setup
Run these steps in order to display the NestQuest panel on a TouchHub
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
is simply a Home Assistant client.
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
a new user for the wall device. Make it **non-admin**, and ensure it is
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
`update_quest_definition`, `set_quest_definition_active`,
`set_presence_pattern`, `create_presence_override`,
`delete_presence_override`, `export_history_csv`, and `manage_child`
(plus `regenerate` flows) — it does not make the account view-only. The
kiosk user *can* call `nestquest.complete_quest`, the one open NestQuest
service, to complete quests from the panel, and as a non-admin Home
Assistant user it may also reach other permitted Home Assistant entities
and services.
2. **Build a dashboard with only the NestQuest panel cards.** Create a
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
Dashboards → Edit → URL slug). A functional panel needs **two** views
in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
log reads the selected child's slug from the view's URL path, so the
board → log hop is a plain Lovelace navigation.
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
view → View type: Panel). Home Assistant's default Masonry layout constrains
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
layout is what renders the card full-screen at 1920×1080.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
Assistant Lovelace** app to the dock and paste the dashboard URL from
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
loads that URL directly as the kiosk landing page). Set TouchHub
**Auto-Return** to a value GREATER than the quest log's
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
Auto-Return is at or below 40s, TouchHub returns to its launcher before
the quest log's idle return can navigate back to the party board.
4. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 1, and leave that session logged in.
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
Lovelace cards, not an iframe, so no Home Assistant framing setting is
needed.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
@@ -172,6 +251,28 @@ To set up the development environment and install dependencies:
uv sync
```
### Frontend cards
The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a
single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The
integration registers that bundle as a Lovelace resource automatically on
setup — do not add it by hand. Card YAML is documented in
`design/ENTITIES-AND-SERVICES.md` §4.
```bash
cd frontend
npm install
npm run build
```
### Kids panel layout
The Lovelace panel cards are designed for the wall display's confirmed
resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom
dock overlays the NestQuest weather dock strip until it auto-hides, so
no tappable NestQuest control sits in that strip (the dock carries
`pointer-events: none`). Keep it that way when editing the cards.
### Running Tests
Run the test suite with `pytest`:
+83 -1
View File
@@ -1,5 +1,87 @@
# NestQuest Release Notes
## Version 0.5.0 — 2026-09-18
### Scope
Fifth release of NestQuest, promoting Features 06, 07, and 08 from `dev` onto 0.4.0 (quest model, children registry, presence engine):
- **Feature 06 — quest definitions and assignment:** business layer `quest_definitions.py` over the multi-assignee / multi-window model (D-008). Create/edit/deactivate definitions; assign/unassign children; query helpers for materialization. `ScheduleRule` ⇄ `schedule_rules` storage mapping in the DAO layer.
- **Feature 07 — instance materialization:** `materialize.py` fans out one instance per (definition, child, window, firing date) when the recurrence rule fires and the child is present. Idempotent on `(definition_id, child_id, due_date, window)`; skips completed instances; never generates past dates. HA-local `today` and configured `horizon_days` threaded end-to-end. Regeneration on definition/assignee/presence changes; daily rollover listener; startup backfill; `nestquest.regenerate` service.
- **Feature 08 — completion and event log:** business layer `completion.py` wrapping the existing append-only `CompletionEventsDao`. `complete_instance` / `uncomplete_instance` (reversal event, original row untouched); state derived from the latest event; HA-local `was_on_time`; derived `missed` (never stored). Actor shapes `'user'` / `'panel'` with `actor_child_id` (D-008). Permission gating of un-complete is Feature 09.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
### Breaking changes
- None at the schema layer. Schema remains **version 6** (advanced in 0.4.0). This release adds application code only.
### Known Limitations
- No permission decorator yet (Feature 09): `complete_instance` / `uncomplete_instance` are Python APIs, not gated HA services. The only HA service in this release is `nestquest.regenerate`.
- No entity or event surface (Feature 10): no sensors, no `nestquest_quest_completed` bus events.
- No panel or admin Lovelace cards (Features 12/13).
- Un-complete is implemented but not admin-gated until Feature 09.
- Single-instance only.
### Rollback
Schema version is unchanged from 0.4.0 (still 6). A code downgrade to 0.4.0 does not require a database restore for schema compatibility, but **back up `nestquest.db` (plus `-wal`/`-shm`) before upgrading** — generated instances and completion events written by 0.5.0 remain in the file after downgrade; 0.4.0 will not generate or complete them.
**HACS installs:**
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.4.0).
2. Restart Home Assistant.
**Manual installs:**
1. Replace `custom_components/nestquest/` with the 0.4.0 tree.
2. Restart Home Assistant.
**Repository operators:** git-revert the 0.5.0 release merge on `main`.
## Version 0.4.0 — 2026-09-15
### Scope
Fourth release of NestQuest, shipping three completed features on top of the data layer and recurrence engine:
- **Feature 14 — quest domain model (BREAKING rename, D-007/D-008):** `task_definitions`/`task_instances` are now `quest_definitions`/`quest_instances` everywhere; definitions are multi-assignee (`quest_definition_assignees`, no single child column) and multi-window (`quest_definition_windows` — morning/afternoon/evening, clock ranges in `const.py`); the instance key widens to `(definition_id, child_id, due_date, window)` so twice-daily and shared quests materialise one instance per child per window per day; `completion_events` gains `actor_child_id` (the tapped panel profile; admin events stay NULL) with a second actor-pair CHECK. Schema version advances to **6**.
- **Feature 03 — children registry & admin allowlist:** typed business layer for child profiles (create/edit with trimmed required names and duplicate-name warnings, whole-table reorder, deactivation as the only removal path — history is never touched); the admin allowlist with a fail-closed `is_admin` resolver (empty allowlist denies everyone; the last admin cannot be removed); automatic seeding on first setup (the config-flow user is persisted in entry data, with the options-flow list and HA owner accounts as fallbacks) so the owner is never locked out; the options flow gains a multi-select picker of existing Home Assistant users.
- **Feature 05 — presence & custody engine:** a pure `PresenceSchedule`/`PresenceOverride` model with total validation and lossless round-trips; anchor-date cycle arithmetic (D-004 — never ISO week numbers or parity); `PresenceEngine.is_present` with the always-present default for schedule-free children; date overrides beat the repeating pattern; a `next_present_dates` preview for the admin schedule UI; a full-year regression guard walking 2020 (366 days, ISO week 53) and 2015 (365 days, ISO week 53) proving the rotation never inverts at a year boundary.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
### Breaking changes
- The database schema advances from version 1 to version 6. Upgrades are automatic and data-preserving (legacy `task_*` tables are renamed; the definitions table is rebuilt for multi-assignee; legacy instances pin to the morning window; panel events backfill `actor_child_id`). **Back up `nestquest.db` before upgrading** — see Rollback.
- Dev installs from before 2026-09-15 migrate automatically on first start.
### Known Limitations
- Still no entities, services, calendar platforms, or panel cards: nothing is user-visible in Home Assistant yet (Features 06-10 land the business layer, materialisation, permission gate, and entity surface).
- No materialisation yet: instances are not generated from definitions + presence yet (Feature 07).
- Single-instance only.
### Rollback
**Back up `nestquest.db` (plus `-wal`/`-shm` sidecars) BEFORE upgrading.** This release migrates the database to schema version 6 with renamed tables; an older NestQuest build refuses a version-6 database ("newer than this integration understands") and cannot read renamed tables at all — a plain downgrade is NOT possible once the database has been migrated.
**Rollback procedure (after a migrated database):**
1. Stop Home Assistant.
2. Restore the pre-upgrade `nestquest.db` backup (and sidecars) you made before upgrading.
3. Downgrade the integration (HACS > Integrations > NestQuest > Redownload > 0.3.0) or replace `custom_components/nestquest/` manually.
4. Restart Home Assistant.
Without a backup, the only path is staying on 0.4.0 (recommended) or deleting the integration and its database to start fresh (Settings > Devices & Services > NestQuest > Delete, then remove `nestquest.db`).
**Repository operators:** instead of uninstalling, git-revert the release merge on `main` and restore the pre-upgrade database.
## Version 0.3.0 — 2026-09-14
### Scope
@@ -126,4 +208,4 @@ v0.1.0 is the first release — there is no previous NestQuest version to downgr
**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal.
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
+3
View File
@@ -28,6 +28,7 @@ from .const import (
LOGGER,
PLATFORMS,
)
from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase
from .materialize import materialize as _materialize_run
@@ -410,11 +411,13 @@ def _deregister_services(hass: HomeAssistant) -> None:
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
await async_register_frontend(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
await async_register_frontend(hass)
hass.data.setdefault(DOMAIN, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
@@ -135,6 +135,17 @@ class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor):
return None
return child.present
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Base attributes plus, for a child away today, the ISO date
of their next present day (the panel's away plate renders
``Returns <weekday>, <Mon D>`` from it)."""
base = super().extra_state_attributes
if base is None:
return None
child = self._child
return {**base, "next_present": child.next_present if child else None}
def _child_binary_sensors(
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
+25 -5
View File
@@ -50,6 +50,7 @@ from .dao_rules import QuestDefinitionsDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
@@ -86,6 +87,11 @@ class ChildDaySnapshot:
completed_today: int
remaining_today: int
completion_pct: int
#: For a child away today: the ISO date of their next present day
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
#: from it). ``None`` when the child is present today or no
#: present day exists within the presence engine's scan cap.
next_present: str | None = None
@dataclass(frozen=True)
@@ -162,10 +168,14 @@ class NestQuestCoordinator(DataUpdateCoordinator):
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
todays = await PresenceOverridesDao(
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
window_end_iso = window_end.isoformat()
ranged = await PresenceOverridesDao(
self.database
).list_by_child_and_range(child.id, today_iso, today_iso)
if todays:
).list_by_child_and_range(child.id, today_iso, window_end_iso)
if ranged:
# The engine validates MODEL objects, not DAO records;
# each record converts losslessly (the schema guarantees
# end >= start and a real is_present).
@@ -177,7 +187,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
record.is_present,
record.note,
)
for record in todays
for record in ranged
]
engine = PresenceEngine(schedules, overrides)
@@ -231,18 +241,28 @@ class NestQuestCoordinator(DataUpdateCoordinator):
due = len(views)
completed = sum(1 for view in views if view.state == "done")
remaining = due - completed
child_present = engine.is_present(child.id, today)
next_present: str | None = None
if not child_present:
try:
next_present = engine.next_present_dates(
child.id, today, 1
)[0].isoformat()
except ValueError:
next_present = None
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=engine.is_present(child.id, today),
present=child_present,
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
next_present=next_present,
)
)
+35
View File
@@ -0,0 +1,35 @@
"""Lovelace card bundle registration for NestQuest."""
from __future__ import annotations
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
WWW_DIR = Path(__file__).parent / "www"
FRONTEND_URL_BASE = "/nestquest-static"
BUNDLE_FILENAME = "nestquest-cards.js"
BUNDLE_URL = f"{FRONTEND_URL_BASE}/{BUNDLE_FILENAME}"
_REGISTERED_ATTR = "_nestquest_frontend_registered"
async def async_register_frontend(hass: HomeAssistant) -> None:
"""Serve the card bundle and inject it into every Lovelace page.
Idempotent across config entries: static paths and extra JS URLs are
process-global, so a second entry must not register them again.
"""
if getattr(hass, _REGISTERED_ATTR, False):
return
await hass.http.async_register_static_paths(
[
StaticPathConfig(
FRONTEND_URL_BASE,
str(WWW_DIR),
cache_headers=False,
)
]
)
add_extra_js_url(hass, BUNDLE_URL)
setattr(hass, _REGISTERED_ATTR, True)
+7 -3
View File
@@ -5,10 +5,14 @@
"@overseer"
],
"config_flow": true,
"documentation": "https://github.com/talon2king/NestQuest",
"dependencies": [
"frontend",
"http"
],
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/talon2king/NestQuest/issues",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.3.0"
"version": "0.5.0"
}
-2
View File
@@ -25,7 +25,6 @@ from collections.abc import Callable, Iterable
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
@@ -280,7 +279,6 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
"""The child's completion percentage for today (100 on a zero-quest day)."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_device_class = SensorDeviceClass.PERCENTAGE
_attr_native_unit_of_measurement = "%"
_name_suffix = "completion pct today"
@@ -0,0 +1,76 @@
/* ============================================================
NestQuest — Parent admin card tokens
Surface: phone, 390x844 reference. Plain CubeCraft styling,
no tabletop treatment, plain domain words in the UI.
Scope on the card host: nestquest-admin-card.
Derived from the approved mockups (admin screens 1d-1i).
Two densities were built; COMPACT is the default. Flip
--nq-a-density-* to the roomy values for the alternative.
============================================================ */
:host {
/* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */
--nq-brand-blue: #3B3AB8;
--nq-brand-purple: #A035CC;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-a-page: #F4F4F6;
--nq-a-surface: #FFFFFF;
--nq-a-surface-subtle: #FAFAFA;
--nq-a-selected: #EBEBF8; /* row being edited */
--nq-a-border: #E4E4EA;
--nq-a-divider: #EDEDF1;
--nq-a-ink: #18181D;
--nq-a-ink-secondary: #52525E;
--nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */
--nq-a-success: #15803D;
--nq-a-danger: #DC2626;
--nq-a-danger-strong: #B91C1C;
--nq-a-danger-bg: #FEE2E2;
--nq-a-warning-ink: #92400E;
--nq-a-warning-ink-2: #78350F;
--nq-a-warning-bg: #FEF3C7;
--nq-a-warning-border: rgba(217,119,6,.35);
--nq-a-info-bg: #DBEAFE;
--nq-a-info-ink: #1E3A8A;
--nq-a-info-icon: #1D4ED8;
--nq-a-reversal: #7D28A0; /* un-complete events in history */
/* ── Type ─────────────────────────────────────────────── */
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px; /* 800, screen title */
--nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */
--nq-a-size-card-title: 19px; /* 800, roomy child card */
--nq-a-size-stat: 22px; /* 800, compact stat number */
--nq-a-size-row: 13.5px;/* 700, list row title */
--nq-a-size-meta: 11px; /* 600, row meta */
--nq-a-size-label: 10px; /* 700, uppercase field label */
--nq-a-track-label: .10em;
/* ── Density: COMPACT (default) ───────────────────────── */
--nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */
--nq-a-density-gap: 10px;
--nq-a-density-card-pad: 13px;
--nq-a-density-row-pad: 11px 13px;
--nq-a-density-radius: 8px;
--nq-a-density-shadow: none;
/* Density: ROOMY — page-pad 18px 20px 92px; gap 16px;
card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */
/* ── Metrics ──────────────────────────────────────────── */
--nq-a-tabbar-height: 70px; /* + 12px safe-area padding */
--nq-a-tap-min: 44px; /* every row/control clears this */
--nq-a-radius-pill: 9999px;
--nq-a-radius-sheet: 20px 20px 0 0;
--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);
--nq-a-progress-height: 6px; /* 8px roomy, 10px hero */
/* ── Motion ───────────────────────────────────────────── */
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
--nq-dur-micro: 120ms;
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/nunito-latin-400-normal.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("./fonts/nunito-latin-600-normal.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/nunito-latin-700-normal.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("./fonts/nunito-latin-800-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("./fonts/cinzel-latin-600-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/cinzel-latin-700-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel";
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("./fonts/cinzel-latin-900-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel Decorative";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/cinzel-decorative-latin-700-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel Decorative";
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("./fonts/cinzel-decorative-latin-900-normal.woff2") format("woff2");
}
@@ -0,0 +1,96 @@
/* ============================================================
NestQuest — Kids' panel card tokens
Surface: DAKboard DAKOS wall panel, 1920x1080 landscape,
viewed from ~3 ft, no login, read/complete only.
Scope these on the card host: nestquest-quest-log-card,
nestquest-party-board-card.
Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j).
============================================================ */
:host {
/* ── Parchment ground ─────────────────────────────────── */
--nq-p-parchment-top: #f0e4c7;
--nq-p-parchment-bottom: #ddcca3;
--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%),
radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%),
repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px),
repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px),
linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));
--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);
/* Card surfaces sitting on parchment */
--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */
--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */
--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */
--nq-p-card-border: rgba(120,88,48,.38);
--nq-p-panel-border: rgba(92,62,26,.40);
--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70);
--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70);
--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);
--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);
--nq-p-rule: 2px solid rgba(92,62,26,.35);
/* ── Ink (all values >= 4.5:1 on parchment) ───────────── */
--nq-p-ink: #2b1f14; /* titles, quest names */
--nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */
--nq-p-ink-muted: #6f6455; /* sealed quest titles only */
--nq-p-ink-away: #4d4433; /* away adventurer name */
--nq-p-ink-late: #8f1526; /* overdue line */
/* ── Brand (CubeCraft, toned down: accents only) ──────── */
--nq-brand-blue: #3B3AB8;
--nq-brand-purple: #A035CC;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */
--nq-p-icon-tile-done: rgba(92,62,26,.16);
--nq-p-crest: var(--nq-brand-gradient);
--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);
/* ── Wax seal (completed marker) ──────────────────────── */
--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);
--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14);
--nq-p-seal-size: 78px; /* 168px on the quest-complete screen */
/* ── Dock (bottom strip, non-interactive) ─────────────── */
--nq-p-dock-bg: rgba(43,31,20,.90);
--nq-p-dock-ink: #f7efdb;
--nq-p-dock-ink-secondary: #d8c9a6;
--nq-p-dock-divider: rgba(233,220,189,.30);
--nq-p-dock-height: 84px;
/* ── Type ─────────────────────────────────────────────── */
--nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */
--nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */
--nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */
--nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */
--nq-p-size-wordmark: 66px; /* 900 weight */
--nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */
--nq-p-size-name: 48px; /* 700, adventurer name */
--nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */
--nq-p-size-quest: 30px; /* 700, quest title */
--nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */
--nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */
--nq-p-track-label: .14em;
--nq-p-track-kicker: .30em;
/* ── Metrics: touch ───────────────────────────────────── */
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
--nq-p-quest-gap: 24px; /* separation between tap targets */
--nq-p-quest-pad: 22px; /* padding inside a quest card */
--nq-p-tile-size: 64px; /* quest icon tile */
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
--nq-p-button-min-width: 150px;
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
--nq-p-radius-card: 12px;
--nq-p-radius-panel: 20px;
--nq-p-radius-pill: 9999px;
--nq-p-page-inset: 62px;
--nq-p-frame-inset: 26px;
/* ── Motion (CubeCraft easing) ────────────────────────── */
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
--nq-dur-micro: 120ms;
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
+1
View File
@@ -90,6 +90,7 @@ child_order: [declan, jordyn, chloe]
# kids' panel, view 2
type: custom:nestquest-quest-log-card
board_path: /nestquest/board
weather_entity: weather.home # optional; dock falls back to date/time
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
confirm_timeout_seconds: 15
complete_screen_seconds: 12
+104
View File
@@ -0,0 +1,104 @@
# Panel empty & error states — design addendum
Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error
states are unspecified by request (child with no quests, everyone away, integration
unconfigured, DB unreachable). Panel must not render a blank parchment — decide
before shipping the card."*
All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground,
double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add
one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`,
2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body).
The frame, wordmark row, kicker and dock always render; only the content area
changes. **No state renders a blank parchment.**
## 1. What the data can say
The cards read `sensor.nestquest_<child>_quests_due_today` (plus siblings). One of
four things comes back, and the card must tell them apart:
| Backend condition | What the card sees | Card's interpretation |
|---|---|---|
| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below |
| Integration not configured | entity does not exist at all | **not set up** |
| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** |
| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) |
An away child (presence `present: false`, from the due-sensor attributes with the
presence binary sensor as fallback) is *not* an error: it is a first-class panel
state ("On travels"). Away children get no instances (materialiser skips them), so
away is detected from the presence flag, never from a zero quest count.
## 2. Quest log — state priority
`nestquest-quest-log-card` resolves exactly one state, in this order:
1. **No adventurer in the path** (direct navigation to a URL with no child slug —
misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*,
body *"Open The Party and tap your crest to open your quest log."*
2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body
*"A parent needs to finish setting up NestQuest."*
3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot
be reached"*, body *"The party's records are quiet right now. NestQuest will
return shortly."*
4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate
gradient), tent icon, *"<Name> is on travels"*, *"The quest log unlocks when
they return."* and, when `next_present` is known, *"Returns <weekday>, <Mon D>"*.
Direct navigation is the one path that can reach this state (the board's away
plate is not tappable); the log answers it honestly instead of showing an empty
column set. Header renders with the crest in away colours and the sub line
`… · On travels`; the remaining-count block is hidden (it is meaningless while
away).
5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests
today"*, body *"Nothing was posted for today, <Name>. Enjoy the day's rest!"* —
never three bare empty columns. The header and remaining block stay (0 quests
left, party progress), so the log still reads like the log.
6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that
shows until the 1c quest-complete screen takes over: centred wax-seal mark (the
`--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"*
— the same phrase screen 1c uses — and body *"Every quest is claimed, <Name>.
The seal is set for today. Returning to The Party shortly."* The idle return
(§3.5) is what actually navigates back; 1c remains the designed takeover.
7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A
window with no quests still renders its head alone, exactly as the spec says.
Notices sit in the columns' content area, vertically centred. Error notices (states
1–3) replace the header entirely — there is no honest name to headline when the
records do not answer — while states 4–6 keep the full header.
## 3. Party board — state priority
`nestquest-party-board-card` resolves per configured child, then at board level:
1. **No children configured** (`child_order` empty) → board notice *"NestQuest is
not set up yet"*, body *"A parent needs to finish setting up NestQuest before
the party can gather."*
2. **Every configured child unresolvable** → one board notice instead of a grid of
broken plates: *"NestQuest is not set up yet"* when the entities are missing,
*"The records cannot be reached"* when they answer `unavailable`/`unknown`.
3. **Partial** (some children resolve, some do not) → the board still renders the
resolving plates; an unresolvable child renders a muted, dashed plate in the
away geometry with a compass icon and the pill label **"Unknown"**, progress
line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0)
and must not lie "On travels": the records simply do not say.
4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates).
The hint line ("Tap your crest…") is hidden in board-level notice states — there
is nothing to tap.
## 4. Shared rules
- **Never a blank parchment.** Every render path emits the frames, wordmark,
kicker, dock, and either content or a notice panel. The dock already degrades to
date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged.
- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel
(`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito
(`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it.
- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an
error says the records cannot be reached, not that everything is fine. Error
copy names the actor who can fix it (a parent) or promises the return of the
data — no stack traces, no entity IDs.
- **No interaction in error/away states.** No confirm dialog, no idle return
changes, no navigation attempts. Notices are static.
- Both cards implement the same resolution rules, so the board and the log never
disagree about whether the integration is up.
+2
View File
@@ -77,6 +77,8 @@
/* ── Metrics: touch ───────────────────────────────────── */
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
--nq-p-quest-gap: 24px; /* separation between tap targets */
--nq-p-quest-pad: 22px; /* padding inside a quest card */
--nq-p-tile-size: 64px; /* quest icon tile */
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
--nq-p-button-min-width: 150px;
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
+1232
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "nestquest-cards",
"private": true,
"type": "module",
"scripts": {
"build": "vite build"
},
"dependencies": {
"lit": "^3.3.0"
},
"devDependencies": {
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
}
+38
View File
@@ -0,0 +1,38 @@
import { LitElement, html, unsafeCSS } from "lit";
import adminTokens from "../../../custom_components/nestquest/www/nestquest-admin-tokens.css?inline";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
export class NestQuestAdminCard extends LitElement {
static properties = {
hass: { attribute: false },
_config: { state: true },
};
static styles = [unsafeCSS(adminTokens)];
hass?: HassLike;
_config?: CardConfig;
setConfig(config: CardConfig): void {
if (!config || typeof config !== "object") {
throw new Error("Invalid configuration");
}
this._config = config;
}
getCardSize(): number {
return 8;
}
render() {
return html`<div>NestQuest Admin</div>`;
}
}
customElements.define("nestquest-admin-card", NestQuestAdminCard);
registerCustomCard({
type: "nestquest-admin-card",
name: "NestQuest Admin",
description: "Parent phone view for today, tasks, schedule, and history.",
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
import "./cards/party-board";
import "./cards/quest-log";
import "./cards/admin";
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = FONTS_HREF;
link.dataset.nqFonts = "";
document.head.appendChild(link);
}
+20
View File
@@ -0,0 +1,20 @@
export type HassLike = Record<string, unknown>;
export type CardConfig = Record<string, unknown>;
export type CustomCardInfo = {
type: string;
name: string;
description: string;
};
declare global {
interface Window {
customCards?: CustomCardInfo[];
}
}
export function registerCustomCard(info: CustomCardInfo): void {
window.customCards = window.customCards ?? [];
window.customCards.push(info);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
export default defineConfig({
build: {
outDir: "../custom_components/nestquest/www",
emptyOutDir: false,
sourcemap: false,
lib: {
entry: "src/nestquest-cards.ts",
formats: ["es"],
fileName: () => "nestquest-cards.js",
},
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.3.0"
version = "0.5.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+38
View File
@@ -51,6 +51,8 @@ _HA_MODULES = (
"homeassistant.components",
"homeassistant.components.sensor",
"homeassistant.components.binary_sensor",
"homeassistant.components.http",
"homeassistant.components.frontend",
)
def _find_spec(name: str):
@@ -96,6 +98,8 @@ for _parent, _child in (
("homeassistant", "components"),
("homeassistant.components", "sensor"),
("homeassistant.components", "binary_sensor"),
("homeassistant.components", "http"),
("homeassistant.components", "frontend"),
):
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
@@ -429,6 +433,28 @@ _sensor_mock.SensorStateClass = SensorStateClass
_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor")
_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity
@dataclass(frozen=True)
class StaticPathConfig:
"""Stand-in mirroring homeassistant.components.http.StaticPathConfig."""
url_path: str
path: str
cache_headers: bool = True
def _add_extra_js_url(hass, url, es5=False):
"""Stand-in mirroring homeassistant.components.frontend.add_extra_js_url."""
urls = getattr(hass, "extra_js_urls", None)
if urls is None:
hass.extra_js_urls = []
urls = hass.extra_js_urls
urls.append(url)
_ha_mock("homeassistant.components.http").StaticPathConfig = StaticPathConfig
_ha_mock("homeassistant.components.frontend").add_extra_js_url = _add_extra_js_url
import voluptuous as vol
@@ -549,6 +575,16 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
class HttpRegistry:
"""Models HA's ``hass.http`` static-path registration."""
def __init__(self):
self.static_paths: list[StaticPathConfig] = []
async def async_register_static_paths(self, configs):
self.static_paths.extend(list(configs))
class EventBus:
"""Models HA's ``hass.bus`` event bus.
@@ -697,6 +733,8 @@ def make_hass() -> tuple:
hass.time_change = TimeChangeRegistry(hass)
hass.services = ServiceRegistry(hass)
hass.bus = EventBus()
hass.http = HttpRegistry()
hass.extra_js_urls: list[str] = []
# A valid IANA time zone (the day-rollover listener reads it to
# compute "today" in HA local time).
hass.config.time_zone = "UTC"
+52 -2
View File
@@ -6,7 +6,7 @@ import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child
from custom_components.nestquest.children import create_child, list_children
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.materialize import materialize
@@ -128,7 +128,57 @@ async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
assert present.name == "NestQuest Ada present today"
for entity in (all_done, present):
assert entity.device_info.name == "NestQuest Ada"
assert entity.extra_state_attributes == {
assert all_done.extra_state_attributes == {
"child_id": ada.id,
"child_name": "Ada",
}
assert present.extra_state_attributes == {
"child_id": ada.id,
"child_name": "Ada",
"next_present": None,
}
async def test_present_today_carries_next_present_when_away(
hass, make_entry
) -> None:
"""A child away today carries the ISO date of their next present
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
from it); an always-absent pattern carries ``None``."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory")
today = datetime.date.today()
pattern = ",".join(
str(day) for day in range(7) if day != today.weekday()
)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), pattern
)
await PresenceSchedulesDao(database).upsert_by_child(
await _cory_id(database), 1, today.isoformat(), ""
)
await coordinator.async_refresh()
assert _binary(hass, ada.id, "present_today").is_on is False
assert _binary(hass, ada.id, "present_today").extra_state_attributes[
"next_present"
] == (today + datetime.timedelta(days=1)).isoformat()
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
assert _binary(hass, cory.child_id, "present_today").is_on is False
assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[
"next_present"
] is None
async def _cory_id(database) -> int:
for child in await list_children(database):
if child.display_name == "Cory":
return child.id
raise AssertionError("Cory was not created")
+65
View File
@@ -78,9 +78,43 @@ async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None:
assert instance.title == "Brush teeth"
assert instance.window == "morning"
assert ada.present is True, "no schedule means present every day"
assert ada.next_present is None, "present child has no returns date"
assert snapshot.cycle_day == 0, "no schedule anywhere"
async def test_coordinator_next_present_for_away_child(
hass, make_entry
) -> None:
"""A child absent today carries the ISO date of their next present
day; an always-absent pattern carries None instead."""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
pattern = ",".join(
str(day) for day in range(7) if day != today.weekday()
)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), pattern
)
await create_child(database, "Bo")
bo = (await list_children(database))[1]
await PresenceSchedulesDao(database).upsert_by_child(
bo.id, 1, today.isoformat(), ""
)
snapshot = await coordinator._async_update_data()
by_name = {child.child_name: child for child in snapshot.children}
assert by_name["Ada"].present is False
assert by_name["Ada"].next_present == (
today + datetime.timedelta(days=1)
).isoformat()
assert by_name["Bo"].present is False
assert by_name["Bo"].next_present is None
async def test_coordinator_reflects_completion_and_uncompletion(
hass, make_entry
) -> None:
@@ -184,6 +218,37 @@ async def test_coordinator_presence_resolves_schedule_and_override(
assert snapshot.children[0].present is True
async def test_coordinator_next_present_honours_future_override(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
# Scheduled present tomorrow (single-day cycle, weekday set set below
# covers every day), but overridden absent tomorrow.
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6"
)
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
await PresenceOverridesDao(database).create(
ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.present is True
assert child.next_present != tomorrow.isoformat(), (
"a future absent override must remove tomorrow from the preview"
)
async def test_coordinator_derives_missed_for_past_due_open(
hass, make_entry
) -> None:
+181
View File
@@ -0,0 +1,181 @@
"""Tests for the Lovelace card toolchain and auto-registered resource."""
from __future__ import annotations
import json
from pathlib import Path
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup, async_setup_entry
from custom_components.nestquest.frontend import (
BUNDLE_FILENAME,
BUNDLE_URL,
FRONTEND_URL_BASE,
WWW_DIR,
async_register_frontend,
)
REPO_ROOT = Path(__file__).parent.parent
FRONTEND_DIR = REPO_ROOT / "frontend"
BUNDLE_PATH = WWW_DIR / BUNDLE_FILENAME
CARD_TYPES = (
"nestquest-party-board-card",
"nestquest-quest-log-card",
"nestquest-admin-card",
)
def test_frontend_package_documents_vite_lit_build() -> None:
package = json.loads((FRONTEND_DIR / "package.json").read_text(encoding="utf-8"))
assert package["scripts"]["build"] == "vite build"
assert "lit" in package["dependencies"]
assert "typescript" in package["devDependencies"]
assert "vite" in package["devDependencies"]
def test_card_modules_exist_as_separate_sources() -> None:
src = FRONTEND_DIR / "src"
assert (src / "nestquest-cards.ts").is_file()
assert (src / "cards" / "party-board.ts").is_file()
assert (src / "cards" / "quest-log.ts").is_file()
assert (src / "cards" / "admin.ts").is_file()
entry = (src / "nestquest-cards.ts").read_text(encoding="utf-8")
assert "./cards/party-board" in entry
assert "./cards/quest-log" in entry
assert "./cards/admin" in entry
def test_card_sources_have_no_visual_editor() -> None:
for path in (FRONTEND_DIR / "src" / "cards").glob("*.ts"):
text = path.read_text(encoding="utf-8")
assert "getConfigElement" not in text
assert "setConfig" in text
assert "getCardSize" in text
def test_built_bundle_contains_all_three_cards() -> None:
assert BUNDLE_PATH.is_file()
bundle = BUNDLE_PATH.read_text(encoding="utf-8")
for card_type in CARD_TYPES:
assert card_type in bundle
async def test_setup_registers_static_path_and_extra_js(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert len(hass.http.static_paths) == 1
registered = hass.http.static_paths[0]
assert registered.url_path == FRONTEND_URL_BASE
assert registered.path == str(WWW_DIR)
assert hass.extra_js_urls == [BUNDLE_URL]
async def test_async_setup_registers_frontend_without_a_config_entry(hass) -> None:
assert await async_setup(hass, {}) is True
assert hass.extra_js_urls == [BUNDLE_URL]
assert hass.http.static_paths[0].url_path == FRONTEND_URL_BASE
async def test_frontend_registration_is_idempotent(hass, make_entry) -> None:
await async_register_frontend(hass)
await async_register_frontend(hass)
entry_a = wire_entry_to_registry(make_entry(entry_id="a"), hass.registry)
entry_b = wire_entry_to_registry(make_entry(entry_id="b"), hass.registry)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
assert len(hass.http.static_paths) == 1
assert hass.extra_js_urls == [BUNDLE_URL]
def test_readme_documents_npm_build() -> None:
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
assert "npm run build" in readme
assert "nestquest-cards.js" in readme
def test_readme_documents_panel_resolution_and_touchhub_dock() -> None:
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
assert "1920×1080" in readme
assert "TouchHub" in readme
assert "dock" in readme
assert "pointer-events: none" in readme
WOFF2_FILES = (
"nunito-latin-400-normal.woff2",
"nunito-latin-600-normal.woff2",
"nunito-latin-700-normal.woff2",
"nunito-latin-800-normal.woff2",
"cinzel-latin-600-normal.woff2",
"cinzel-latin-700-normal.woff2",
"cinzel-latin-900-normal.woff2",
"cinzel-decorative-latin-700-normal.woff2",
"cinzel-decorative-latin-900-normal.woff2",
)
CDN_MARKERS = (
"fonts.googleapis.com",
"fonts.gstatic.com",
"cdn.jsdelivr.net",
"unpkg.com",
"cdnjs.cloudflare.com",
)
def test_www_contains_required_woff2_fonts() -> None:
fonts_dir = WWW_DIR / "fonts"
for name in WOFF2_FILES:
path = fonts_dir / name
assert path.is_file(), name
assert path.read_bytes()[:4] == b"wOF2"
def test_font_css_declares_swap_and_local_urls() -> None:
css = (WWW_DIR / "nestquest-fonts.css").read_text(encoding="utf-8")
assert css.count("font-display: swap") == 9
assert css.count("@font-face") == 9
assert 'font-family: "Nunito"' in css
assert 'font-family: "Cinzel"' in css
assert 'font-family: "Cinzel Decorative"' in css
for name in WOFF2_FILES:
assert f"./fonts/{name}" in css
assert "http://" not in css
assert "https://" not in css
def test_token_css_copied_verbatim_from_design() -> None:
design = REPO_ROOT / "design" / "tokens"
for name in ("nestquest-panel-tokens.css", "nestquest-admin-tokens.css"):
expected = (design / name).read_bytes()
actual = (WWW_DIR / name).read_bytes()
assert actual == expected
def test_frontend_and_www_have_no_cdn_font_references() -> None:
roots = (FRONTEND_DIR, WWW_DIR)
for root in roots:
for path in root.rglob("*"):
if not path.is_file():
continue
if "node_modules" in path.parts:
continue
if path.suffix.lower() in {".woff2", ".png", ".jpg"}:
continue
text = path.read_text(encoding="utf-8", errors="ignore")
for marker in CDN_MARKERS:
assert marker not in text, f"{path}: {marker}"
def test_readme_documents_the_touchhub_setup_runbook() -> None:
readme = " ".join(
(REPO_ROOT / "README.md").read_text(encoding="utf-8").split()
)
for required in ("TouchHub", "kiosk"):
assert required in readme, required
assert "not in the NestQuest admin allowlist" in readme
assert "contains only the NestQuest panel card" in readme
assert "nestquest-party-board-card" in readme
assert "nestquest-quest-log-card" in readme
assert "Panel" in readme
assert "Auto-Return" in readme
assert "use_x_frame_options" not in readme
assert "clickjacking" not in readme
+2 -2
View File
@@ -67,9 +67,9 @@ def test_readme_exists_and_contains_required_sections() -> None:
def test_readme_hacs_points_at_public_github_mirror() -> None:
"""Verify README directs HACS users to the public GitHub mirror, not the private Gitea repo."""
content = README_PATH.read_text(encoding="utf-8")
assert "https://github.com/talon2king/NestQuest" in content
assert "https://github.com/Maniacs-Mansion/NestQuest" in content
assert "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest" in content
assert content.index("https://github.com/talon2king/NestQuest") < content.index(
assert content.index("https://github.com/Maniacs-Mansion/NestQuest") < content.index(
"https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest"
)
+3 -3
View File
@@ -58,19 +58,19 @@ def test_manifest_requirements(manifest: dict) -> None:
def test_manifest_documentation_url_is_public_github_mirror(manifest: dict) -> None:
"""Verify documentation URL points at the public GitHub mirror."""
assert manifest.get("documentation") == "https://github.com/talon2king/NestQuest"
assert manifest.get("documentation") == "https://github.com/Maniacs-Mansion/NestQuest"
def test_manifest_issue_tracker_url_is_public_github_mirror(manifest: dict) -> None:
"""Verify issue_tracker URL points at the public GitHub mirror."""
assert manifest.get("issue_tracker") == "https://github.com/talon2king/NestQuest/issues"
assert manifest.get("issue_tracker") == "https://github.com/Maniacs-Mansion/NestQuest/issues"
@pytest.mark.parametrize("key", ["documentation", "issue_tracker"])
def test_manifest_urls_use_github_com(manifest: dict, key: str) -> None:
"""Verify manifest URLs use github.com rather than the private Gitea host."""
url = manifest.get(key, "")
assert url.startswith("https://github.com/talon2king/NestQuest")
assert url.startswith("https://github.com/Maniacs-Mansion/NestQuest")
assert "cubecraftlabs" not in url
+160
View File
@@ -0,0 +1,160 @@
"""Source-level tests for the kid panel cards.
The panel cards are Lit/TypeScript modules bundled by Vite; these tests assert
the contract by reading the card sources and the built bundle, in the same
spirit as test_frontend.py. They cover the states decided in
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
bundles against ever gaining a parent-only service call.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards"
PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
# Parent-only NestQuest services. The party board and quest log are the
# kid-facing panel: they may complete a quest for the tapped child, and nothing
# else. Any of these names appearing in a panel source or bundle means an admin
# control has leaked into the kid panel.
ADMIN_ONLY_SERVICES = (
"uncomplete_quest",
"create_quest_definition",
"update_quest_definition",
"set_quest_definition_active",
"set_presence_pattern",
"create_presence_override",
"delete_presence_override",
"export_history_csv",
"manage_child",
"regenerate",
)
WINDOW_KEYS = ("morning", "afternoon", "evening")
WINDOW_NAMES = ("Morning", "Afternoon", "Evening")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_party_board_renders_present_and_away_plates() -> None:
source = _read(PARTY_BOARD)
assert "nestquest-party-board-card" in source
# Present adventurers get the tappable crest plate.
assert "button.plate" in source
assert 'class="plate"' in source
assert "Home today" in source
assert "aria-label=\"Open ${plate.name}'s Quest Log\"" in source
# Away adventurers get the dashed On travels plate, not a tappable one.
assert ".plate.away" in source
assert 'class="plate away"' in source
assert "On travels" in source
assert "next_present" in source
assert "Returns ${formatDay(returnsDate" in source
def test_quest_log_renders_three_window_columns() -> None:
source = _read(QUEST_LOG)
for key, name in zip(WINDOW_KEYS, WINDOW_NAMES):
assert f'key: "{key}"' in source, key
assert f'name: "{name}"' in source, name
assert "grid-template-columns: repeat(3, 1fr)" in source
assert 'class="column-head"' in source
assert 'class="column-names"' in source
assert 'class="column-range"' in source
def test_empty_window_still_renders_its_column_head() -> None:
"""A window with no quests renders the head alone (PANEL-SPEC §3.1)."""
source = _read(QUEST_LOG)
column = source[source.index("private _renderColumn(windowDef: WindowDef)") :]
column = column[: column.index("private _renderQuest(")]
# The head is emitted unconditionally, before the stack is built.
assert 'class="column-head"' in column
assert "sealed.length}/${instances.length}" in column
# There is no early return that would drop the head on an empty window.
assert "instances.length === 0" not in column
assert "return nothing" not in column
def test_complete_quest_is_optimistic_with_rollback() -> None:
source = _read(QUEST_LOG)
assert "_optimistic" in source
assert "new Date().toISOString()" in source
assert ".set(instance.id, stampedAt)" in source
# The overlay is applied to the rendered instance before the service answers.
assert 'instance.state = "completed"' in source
# A rejected call reverts the overlay and raises the parchment toast.
assert "reverted.delete(instance.id)" in source
assert "NestQuest could not set the seal just now. Please try again." in source
assert "_showToast(" in source
assert "await this._callCompleteQuest(instance)" in source
assert '"nestquest", "complete_quest"' in source
def test_idle_return_to_board() -> None:
source = _read(QUEST_LOG)
assert "idle_return_seconds" in source
assert "_armIdle" in source
assert "_clearIdle" in source
assert "this._idleSeconds() * 1000" in source
assert "board_path" in source
assert "_returnToBoard" in source
assert 'window.dispatchEvent(new Event("location-changed"))' in source
def test_decided_empty_and_error_states_are_present() -> None:
log = _read(QUEST_LOG)
board = _read(PARTY_BOARD)
for kind in (
"no-adventurer",
"not-set-up",
"unreachable",
"away",
"empty-day",
"complete-day",
):
assert f'kind: "{kind}"' in log, kind
assert "PANEL-EMPTY-STATES" in log
assert "No adventurer chosen" in log
assert "NestQuest is not set up yet" in log
assert "The records cannot be reached" in log
assert "No quests today" in log
assert 'role="status"' in log
# The board distinguishes the same setup/unreachable cases at board level.
assert "NestQuest is not set up yet" in board
assert "The records cannot be reached" in board
assert "BOARD_NOTICE_NOT_SET_UP" in board
assert "BOARD_NOTICE_UNREACHABLE" in board
def test_quest_complete_trigger() -> None:
source = _read(QUEST_LOG)
assert "binary_sensor.nestquest_${slug}_all_done" in source
assert "nestquest_child_day_complete" in source
assert "NESTQUEST_EVENT_TYPES" in source
assert 'class="complete-screen"' in source
assert "Quest complete" in source
assert "Returning to The Party in" in source
assert "_armCompleteTimer" in source
assert "complete_screen_seconds" in source
def test_panel_cards_reference_no_admin_only_services() -> None:
# Only the kid panel sources are scanned: the bundle also contains the
# sibling admin card, so its parent-only service names are expected there.
for path in PANEL_SOURCES:
text = _read(path)
for service in ADMIN_ONLY_SERVICES:
assert service not in text, f"{path}: {service}"
# The bundle is still built and carries both panel card custom elements.
assert BUNDLE_PATH.is_file()
bundle = _read(BUNDLE_PATH)
assert "nestquest-party-board-card" in bundle
assert "nestquest-quest-log-card" in bundle
+1 -1
View File
@@ -143,7 +143,7 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity(
assert entity.native_unit_of_measurement is None
assert pct_entity.state_class == "measurement"
assert pct_entity.native_unit_of_measurement == "%"
assert pct_entity.device_class == "percentage"
assert pct_entity.device_class is None
device_info = due_entity.device_info
assert device_info.name == f"NestQuest {name}"
assert device_info.manufacturer == "NestQuest"
Generated
+1 -1
View File
@@ -108,7 +108,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.3.0"
version = "0.5.0"
source = { virtual = "." }
[package.dev-dependencies]