Feature 12: Kid panel Lovelace cards
Feature 12: Kid panel Lovelace cards Squash-merged PRs #125-#133. All Codex-reviewed. pytest 1496 passed.
This commit was merged in pull request #134.
This commit is contained in:
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -5,6 +5,10 @@
|
||||
"@overseer"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/talon2king/NestQuest",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 */
|
||||
|
||||
Generated
+1232
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user