Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4942917fa | ||
|
|
290a77441c | ||
|
|
8801cdb0aa | ||
|
|
4cb07fbca4 | ||
|
|
030ba83b14 | ||
|
|
a2c027deca |
@@ -186,6 +186,14 @@ npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Kids panel layout
|
||||
|
||||
The Lovelace panel cards are designed for the wall display's confirmed
|
||||
resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom
|
||||
dock overlays the NestQuest weather dock strip until it auto-hides, so
|
||||
no tappable NestQuest control sits in that strip (the dock carries
|
||||
`pointer-events: none`). Keep it that way when editing the cards.
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run the test suite with `pytest`:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,7 @@ child_order: [declan, jordyn, chloe]
|
||||
# kids' panel, view 2
|
||||
type: custom:nestquest-quest-log-card
|
||||
board_path: /nestquest/board
|
||||
weather_entity: weather.home # optional; dock falls back to date/time
|
||||
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
|
||||
confirm_timeout_seconds: 15
|
||||
complete_screen_seconds: 12
|
||||
|
||||
@@ -8,6 +8,20 @@ type StateObject = {
|
||||
attributes?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type HassConnection = {
|
||||
subscribeEvents: (
|
||||
callback: (event: unknown) => void,
|
||||
eventType: string
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
|
||||
const NESTQUEST_EVENT_TYPES = [
|
||||
"nestquest_quest_completed",
|
||||
"nestquest_quest_uncompleted",
|
||||
"nestquest_quest_missed",
|
||||
"nestquest_child_day_complete",
|
||||
];
|
||||
|
||||
type ChildPlate = {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -715,6 +729,10 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
_config?: CardConfig;
|
||||
_now = new Date();
|
||||
private _clockTimer?: number;
|
||||
private _unsubs: Array<() => void> = [];
|
||||
private _subscribed = false;
|
||||
private _subGeneration = 0;
|
||||
private _retryTimer?: number;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
@@ -723,19 +741,101 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _hassConnection(): HassConnection | null {
|
||||
const connection = (this.hass as { connection?: unknown } | undefined)
|
||||
?.connection;
|
||||
if (
|
||||
!connection ||
|
||||
typeof connection !== "object" ||
|
||||
typeof (connection as HassConnection).subscribeEvents !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return connection as HassConnection;
|
||||
}
|
||||
|
||||
private _subscribeLive(): void {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this._subscribed) {
|
||||
return;
|
||||
}
|
||||
const connection = this._hassConnection();
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
this._subscribed = true;
|
||||
const token = ++this._subGeneration;
|
||||
const track = (unsubPromise: Promise<() => void>): void => {
|
||||
unsubPromise
|
||||
.then((unsub) => {
|
||||
if (this._subscribed && token === this._subGeneration) {
|
||||
this._unsubs.push(unsub);
|
||||
} else {
|
||||
unsub();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (token === this._subGeneration) {
|
||||
this._unsubscribeLive();
|
||||
this._armSubscribeRetry();
|
||||
}
|
||||
});
|
||||
};
|
||||
for (const eventType of NESTQUEST_EVENT_TYPES) {
|
||||
track(
|
||||
connection.subscribeEvents(() => this.requestUpdate(), eventType)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _armSubscribeRetry(): void {
|
||||
if (this._retryTimer !== undefined) {
|
||||
return;
|
||||
}
|
||||
this._retryTimer = window.setTimeout(() => {
|
||||
this._retryTimer = undefined;
|
||||
this._subscribeLive();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private _clearSubscribeRetry(): void {
|
||||
if (this._retryTimer !== undefined) {
|
||||
window.clearTimeout(this._retryTimer);
|
||||
this._retryTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _unsubscribeLive(): void {
|
||||
this._clearSubscribeRetry();
|
||||
this._subGeneration++;
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
this._subscribed = false;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 22;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._subscribeLive();
|
||||
this._now = new Date();
|
||||
this._clockTimer = window.setInterval(() => {
|
||||
this._now = new Date();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this._subscribeLive();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._unsubscribeLive();
|
||||
if (this._clockTimer !== undefined) {
|
||||
window.clearInterval(this._clockTimer);
|
||||
this._clockTimer = undefined;
|
||||
|
||||
+547
-13
@@ -4,11 +4,29 @@ import panelTokens from "../../../custom_components/nestquest/www/nestquest-pane
|
||||
|
||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||
|
||||
type QuestLogCardConfig = CardConfig & {
|
||||
weather_entity?: string;
|
||||
};
|
||||
|
||||
type StateObject = {
|
||||
state: unknown;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type HassConnection = {
|
||||
subscribeEvents: (
|
||||
callback: (event: unknown) => void,
|
||||
eventType: string
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
|
||||
const NESTQUEST_EVENT_TYPES = [
|
||||
"nestquest_quest_completed",
|
||||
"nestquest_quest_uncompleted",
|
||||
"nestquest_quest_missed",
|
||||
"nestquest_child_day_complete",
|
||||
];
|
||||
|
||||
type PanelInstance = {
|
||||
id: number;
|
||||
child_id: number | null;
|
||||
@@ -19,6 +37,7 @@ type PanelInstance = {
|
||||
state: string;
|
||||
overdue: boolean;
|
||||
completed_at: string | null;
|
||||
on_time: boolean | null;
|
||||
};
|
||||
|
||||
type WindowDef = {
|
||||
@@ -109,6 +128,14 @@ function formatInstant(value: string, timeZone: string | undefined): string {
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
function formatTime(date: Date, timeZone: string | undefined): string {
|
||||
return zonedFormatter(timeZone, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatWallClock(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -227,9 +254,65 @@ function toPanelInstance(value: unknown): PanelInstance | null {
|
||||
state,
|
||||
overdue: row.overdue === true,
|
||||
completed_at: asString(row.completed_at) || null,
|
||||
on_time: row.on_time === true ? true : row.on_time === false ? false : null,
|
||||
};
|
||||
}
|
||||
|
||||
type DockWeather = {
|
||||
condition: string;
|
||||
temperature: number | null;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
};
|
||||
|
||||
const CONDITION_LABELS: Record<string, string> = {
|
||||
"clear-night": "Clear",
|
||||
cloudy: "Cloudy",
|
||||
exceptional: "Clear",
|
||||
fog: "Foggy",
|
||||
hail: "Hail",
|
||||
lightning: "Storms",
|
||||
"lightning-rainy": "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-rainy": "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 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%)";
|
||||
@@ -919,6 +1002,170 @@ const logStyles = css`
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.complete-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 96px 62px 130px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.complete-seal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
border-radius: var(--nq-p-radius-pill);
|
||||
background: var(--nq-p-seal);
|
||||
box-shadow: 0 10px 24px rgba(60, 10, 20, 0.42);
|
||||
color: rgba(255, 235, 235, 0.95);
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
|
||||
.complete-seal svg {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
}
|
||||
|
||||
.complete-title {
|
||||
margin: 28px 0 0;
|
||||
font-family: var(--nq-p-font-display);
|
||||
font-size: 86px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
background: var(--nq-brand-gradient-h);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.complete-sub {
|
||||
margin: 16px 0 0;
|
||||
max-width: 1000px;
|
||||
font-family: var(--nq-p-font-body);
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--nq-p-ink-secondary);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.complete-countdown {
|
||||
margin: 12px 0 0;
|
||||
font-family: var(--nq-p-font-body);
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--nq-p-ink-secondary);
|
||||
}
|
||||
|
||||
.complete-stats {
|
||||
position: absolute;
|
||||
top: 540px;
|
||||
left: 200px;
|
||||
right: 200px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.complete-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px 16px;
|
||||
border: 2px solid var(--nq-p-panel-border);
|
||||
border-radius: 16px;
|
||||
background: var(--nq-p-card-panel);
|
||||
box-shadow: var(--nq-p-panel-shadow);
|
||||
}
|
||||
|
||||
.complete-stat .numeral {
|
||||
font-family: var(--nq-p-font-heading);
|
||||
font-size: 58px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color: var(--nq-p-ink);
|
||||
}
|
||||
|
||||
.complete-stat .label {
|
||||
font-family: var(--nq-p-font-heading);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
color: var(--nq-p-ink-secondary);
|
||||
}
|
||||
|
||||
.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 = html`<svg
|
||||
@@ -1064,6 +1311,19 @@ const ICON_CLOUD_OFF = html`<svg
|
||||
<path d="m2 2 20 20"></path>
|
||||
</svg>`;
|
||||
|
||||
const ICON_CLOUD_MOON = 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="M13 16a3 3 0 1 1 0 6H7a5 5 0 1 1 4.9-6Z"></path>
|
||||
<path d="M10.1 9A6 6 0 0 1 16 4a4.5 4.5 0 0 0 8.9 4.2v.1a6 6 0 0 1-5.2 5.7"></path>
|
||||
</svg>`;
|
||||
|
||||
const LOG_NOTICE_NO_ADVENTURER: LogNotice = {
|
||||
icon: ICON_COMPASS,
|
||||
headline: "No adventurer chosen",
|
||||
@@ -1096,36 +1356,120 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
_confirm: { state: true },
|
||||
_optimistic: { state: true },
|
||||
_toast: { state: true },
|
||||
_countdown: { state: true },
|
||||
};
|
||||
|
||||
static styles = [unsafeCSS(panelTokens), logStyles];
|
||||
|
||||
hass?: HassLike;
|
||||
_config?: CardConfig;
|
||||
_config?: QuestLogCardConfig;
|
||||
_now = new Date();
|
||||
_confirm: number | null = null;
|
||||
/** instanceId → the completion instant stamped before the service has
|
||||
* answered; the seal shows immediately and reverts on failure. */
|
||||
_optimistic = new Map<number, string>();
|
||||
_toast: string | null = null;
|
||||
_countdown: number | null = null;
|
||||
private _clockTimer?: number;
|
||||
private _idleTimer?: number;
|
||||
private _confirmTimer?: number;
|
||||
private _toastTimer?: number;
|
||||
private _completeTimer?: number;
|
||||
private _unsubs: Array<() => void> = [];
|
||||
private _subscribed = false;
|
||||
private _subGeneration = 0;
|
||||
private _retryTimer?: number;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
setConfig(config: QuestLogCardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _hassConnection(): HassConnection | null {
|
||||
const connection = (this.hass as { connection?: unknown } | undefined)
|
||||
?.connection;
|
||||
if (
|
||||
!connection ||
|
||||
typeof connection !== "object" ||
|
||||
typeof (connection as HassConnection).subscribeEvents !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return connection as HassConnection;
|
||||
}
|
||||
|
||||
private _subscribeLive(): void {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this._subscribed) {
|
||||
return;
|
||||
}
|
||||
const connection = this._hassConnection();
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
this._subscribed = true;
|
||||
const token = ++this._subGeneration;
|
||||
const track = (unsubPromise: Promise<() => void>): void => {
|
||||
unsubPromise
|
||||
.then((unsub) => {
|
||||
if (this._subscribed && token === this._subGeneration) {
|
||||
this._unsubs.push(unsub);
|
||||
} else {
|
||||
unsub();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (token === this._subGeneration) {
|
||||
this._unsubscribeLive();
|
||||
this._armSubscribeRetry();
|
||||
}
|
||||
});
|
||||
};
|
||||
for (const eventType of NESTQUEST_EVENT_TYPES) {
|
||||
track(
|
||||
connection.subscribeEvents(() => this.requestUpdate(), eventType)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _armSubscribeRetry(): void {
|
||||
if (this._retryTimer !== undefined) {
|
||||
return;
|
||||
}
|
||||
this._retryTimer = window.setTimeout(() => {
|
||||
this._retryTimer = undefined;
|
||||
this._subscribeLive();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private _clearSubscribeRetry(): void {
|
||||
if (this._retryTimer !== undefined) {
|
||||
window.clearTimeout(this._retryTimer);
|
||||
this._retryTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _unsubscribeLive(): void {
|
||||
this._clearSubscribeRetry();
|
||||
this._subGeneration++;
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
this._subscribed = false;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 22;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._subscribeLive();
|
||||
this._now = new Date();
|
||||
this._clockTimer = window.setInterval(() => {
|
||||
this._now = new Date();
|
||||
@@ -1134,13 +1478,32 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
window.addEventListener("touchstart", this._onActivity, true);
|
||||
window.addEventListener("keydown", this._onActivity, true);
|
||||
this._armIdle();
|
||||
if (this._logView().kind === "complete-day") {
|
||||
this._armCompleteTimer();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this._subscribeLive();
|
||||
if (this._logView().kind === "complete-day") {
|
||||
this._clearIdle();
|
||||
this._armCompleteTimer();
|
||||
} else {
|
||||
this._clearCompleteTimer();
|
||||
this._countdown = null;
|
||||
if (this._idleTimer === undefined) {
|
||||
this._armIdle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._unsubscribeLive();
|
||||
this._stopClock();
|
||||
this._clearIdle();
|
||||
this._clearConfirmTimer();
|
||||
this._clearToastTimer();
|
||||
this._clearCompleteTimer();
|
||||
window.removeEventListener("pointerdown", this._onActivity, true);
|
||||
window.removeEventListener("touchstart", this._onActivity, true);
|
||||
window.removeEventListener("keydown", this._onActivity, true);
|
||||
@@ -1206,20 +1569,39 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
case "complete-day":
|
||||
case "complete-day": {
|
||||
const stats = this._completeStats();
|
||||
const remaining = this._countdown ?? this._completeSeconds();
|
||||
return html`
|
||||
${this._renderHeader(view)}
|
||||
<div class="notice-wrap">
|
||||
<div class="notice" role="status">
|
||||
<span class="notice-seal">${ICON_CHECK}</span>
|
||||
<span class="notice-headline">Quest complete</span>
|
||||
<p class="notice-body">
|
||||
${`Every quest is claimed, ${view.name}. The seal is set for today.`}
|
||||
</p>
|
||||
<p class="notice-body">Returning to The Party shortly.</p>
|
||||
<div class="complete-screen" role="status">
|
||||
<span class="complete-seal" aria-hidden="true">${ICON_CHECK}</span>
|
||||
<h1 class="complete-title">Quest complete</h1>
|
||||
<p class="complete-sub">${this._completeSub(view.name)}</p>
|
||||
<p class="complete-countdown">
|
||||
Returning to The Party in ${remaining} seconds
|
||||
</p>
|
||||
<div class="complete-stats">
|
||||
<div class="complete-stat">
|
||||
<span class="numeral">${stats.claimed}</span>
|
||||
<span class="label">Quests claimed</span>
|
||||
</div>
|
||||
<div class="complete-stat">
|
||||
<span class="numeral">${stats.onTime}</span>
|
||||
<span class="label">On time</span>
|
||||
</div>
|
||||
<div class="complete-stat">
|
||||
<span class="numeral">${stats.late}</span>
|
||||
<span class="label">Late</span>
|
||||
</div>
|
||||
<div class="complete-stat">
|
||||
<span class="numeral">${stats.party}</span>
|
||||
<span class="label">The Party</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${this._renderDock()}
|
||||
`;
|
||||
}
|
||||
default:
|
||||
return html`
|
||||
${this._renderHeader(view)}
|
||||
@@ -1248,6 +1630,10 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
if (!this._childPresent(slug)) {
|
||||
return { kind: "away", name, returns: this._awayReturns(slug) };
|
||||
}
|
||||
const allDone = this._state(`binary_sensor.nestquest_${slug}_all_done`);
|
||||
if (allDone && String(allDone.state ?? "").trim().toLowerCase() === "on") {
|
||||
return { kind: "complete-day", name };
|
||||
}
|
||||
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
|
||||
const due = clampCount(asNumber(dueSensor?.state, 0));
|
||||
if (due === 0) {
|
||||
@@ -1342,6 +1728,11 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
return seconds > 0 ? seconds : 15;
|
||||
}
|
||||
|
||||
private _completeSeconds(): number {
|
||||
const seconds = asNumber(this._config?.complete_screen_seconds, 12);
|
||||
return seconds > 0 ? seconds : 12;
|
||||
}
|
||||
|
||||
private _childName(slug: string): string | null {
|
||||
if (!slug) {
|
||||
return null;
|
||||
@@ -1435,6 +1826,41 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
return { completed, due };
|
||||
}
|
||||
|
||||
private _completeStats(): {
|
||||
claimed: number;
|
||||
onTime: number;
|
||||
late: number;
|
||||
party: string;
|
||||
} {
|
||||
const claimed = this._childCounts(this._childSlug()).completed;
|
||||
const onTime = this._instances().filter(
|
||||
(instance) => instance.on_time === true
|
||||
).length;
|
||||
const late = clampCount(claimed - onTime);
|
||||
const party = this._partyCounts();
|
||||
return {
|
||||
claimed,
|
||||
onTime,
|
||||
late,
|
||||
party: `${party.completed}/${party.due}`,
|
||||
};
|
||||
}
|
||||
|
||||
private _completeSub(name: string): string {
|
||||
const date = formatDay(this._now, this._timeZone());
|
||||
const { claimed, onTime, late } = this._completeStats();
|
||||
if (claimed === 0) {
|
||||
return `${name} sealed the day on ${date}.`;
|
||||
}
|
||||
if (late === 0) {
|
||||
return `${name} claimed every quest on ${date} — all on time.`;
|
||||
}
|
||||
if (onTime === 0) {
|
||||
return `${name} claimed every quest on ${date} — all late.`;
|
||||
}
|
||||
return `${name} claimed every quest on ${date} — ${onTime} on time, ${late} late.`;
|
||||
}
|
||||
|
||||
private _otherChildren(): OtherChild[] {
|
||||
const states = this.hass?.states as
|
||||
| Record<string, StateObject>
|
||||
@@ -1897,6 +2323,112 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _armCompleteTimer(): void {
|
||||
if (this._completeTimer !== undefined) {
|
||||
return;
|
||||
}
|
||||
if (this._countdown === null) {
|
||||
this._countdown = this._completeSeconds();
|
||||
}
|
||||
if (this._countdown <= 0) {
|
||||
this._returnToBoard();
|
||||
return;
|
||||
}
|
||||
this._completeTimer = window.setInterval(() => {
|
||||
const remaining = (this._countdown ?? 1) - 1;
|
||||
if (remaining <= 0) {
|
||||
this._countdown = 0;
|
||||
this._clearCompleteTimer();
|
||||
this._returnToBoard();
|
||||
return;
|
||||
}
|
||||
this._countdown = remaining;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private _clearCompleteTimer(): void {
|
||||
if (this._completeTimer !== undefined) {
|
||||
window.clearInterval(this._completeTimer);
|
||||
this._completeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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 _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 _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_MOON}
|
||||
${temperature}
|
||||
<span class="divider"></span>
|
||||
<span class="condition">
|
||||
${hiLo === null ? label : `${label} · ${hiLo}`}
|
||||
</span>
|
||||
<span class="divider"></span>
|
||||
<span class="condition">${phrase}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _returnToBoard(): void {
|
||||
const path = this._boardPath();
|
||||
if (!path) {
|
||||
@@ -1914,7 +2446,9 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
}
|
||||
|
||||
private _onActivity = (): void => {
|
||||
this._armIdle();
|
||||
if (this._logView().kind !== "complete-day") {
|
||||
this._armIdle();
|
||||
}
|
||||
if (this._confirm !== null) {
|
||||
this._armConfirmTimer();
|
||||
}
|
||||
|
||||
@@ -93,6 +93,14 @@ def test_readme_documents_npm_build() -> None:
|
||||
assert "nestquest-cards.js" in readme
|
||||
|
||||
|
||||
def test_readme_documents_panel_resolution_and_touchhub_dock() -> None:
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert "1920×1080" in readme
|
||||
assert "TouchHub" in readme
|
||||
assert "dock" in readme
|
||||
assert "pointer-events: none" in readme
|
||||
|
||||
|
||||
WOFF2_FILES = (
|
||||
"nunito-latin-400-normal.woff2",
|
||||
"nunito-latin-600-normal.woff2",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Source-level tests for the kid panel cards.
|
||||
|
||||
The panel cards are Lit/TypeScript modules bundled by Vite; these tests assert
|
||||
the contract by reading the card sources and the built bundle, in the same
|
||||
spirit as test_frontend.py. They cover the states decided in
|
||||
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
|
||||
bundles against ever gaining a parent-only service call.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards"
|
||||
PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
|
||||
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
|
||||
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
|
||||
|
||||
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
|
||||
|
||||
# Parent-only NestQuest services. The party board and quest log are the
|
||||
# kid-facing panel: they may complete a quest for the tapped child, and nothing
|
||||
# else. Any of these names appearing in a panel source or bundle means an admin
|
||||
# control has leaked into the kid panel.
|
||||
ADMIN_ONLY_SERVICES = (
|
||||
"uncomplete_quest",
|
||||
"create_quest_definition",
|
||||
"update_quest_definition",
|
||||
"set_quest_definition_active",
|
||||
"set_presence_pattern",
|
||||
"create_presence_override",
|
||||
"delete_presence_override",
|
||||
"export_history_csv",
|
||||
"manage_child",
|
||||
"regenerate",
|
||||
)
|
||||
|
||||
WINDOW_KEYS = ("morning", "afternoon", "evening")
|
||||
WINDOW_NAMES = ("Morning", "Afternoon", "Evening")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_party_board_renders_present_and_away_plates() -> None:
|
||||
source = _read(PARTY_BOARD)
|
||||
assert "nestquest-party-board-card" in source
|
||||
# Present adventurers get the tappable crest plate.
|
||||
assert "button.plate" in source
|
||||
assert 'class="plate"' in source
|
||||
assert "Home today" in source
|
||||
assert "aria-label=\"Open ${plate.name}'s Quest Log\"" in source
|
||||
# Away adventurers get the dashed On travels plate, not a tappable one.
|
||||
assert ".plate.away" in source
|
||||
assert 'class="plate away"' in source
|
||||
assert "On travels" in source
|
||||
assert "next_present" in source
|
||||
assert "Returns ${formatDay(returnsDate" in source
|
||||
|
||||
|
||||
def test_quest_log_renders_three_window_columns() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
for key, name in zip(WINDOW_KEYS, WINDOW_NAMES):
|
||||
assert f'key: "{key}"' in source, key
|
||||
assert f'name: "{name}"' in source, name
|
||||
assert "grid-template-columns: repeat(3, 1fr)" in source
|
||||
assert 'class="column-head"' in source
|
||||
assert 'class="column-names"' in source
|
||||
assert 'class="column-range"' in source
|
||||
|
||||
|
||||
def test_empty_window_still_renders_its_column_head() -> None:
|
||||
"""A window with no quests renders the head alone (PANEL-SPEC §3.1)."""
|
||||
source = _read(QUEST_LOG)
|
||||
column = source[source.index("private _renderColumn(windowDef: WindowDef)") :]
|
||||
column = column[: column.index("private _renderQuest(")]
|
||||
# The head is emitted unconditionally, before the stack is built.
|
||||
assert 'class="column-head"' in column
|
||||
assert "sealed.length}/${instances.length}" in column
|
||||
# There is no early return that would drop the head on an empty window.
|
||||
assert "instances.length === 0" not in column
|
||||
assert "return nothing" not in column
|
||||
|
||||
|
||||
def test_complete_quest_is_optimistic_with_rollback() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
assert "_optimistic" in source
|
||||
assert "new Date().toISOString()" in source
|
||||
assert ".set(instance.id, stampedAt)" in source
|
||||
# The overlay is applied to the rendered instance before the service answers.
|
||||
assert 'instance.state = "completed"' in source
|
||||
# A rejected call reverts the overlay and raises the parchment toast.
|
||||
assert "reverted.delete(instance.id)" in source
|
||||
assert "NestQuest could not set the seal just now. Please try again." in source
|
||||
assert "_showToast(" in source
|
||||
assert "await this._callCompleteQuest(instance)" in source
|
||||
assert '"nestquest", "complete_quest"' in source
|
||||
|
||||
|
||||
def test_idle_return_to_board() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
assert "idle_return_seconds" in source
|
||||
assert "_armIdle" in source
|
||||
assert "_clearIdle" in source
|
||||
assert "this._idleSeconds() * 1000" in source
|
||||
assert "board_path" in source
|
||||
assert "_returnToBoard" in source
|
||||
assert 'window.dispatchEvent(new Event("location-changed"))' in source
|
||||
|
||||
|
||||
def test_decided_empty_and_error_states_are_present() -> None:
|
||||
log = _read(QUEST_LOG)
|
||||
board = _read(PARTY_BOARD)
|
||||
for kind in (
|
||||
"no-adventurer",
|
||||
"not-set-up",
|
||||
"unreachable",
|
||||
"away",
|
||||
"empty-day",
|
||||
"complete-day",
|
||||
):
|
||||
assert f'kind: "{kind}"' in log, kind
|
||||
assert "PANEL-EMPTY-STATES" in log
|
||||
assert "No adventurer chosen" in log
|
||||
assert "NestQuest is not set up yet" in log
|
||||
assert "The records cannot be reached" in log
|
||||
assert "No quests today" in log
|
||||
assert 'role="status"' in log
|
||||
# The board distinguishes the same setup/unreachable cases at board level.
|
||||
assert "NestQuest is not set up yet" in board
|
||||
assert "The records cannot be reached" in board
|
||||
assert "BOARD_NOTICE_NOT_SET_UP" in board
|
||||
assert "BOARD_NOTICE_UNREACHABLE" in board
|
||||
|
||||
|
||||
def test_quest_complete_trigger() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
assert "binary_sensor.nestquest_${slug}_all_done" in source
|
||||
assert "nestquest_child_day_complete" in source
|
||||
assert "NESTQUEST_EVENT_TYPES" in source
|
||||
assert 'class="complete-screen"' in source
|
||||
assert "Quest complete" in source
|
||||
assert "Returning to The Party in" in source
|
||||
assert "_armCompleteTimer" in source
|
||||
assert "complete_screen_seconds" in source
|
||||
|
||||
|
||||
def test_panel_cards_reference_no_admin_only_services() -> None:
|
||||
# Only the kid panel sources are scanned: the bundle also contains the
|
||||
# sibling admin card, so its parent-only service names are expected there.
|
||||
for path in PANEL_SOURCES:
|
||||
text = _read(path)
|
||||
for service in ADMIN_ONLY_SERVICES:
|
||||
assert service not in text, f"{path}: {service}"
|
||||
# The bundle is still built and carries both panel card custom elements.
|
||||
assert BUNDLE_PATH.is_file()
|
||||
bundle = _read(BUNDLE_PATH)
|
||||
assert "nestquest-party-board-card" in bundle
|
||||
assert "nestquest-quest-log-card" in bundle
|
||||
Reference in New Issue
Block a user