Add the party board card (1a) read-only
Add the party board card (1a) read-only
This commit was merged in pull request #125.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,604 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
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)];
|
||||
static styles = [unsafeCSS(panelTokens), boardStyles];
|
||||
|
||||
hass?: HassLike;
|
||||
_config?: CardConfig;
|
||||
_now = new Date();
|
||||
private _clockTimer?: number;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
@@ -22,11 +608,279 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 6;
|
||||
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>NestQuest Party Board</div>`;
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user