Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1796cc73f | ||
|
|
fba8f595d6 | ||
|
|
729bcad62f | ||
|
|
263f5c2877 | ||
|
|
0dff75a682 | ||
|
|
39712e668b | ||
|
|
577c26f3cb | ||
|
|
0dde7b886e | ||
|
|
bc04b45a66 | ||
|
|
1911b2bf6e | ||
|
|
89f517b3ad | ||
|
|
edca25affa | ||
|
|
7c62c50526 |
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -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
@@ -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,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}"
|
||||
Reference in New Issue
Block a user