Compare commits

..
Author SHA1 Message Date
overseer e1796cc73f Lower dense quest min-height floor to 58px 2026-09-20 13:50:10 -04:00
overseer fba8f595d6 Increase afternoon roll-up reserve for wrapped text 2026-09-20 13:46:14 -04:00
overseer 729bcad62f Rebuild nestquest-cards.js for roll-up reserve 2026-09-20 13:29:45 -04:00
overseer 263f5c2877 Increase afternoon roll-up reserve to include stack gap 2026-09-20 13:29:15 -04:00
overseer 0dff75a682 Reserve afternoon roll-up and floor dense quest height 2026-09-20 13:24:56 -04:00
overseer 39712e668b Shrink quest tile and padding so dense columns fit 2026-09-20 13:17:21 -04:00
overseer 577c26f3cb Fit N quest cards per column by shrinking gap and min-height 2026-09-20 13:10:18 -04:00
overseer 0dde7b886e Fix quest log parseIsoDate RangeError from invalid hour option 2026-09-20 12:57:23 -04:00
overseer bc04b45a66 Add the quest log card (1b) read-only 2026-09-20 12:52:17 -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
35 changed files with 6867 additions and 7 deletions
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
.venv/
.pytest_cache/
*.metadata.json
frontend/node_modules/
+14
View File
@@ -172,6 +172,20 @@ 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
```
### Running Tests
Run the test suite with `pytest`:
+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)
@@ -5,6 +5,10 @@
"@overseer"
],
"config_flow": true,
"dependencies": [
"frontend",
"http"
],
"documentation": "https://github.com/talon2king/NestQuest",
"integration_type": "hub",
"iot_class": "local_polling",
@@ -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;
}
+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.",
});
+892
View File
@@ -0,0 +1,892 @@
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
};
type ChildPlate = {
slug: string;
name: string;
initial: string;
present: boolean;
completed: number;
due: number;
pct: number;
returns: string | null;
};
type DockWeather = {
condition: string;
temperature: number | null;
high: number | null;
low: number | null;
};
const FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
function zonedFormatter(
timeZone: string | undefined,
options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormat {
const key = `${timeZone ?? "local"}|${JSON.stringify(options)}`;
const cached = FORMATTER_CACHE.get(key);
if (cached) {
return cached;
}
let formatter: Intl.DateTimeFormat;
try {
formatter = new Intl.DateTimeFormat("en-US", {
...options,
...(timeZone ? { timeZone } : {}),
});
} catch {
formatter = new Intl.DateTimeFormat("en-US", options);
}
FORMATTER_CACHE.set(key, formatter);
return formatter;
}
function hassTimeZone(hass: HassLike | undefined): string | undefined {
const config = hass?.config;
if (!config || typeof config !== "object") {
return undefined;
}
const timeZone = (config as Record<string, unknown>).time_zone;
return typeof timeZone === "string" && timeZone.trim()
? timeZone.trim()
: undefined;
}
function formatDay(date: Date, timeZone: string | undefined): string {
return `${zonedFormatter(timeZone, { weekday: "long" }).format(date)}, ${zonedFormatter(timeZone, { month: "short", day: "numeric" }).format(date)}`;
}
function formatTime(date: Date, timeZone: string | undefined): string {
return zonedFormatter(timeZone, {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date);
}
const CONDITION_LABELS: Record<string, string> = {
"clear-night": "Clear",
cloudy: "Cloudy",
exceptional: "Clear",
fog: "Foggy",
hail: "Hail",
lightning: "Storms",
"lightning-rimmed": "Storms",
partlycloudy: "Partly cloudy",
pouring: "Heavy rain",
rainy: "Rain",
snowy: "Snow",
"snowy-rainy": "Sleet",
sunny: "Sunny",
windy: "Windy",
"windy-variant": "Windy",
};
const CONDITION_PHRASES: Record<string, string> = {
"clear-night": "Clear night skies",
cloudy: "Grey skies today",
exceptional: "A striking day",
fog: "Mist on the road",
hail: "Ice from the sky",
lightning: "Storms may roll in",
"lightning-rimmed": "Storms may roll in",
partlycloudy: "Sun between clouds",
pouring: "Heavy rain outside",
rainy: "Rain on the walls",
snowy: "Snow on the peaks",
"snowy-rainy": "Sleet may fall",
sunny: "Clear skies today",
windy: "A blustery day",
"windy-variant": "A blustery day",
};
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function asNumber(value: unknown, fallback: number): number {
if (value === null || value === undefined || value === "") {
return fallback;
}
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(100, Math.max(0, value));
}
function clampCount(value: number): number {
return Math.max(0, Math.round(value));
}
function titleCaseSlug(slug: string): string {
return slug
.split(/[-_]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function zoneOffsetMs(instant: number, timeZone: string): number {
const parts = zonedFormatter(timeZone, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(instant));
const read = (type: Intl.DateTimeFormatPartTypes): number => {
const part = parts.find((entry) => entry.type === type);
return part ? Number(part.value) : Number.NaN;
};
const asUtc = Date.UTC(
read("year"),
read("month") - 1,
read("day"),
read("hour"),
read("minute"),
read("second")
);
return Number.isFinite(asUtc) ? asUtc - instant : 0;
}
function parseIsoDate(value: unknown, timeZone?: string): Date | null {
if (typeof value !== "string") {
return null;
}
const parts = value.split("-");
if (parts.length !== 3) {
return null;
}
const [year, month, day] = parts.map((part) => Number(part));
if (!year || !month || !day) {
return null;
}
if (!timeZone) {
const local = new Date(year, month - 1, day);
return Number.isNaN(local.getTime()) ? null : local;
}
const utcMs = Date.UTC(year, month - 1, day);
const date = new Date(utcMs - zoneOffsetMs(utcMs, timeZone));
return Number.isNaN(date.getTime()) ? null : date;
}
function conditionLabel(condition: string): string {
const known = CONDITION_LABELS[condition];
if (known) {
return known;
}
return condition.charAt(0).toUpperCase() + condition.slice(1);
}
function conditionPhrase(condition: string, label: string): string {
return CONDITION_PHRASES[condition] ?? label;
}
const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)";
const HEXAGON_CLIP =
"polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
const boardStyles = css`
* {
box-sizing: border-box;
}
:host {
display: block;
height: 100%;
--nq-brand-gradient-h: linear-gradient(
90deg,
var(--nq-brand-blue),
var(--nq-brand-purple)
);
}
.board {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
height: 1080px;
padding: 0;
overflow: hidden;
background: var(--nq-p-parchment);
box-shadow: var(--nq-p-vignette);
font-family: var(--nq-p-font-body);
}
.frame {
position: absolute;
pointer-events: none;
}
.frame-outer {
inset: 26px;
border: var(--nq-p-frame-outer);
border-radius: 14px;
}
.frame-inner {
inset: 36px;
border: var(--nq-p-frame-inner);
border-radius: 8px;
}
.wordmark-row {
position: absolute;
top: 64px;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 26px;
width: 100%;
}
.rule {
flex: 1 1 0;
max-width: 340px;
height: 2px;
border-radius: 1px;
background: rgba(92, 62, 26, 0.35);
}
.d20 {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
clip-path: ${unsafeCSS(HEXAGON_CLIP)};
background: var(--nq-brand-gradient);
}
.d20-numeral {
font-family: var(--nq-p-font-heading);
font-size: 15px;
font-weight: 700;
line-height: 1;
color: #ffffff;
}
.wordmark {
font-family: var(--nq-p-font-display);
font-size: var(--nq-p-size-wordmark);
font-weight: 900;
line-height: 1.15;
background: var(--nq-brand-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.kicker {
position: absolute;
top: 154px;
left: 0;
right: 0;
font-family: var(--nq-p-font-heading);
font-size: 22px;
font-weight: 600;
letter-spacing: var(--nq-p-track-kicker);
text-transform: uppercase;
line-height: 1.2;
text-align: center;
color: var(--nq-p-ink-secondary);
}
.plates {
position: absolute;
top: 262px;
left: 110px;
right: 110px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 56px;
}
.plate {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 26px;
height: 600px;
padding: 44px 28px 40px;
border: 2px solid var(--nq-p-panel-border);
border-radius: var(--nq-p-radius-panel);
background: var(--nq-p-card-panel);
box-shadow: var(--nq-p-panel-shadow);
font: inherit;
}
button.plate {
width: 100%;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
}
button.plate:active {
transform: scale(0.98);
}
button.plate:focus-visible {
outline: 3px solid var(--nq-brand-purple);
outline-offset: 4px;
}
.plate.away {
border: 2px dashed var(--nq-p-panel-border);
background: linear-gradient(#f2ecdd, #e6dcc6);
box-shadow: none;
}
.crest {
position: relative;
flex: none;
width: 188px;
height: 214px;
padding: 4px;
clip-path: ${unsafeCSS(SHIELD_CLIP)};
background: rgba(255, 255, 255, 0.22);
}
.crest-face {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 70px;
clip-path: ${unsafeCSS(SHIELD_CLIP)};
background: var(--nq-p-crest);
}
.crest.away .crest-face {
background: var(--nq-p-crest-away);
}
.crest .initial {
font-family: var(--nq-p-font-display);
font-size: 82px;
font-weight: 700;
line-height: 1;
color: #ffffff;
}
.name {
font-family: var(--nq-p-font-heading);
font-size: var(--nq-p-size-name);
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.1;
color: var(--nq-p-ink);
}
.plate.away .name {
color: var(--nq-p-ink-away);
}
.pill {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 20px;
border-radius: var(--nq-p-radius-pill);
background: rgba(22, 120, 60, 0.35);
font-family: var(--nq-p-font-heading);
font-size: 22px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
color: #146b36;
}
.pill svg {
width: 22px;
height: 22px;
color: #157a3c;
}
.pill.away {
background: rgba(92, 62, 26, 0.16);
color: var(--nq-p-ink-away);
}
.pill.away svg {
color: var(--nq-p-ink-away);
}
.progress-line {
font-family: var(--nq-p-font-heading);
font-size: 24px;
font-weight: 700;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.bar {
width: 100%;
height: 16px;
border: 1px solid rgba(92, 62, 26, 0.35);
border-radius: var(--nq-p-radius-pill);
background: rgba(92, 62, 26, 0.18);
overflow: hidden;
}
.bar .fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--nq-brand-gradient-h);
}
.hint {
position: absolute;
top: 930px;
left: 0;
right: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-weight: 600;
line-height: 1.4;
text-align: center;
color: var(--nq-p-ink-secondary);
}
.spacer {
flex: 1 1 0;
}
.dock {
position: absolute;
left: 46px;
right: 46px;
bottom: 46px;
height: var(--nq-p-dock-height);
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 0 40px;
border-radius: 14px;
background: var(--nq-p-dock-bg);
pointer-events: none;
user-select: none;
color: var(--nq-p-dock-ink);
}
.dock svg {
flex: none;
width: 40px;
height: 40px;
color: var(--nq-p-dock-ink);
}
.dock .divider {
flex: none;
width: 1px;
height: 40px;
background: var(--nq-p-dock-divider);
}
.dock .temp {
font-family: var(--nq-p-font-body);
font-size: 38px;
font-weight: 800;
line-height: 1;
}
.dock .condition {
font-family: var(--nq-p-font-body);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-dock-ink-secondary);
}
.dock .date {
font-family: var(--nq-p-font-body);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-dock-ink-secondary);
}
.dock .clock {
font-family: var(--nq-p-font-body);
font-size: 38px;
font-weight: 800;
line-height: 1;
}
`;
const ICON_CHECK_CIRCLE = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="m9 12 2 2 4-4"></path>
</svg>`;
const ICON_TENT = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M3.5 21 12 3.5 20.5 21"></path>
<path d="M9 21l3-8 3 8"></path>
</svg>`;
const ICON_CLOUD_SUN = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 2v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="M20 12h2"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
<path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"></path>
<path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"></path>
</svg>`;
export class NestQuestPartyBoardCard extends LitElement {
static properties = {
hass: { attribute: false },
_config: { state: true },
_now: { state: true },
};
static styles = [unsafeCSS(panelTokens), boardStyles];
hass?: HassLike;
_config?: CardConfig;
_now = new Date();
private _clockTimer?: number;
setConfig(config: CardConfig): void {
if (!config || typeof config !== "object") {
throw new Error("Invalid configuration");
}
this._config = config;
}
getCardSize(): number {
return 22;
}
connectedCallback(): void {
super.connectedCallback();
this._now = new Date();
this._clockTimer = window.setInterval(() => {
this._now = new Date();
}, 1000);
}
disconnectedCallback(): void {
if (this._clockTimer !== undefined) {
window.clearInterval(this._clockTimer);
this._clockTimer = undefined;
}
super.disconnectedCallback();
}
render() {
return html`
<div class="board">
<div class="frame frame-outer"></div>
<div class="frame frame-inner"></div>
<div class="wordmark-row">
<span class="rule"></span>
<span class="d20" aria-hidden="true">
<span class="d20-numeral">20</span>
</span>
<span class="wordmark">NestQuest</span>
<span class="d20" aria-hidden="true">
<span class="d20-numeral">20</span>
</span>
<span class="rule"></span>
</div>
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
<div class="plates">
${this._plates().map((plate) => this._renderPlate(plate))}
</div>
<p class="hint">Tap your crest to open your Quest Log</p>
<div class="spacer"></div>
${this._renderDock()}
</div>
`;
}
private _timeZone(): string | undefined {
return hassTimeZone(this.hass);
}
private _state(entityId: string): StateObject | null {
const states = this.hass?.states as
| Record<string, StateObject>
| undefined;
const stateObj = states?.[entityId];
return stateObj && typeof stateObj === "object" ? stateObj : null;
}
private _childSlugs(): string[] {
if (!this._config) {
return [];
}
const order = this._config.child_order;
if (!Array.isArray(order)) {
return [];
}
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
}
private _plates(): ChildPlate[] {
return this._childSlugs().map((slug) => this._plate(slug));
}
private _plate(slug: string): ChildPlate {
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const doneSensor = this._state(
`sensor.nestquest_${slug}_quests_completed_today`
);
const pctSensor = this._state(
`sensor.nestquest_${slug}_completion_pct_today`
);
const presence = this._state(
`binary_sensor.nestquest_${slug}_present_today`
);
const dueAttrs = dueSensor?.attributes ?? {};
const due = clampCount(asNumber(dueSensor?.state, 0));
const completed = clampCount(asNumber(doneSensor?.state, 0));
const rawPct = pctSensor
? asNumber(pctSensor.state, Number.NaN)
: Number.NaN;
const pct = Number.isFinite(rawPct)
? clampPercent(rawPct)
: due > 0
? clampPercent((completed / due) * 100)
: 0;
let present = true;
const presentAttr = dueAttrs.present;
if (typeof presentAttr === "boolean") {
present = presentAttr;
} else if (presence) {
present = String(presence.state ?? "").trim().toLowerCase() === "on";
}
let name = "";
const dueName = dueAttrs.child_name;
if (typeof dueName === "string" && dueName.trim()) {
name = dueName.trim();
} else {
const presenceName = presence?.attributes?.child_name;
if (typeof presenceName === "string" && presenceName.trim()) {
name = presenceName.trim();
}
}
if (!name) {
name = titleCaseSlug(slug);
}
const returnsDate = parseIsoDate(
presence?.attributes?.next_present,
this._timeZone()
);
const returns = returnsDate
? `Returns ${formatDay(returnsDate, this._timeZone())}`
: null;
return {
slug,
name,
initial: (name.charAt(0) || "?").toUpperCase(),
present,
completed,
due,
pct,
returns,
};
}
private _renderPlate(plate: ChildPlate) {
if (plate.present) {
return html`
<button
class="plate"
type="button"
aria-label="Open ${plate.name}'s Quest Log"
@click=${() => this._openQuestLog(plate.slug)}
>
<span class="crest">
<span class="crest-face">
<span class="initial">${plate.initial}</span>
</span>
</span>
<span class="name">${plate.name}</span>
<span class="pill">
${ICON_CHECK_CIRCLE}
<span>Home today</span>
</span>
<span class="progress-line">
${plate.completed} of ${plate.due} quests claimed
</span>
<span class="bar">
<span class="fill" style="width: ${plate.pct}%"></span>
</span>
</button>
`;
}
return html`
<div class="plate away">
<span class="crest away">
<span class="crest-face">
<span class="initial">${plate.initial}</span>
</span>
</span>
<span class="name">${plate.name}</span>
<span class="pill away">
${ICON_TENT}
<span>On travels</span>
</span>
<span class="progress-line">${plate.returns ?? "Returns —"}</span>
<span class="bar"></span>
</div>
`;
}
private _openQuestLog(slug: string): void {
const path = asString(this._config?.quest_log_path);
if (!path) {
return;
}
window.history.pushState(null, "", `${path.replace(/\/+$/, "")}/${slug}`);
window.dispatchEvent(new Event("location-changed"));
}
private _dockWeather(): DockWeather | null {
const entityId = asString(this._config?.weather_entity);
if (!entityId) {
return null;
}
const stateObj = this._state(entityId);
if (!stateObj) {
return null;
}
const condition = String(stateObj.state ?? "").trim();
if (!condition || condition === "unavailable" || condition === "unknown") {
return null;
}
const attrs = stateObj.attributes ?? {};
let high: number | null = null;
let low: number | null = null;
const forecast = attrs.forecast;
if (Array.isArray(forecast) && forecast.length > 0) {
const entry = forecast[0];
if (entry && typeof entry === "object") {
const typed = entry as Record<string, unknown>;
high = this._optionalNumber(typed.temperature);
low = this._optionalNumber(typed.templow);
}
}
return {
condition,
temperature: this._optionalNumber(attrs.temperature),
high,
low,
};
}
private _optionalNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
private _renderDock() {
const weather = this._dockWeather();
if (!weather) {
return html`
<div class="dock">
<span class="date">${formatDay(this._now, this._timeZone())}</span>
<span class="divider"></span>
<span class="clock">${formatTime(this._now, this._timeZone())}</span>
</div>
`;
}
const label = conditionLabel(weather.condition);
const phrase = conditionPhrase(weather.condition, label);
const temperature =
weather.temperature === null
? nothing
: html`<span class="temp">${Math.round(weather.temperature)}°</span>`;
const hiLo =
weather.high === null || weather.low === null
? null
: `${Math.round(weather.high)}° / ${Math.round(weather.low)}°`;
return html`
<div class="dock">
${ICON_CLOUD_SUN}
${temperature}
<span class="divider"></span>
<span class="condition">
${hiLo === null ? label : `${label} · ${hiLo}`}
</span>
<span class="divider"></span>
<span class="condition">${phrase}</span>
</div>
`;
}
}
customElements.define("nestquest-party-board-card", NestQuestPartyBoardCard);
registerCustomCard({
type: "nestquest-party-board-card",
name: "NestQuest Party Board",
description: "Kids' attract screen for present and away adventurers.",
});
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,
},
},
},
});
+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:
+157
View File
@@ -0,0 +1,157 @@
"""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
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}"