Files
NestQuest/frontend/src/cards/quest-log.ts
T

1448 lines
36 KiB
TypeScript
Raw Normal View History

2026-09-20 13:54:14 -04:00
import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit";
import { repeat } from "lit/directives/repeat.js";
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
2026-09-20 13:54:14 -04:00
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
};
type PanelInstance = {
id: number;
title: string;
icon: string | null;
window: string;
due_time: string | null;
state: string;
overdue: boolean;
completed_at: string | null;
};
type WindowDef = {
key: string;
name: string;
range: string;
icon: TemplateResult;
};
type OtherChild = {
slug: string;
name: string;
present: boolean;
due: number;
completed: number;
returnsWeekday: string | 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 formatWeekday(date: Date, timeZone: string | undefined): string {
return zonedFormatter(timeZone, { weekday: "long" }).format(date);
}
function formatInstant(value: string, timeZone: string | undefined): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return "";
}
return zonedFormatter(timeZone, {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(parsed);
}
function formatWallClock(value: string | null): string | null {
if (!value) {
return null;
}
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
if (!match) {
return null;
}
const hours = Number(match[1]);
if (hours > 23) {
return null;
}
const suffix = hours >= 12 ? "PM" : "AM";
const hour12 = hours % 12 === 0 ? 12 : hours % 12;
return `${hour12}:${match[2]} ${suffix}`;
}
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 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 childSlugFromPath(): string {
const segments = window.location.pathname.split("/").filter(Boolean);
return segments.length > 0
? segments[segments.length - 1].toLowerCase()
: "";
}
function toPanelInstance(value: unknown): PanelInstance | null {
if (!value || typeof value !== "object") {
return null;
}
const row = value as Record<string, unknown>;
const state = asString(row.state).toLowerCase();
if (state !== "open" && state !== "completed") {
return null;
}
const id = asNumber(row.id, 0);
if (!id) {
return null;
}
return {
id,
title: asString(row.title) || "Quest",
icon: asString(row.icon) || null,
window: asString(row.window).toLowerCase(),
due_time: asString(row.due_time) || null,
state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null,
};
}
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 SEAL_ROTATIONS = [-9, 6, -4];
const logStyles = 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;
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;
}
.header {
position: absolute;
top: 56px;
left: var(--nq-p-page-inset);
right: var(--nq-p-page-inset);
display: flex;
align-items: center;
gap: 28px;
}
.crest {
position: relative;
flex: none;
width: 96px;
height: 110px;
padding: 3px;
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: 32px;
clip-path: ${unsafeCSS(SHIELD_CLIP)};
background: var(--nq-p-crest);
}
.crest .initial {
font-family: var(--nq-p-font-display);
font-size: 44px;
font-weight: 700;
line-height: 1;
color: #ffffff;
}
.titles {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.title {
margin: 0;
font-family: var(--nq-p-font-display);
font-size: var(--nq-p-size-title);
font-weight: 700;
line-height: 1.1;
color: var(--nq-p-ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
margin: 0;
font-family: var(--nq-p-font-heading);
font-size: 20px;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.remaining {
margin-left: auto;
flex: none;
display: flex;
align-items: center;
gap: 20px;
padding: 18px 28px;
border: 2px solid var(--nq-p-panel-border);
border-radius: 14px;
background: var(--nq-p-card-panel);
box-shadow: var(--nq-p-panel-shadow);
}
.d20 {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
clip-path: ${unsafeCSS(HEXAGON_CLIP)};
background: var(--nq-brand-gradient);
}
.d20-numeral {
font-family: var(--nq-p-font-heading);
font-size: 19px;
font-weight: 700;
line-height: 1;
color: #ffffff;
}
.remaining-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.remaining-count {
font-family: var(--nq-p-font-heading);
font-size: 28px;
font-weight: 700;
line-height: 1.1;
color: var(--nq-p-ink);
}
.party-line {
font-family: var(--nq-p-font-body);
font-size: var(--nq-p-size-body);
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.columns {
position: absolute;
top: 236px;
left: var(--nq-p-page-inset);
right: var(--nq-p-page-inset);
bottom: 130px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 34px;
}
.column {
display: flex;
flex-direction: column;
min-height: 0;
}
.column-head {
flex: none;
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 14px;
border-bottom: var(--nq-p-rule);
}
.column-head svg {
flex: none;
width: 34px;
height: 34px;
color: var(--nq-p-ink-secondary);
}
.column-names {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.column-name {
font-family: var(--nq-p-font-heading);
font-size: var(--nq-p-size-section);
font-weight: 700;
line-height: 1.1;
color: var(--nq-p-ink);
}
.column-range {
font-family: var(--nq-p-font-heading);
font-size: var(--nq-p-size-label);
font-weight: 600;
letter-spacing: var(--nq-p-track-label);
text-transform: uppercase;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.column-count {
margin-left: auto;
flex: none;
font-family: var(--nq-p-font-heading);
font-size: 26px;
font-weight: 700;
line-height: 1.1;
color: var(--nq-p-ink-secondary);
}
.stack {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: visible;
gap: var(--nq-p-quest-gap);
margin-top: 22px;
}
.quest {
position: relative;
display: flex;
align-items: center;
gap: 22px;
min-height: var(--nq-p-quest-min-height);
padding: var(--nq-p-quest-pad);
border: 1px solid var(--nq-p-card-border);
border-radius: var(--nq-p-radius-card);
background: var(--nq-p-card-open);
box-shadow: var(--nq-p-card-shadow);
-webkit-tap-highlight-color: transparent;
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
}
.quest.tappable {
cursor: pointer;
}
.quest.tappable:active {
transform: scale(0.98);
}
.quest.sealed {
background: var(--nq-p-card-done);
opacity: 0.82;
box-shadow: none;
}
.tile {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--nq-p-tile-size);
height: var(--nq-p-tile-size);
border-radius: 10px;
background: var(--nq-p-icon-tile);
color: #ffffff;
}
.tile svg {
width: 34px;
height: 34px;
}
.sealed .tile {
background: var(--nq-p-icon-tile-done);
color: var(--nq-p-ink-secondary);
}
.quest .body {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.quest .quest-title {
font-family: var(--nq-p-font-heading);
font-size: var(--nq-p-size-quest);
font-weight: 700;
line-height: 1.15;
color: var(--nq-p-ink);
overflow-wrap: anywhere;
}
.sealed .quest-title {
color: var(--nq-p-ink-muted);
text-decoration: line-through;
}
.quest .meta {
font-family: var(--nq-p-font-body);
font-size: var(--nq-p-size-body);
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.quest .meta.late {
font-family: var(--nq-p-font-heading);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--nq-p-ink-late);
}
button.complete {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: var(--nq-p-button-height);
min-width: var(--nq-p-button-min-width);
border: none;
border-radius: 10px;
background: var(--nq-brand-gradient);
color: #ffffff;
font-family: var(--nq-p-font-heading);
font-size: 24px;
font-weight: 700;
line-height: 1;
box-shadow: 0 3px 0 rgba(40, 20, 60, 0.35);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
}
button.complete:active {
transform: scale(0.98);
}
button.complete svg {
width: 30px;
height: 30px;
}
button:focus-visible {
outline: 3px solid var(--nq-brand-purple);
outline-offset: 4px;
}
.seal {
position: absolute;
right: 16px;
top: -12px;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--nq-p-seal-size);
height: var(--nq-p-seal-size);
border-radius: var(--nq-p-radius-pill);
background: var(--nq-p-seal);
box-shadow: var(--nq-p-seal-shadow);
color: rgba(255, 235, 235, 0.95);
transform: rotate(var(--seal-rot, 0deg));
animation: seal-in var(--nq-dur-base) var(--nq-ease-out);
pointer-events: none;
}
.seal svg {
width: 38px;
height: 38px;
}
@keyframes seal-in {
from {
opacity: 0;
transform: scale(1.15) rotate(var(--seal-rot, 0deg));
}
to {
opacity: 1;
transform: scale(1) rotate(var(--seal-rot, 0deg));
}
}
.rollup {
border: 2px dashed var(--nq-p-panel-border);
border-radius: 14px;
padding: 16px 22px;
}
.rollup-label {
display: block;
font-family: var(--nq-p-font-heading);
font-size: 18px;
font-weight: 600;
letter-spacing: var(--nq-p-track-label);
text-transform: uppercase;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.rollup-body {
margin: 6px 0 0;
font-family: var(--nq-p-font-body);
font-size: 23px;
font-weight: 600;
line-height: 1.35;
color: var(--nq-p-ink-secondary);
}
.scrim {
position: absolute;
inset: 0;
z-index: 10;
background: rgba(30, 20, 10, 0.62);
}
.dialog {
position: absolute;
top: 196px;
left: 460px;
width: 1000px;
border: 3px solid rgba(92, 62, 26, 0.5);
border-radius: 20px;
background: var(--nq-p-card-panel);
box-shadow: 0 26px 60px rgba(20, 10, 0, 0.5);
padding: 64px 68px 56px;
animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out);
}
@keyframes dialog-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.dialog-header {
display: flex;
align-items: center;
gap: 24px;
}
.dialog-tile {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 104px;
height: 104px;
border-radius: 12px;
background: var(--nq-p-icon-tile);
color: #ffffff;
}
.dialog-tile svg {
width: 56px;
height: 56px;
}
.dialog-titles {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.dialog-kicker {
font-family: var(--nq-p-font-heading);
font-size: var(--nq-p-size-label);
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.dialog-quest-title {
font-family: var(--nq-p-font-display);
font-size: 58px;
font-weight: 700;
line-height: 1.1;
color: var(--nq-p-ink);
overflow-wrap: anywhere;
}
.dialog-body {
margin: 28px 0 0;
font-family: var(--nq-p-font-body);
font-size: 32px;
font-weight: 600;
line-height: 1.35;
color: #3f2f1c;
}
.dialog-buttons {
display: flex;
gap: 24px;
margin-top: 40px;
}
button.confirm-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
height: var(--nq-p-confirm-button);
border-radius: 10px;
font-family: var(--nq-p-font-heading);
font-weight: 700;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
}
button.confirm-button:active {
transform: scale(0.98);
}
button.not-yet {
flex: 1;
border: 2px solid rgba(92, 62, 26, 0.5);
background: rgba(92, 62, 26, 0.1);
color: var(--nq-p-ink);
font-size: 30px;
}
button.not-yet svg {
width: 34px;
height: 34px;
}
button.do-complete {
flex: 2;
border: none;
background: var(--nq-brand-gradient);
color: #ffffff;
font-size: 32px;
}
button.do-complete svg {
width: 38px;
height: 38px;
}
`;
const ICON_CHECK = 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="M20 6 9 17l-5-5"></path>
</svg>`;
const ICON_X = 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="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>`;
const ICON_STAR = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26"
></polygon>
</svg>`;
const ICON_SUNRISE = 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 2v8"></path>
<path d="m4.93 10.93 1.41 1.41"></path>
<path d="M2 18h2"></path>
<path d="M20 18h2"></path>
<path d="m19.07 10.93-1.41 1.41"></path>
<path d="M22 22H2"></path>
<path d="m8 6 4-4 4 4"></path>
<path d="M16 18a4 4 0 0 0-8 0"></path>
</svg>`;
const ICON_SUN = 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="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>`;
const ICON_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="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>`;
const WINDOW_DEFS: WindowDef[] = [
{ key: "morning", name: "Morning", range: "Until 11:59 AM", icon: ICON_SUNRISE },
{ key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: ICON_SUN },
{ key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: ICON_MOON },
];
export class NestQuestQuestLogCard extends LitElement {
static properties = {
hass: { attribute: false },
_config: { state: true },
2026-09-20 13:54:14 -04:00
_now: { state: true },
_confirm: { state: true },
};
2026-09-20 13:54:14 -04:00
static styles = [unsafeCSS(panelTokens), logStyles];
hass?: HassLike;
_config?: CardConfig;
2026-09-20 13:54:14 -04:00
_now = new Date();
_confirm: number | null = null;
private _clockTimer?: number;
private _idleTimer?: number;
private _confirmTimer?: number;
setConfig(config: CardConfig): void {
if (!config || typeof config !== "object") {
throw new Error("Invalid configuration");
}
this._config = config;
}
getCardSize(): number {
2026-09-20 13:54:14 -04:00
return 22;
}
connectedCallback(): void {
super.connectedCallback();
this._now = new Date();
this._clockTimer = window.setInterval(() => {
this._now = new Date();
}, 60000);
window.addEventListener("pointerdown", this._onActivity, true);
window.addEventListener("touchstart", this._onActivity, true);
window.addEventListener("keydown", this._onActivity, true);
this._armIdle();
}
disconnectedCallback(): void {
this._stopClock();
this._clearIdle();
this._clearConfirmTimer();
window.removeEventListener("pointerdown", this._onActivity, true);
window.removeEventListener("touchstart", this._onActivity, true);
window.removeEventListener("keydown", this._onActivity, true);
super.disconnectedCallback();
}
render() {
2026-09-20 13:54:14 -04:00
return html`
<div class="board">
<div class="frame frame-outer"></div>
<div class="frame frame-inner"></div>
${this._renderHeader()}
<div class="columns">
${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))}
</div>
${this._renderConfirm()}
</div>
`;
}
2026-09-20 13:54:14 -04:00
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 _childSlug(): string {
return childSlugFromPath();
}
private _boardPath(): string {
return asString(this._config?.board_path).replace(/\/+$/, "");
}
private _idleSeconds(): number {
const seconds = asNumber(this._config?.idle_return_seconds, 40);
return seconds > 0 ? seconds : 40;
}
private _confirmSeconds(): number {
const seconds = asNumber(this._config?.confirm_timeout_seconds, 15);
return seconds > 0 ? seconds : 15;
}
private _childName(slug: string): string | null {
if (!slug) {
return null;
}
const name = this._state(`sensor.nestquest_${slug}_quests_due_today`)
?.attributes?.child_name;
if (typeof name === "string" && name.trim()) {
return name.trim();
}
return null;
}
private _childCounts(slug: string): { due: number; completed: number } {
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const completedSensor = this._state(
`sensor.nestquest_${slug}_quests_completed_today`
);
const due = clampCount(asNumber(dueSensor?.state, 0));
const completed = clampCount(asNumber(completedSensor?.state, 0));
return { due, completed };
}
private _instances(): PanelInstance[] {
const slug = this._childSlug();
if (!slug) {
return [];
}
const payload = this._state(`sensor.nestquest_${slug}_quests_due_today`)
?.attributes?.instances;
if (!Array.isArray(payload)) {
return [];
}
return payload
.map(toPanelInstance)
.filter((instance): instance is PanelInstance => instance !== null);
}
private _remaining(): number {
const slug = this._childSlug();
if (!slug) {
return 0;
}
const sensor = this._state(
`sensor.nestquest_${slug}_quests_remaining_today`
);
if (sensor) {
return clampCount(asNumber(sensor.state, 0));
}
const { due, completed } = this._childCounts(slug);
return clampCount(due - completed);
}
private _partyCounts(): { completed: number; due: number } {
const householdDue = this._state(
"sensor.nestquest_household_quests_due_today"
);
const householdCompleted = this._state(
"sensor.nestquest_household_quests_completed_today"
);
if (householdDue || householdCompleted) {
return {
completed: clampCount(asNumber(householdCompleted?.state, 0)),
due: clampCount(asNumber(householdDue?.state, 0)),
};
}
const slug = this._childSlug();
const { due, completed } = this._childCounts(slug);
return { completed, due };
}
private _otherChildren(): OtherChild[] {
const states = this.hass?.states as
| Record<string, StateObject>
| undefined;
if (!states) {
return [];
}
const slug = this._childSlug();
const timeZone = this._timeZone();
const result: OtherChild[] = [];
for (const entityId of Object.keys(states)) {
const match = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(entityId);
if (!match || match[1] === "household" || match[1] === slug) {
continue;
}
const childSlug = match[1];
const attrs = states[entityId]?.attributes ?? {};
let name = "";
if (typeof attrs.child_name === "string" && attrs.child_name.trim()) {
name = attrs.child_name.trim();
} else {
name = titleCaseSlug(childSlug);
}
let present = true;
if (typeof attrs.present === "boolean") {
present = attrs.present;
} else {
const presence = this._state(
`binary_sensor.nestquest_${childSlug}_present_today`
);
present =
presence === null ||
String(presence.state ?? "").trim().toLowerCase() !== "off";
}
const completedSensor = this._state(
`sensor.nestquest_${childSlug}_quests_completed_today`
);
const due = clampCount(asNumber(states[entityId]?.state, 0));
const completed = clampCount(asNumber(completedSensor?.state, 0));
let returnsWeekday: string | null = null;
if (!present) {
const presence = this._state(
`binary_sensor.nestquest_${childSlug}_present_today`
);
const nextPresent = parseIsoDate(
presence?.attributes?.next_present,
timeZone
);
returnsWeekday = nextPresent
? formatWeekday(nextPresent, timeZone)
: null;
}
result.push({
slug: childSlug,
name,
present,
due,
completed,
returnsWeekday,
});
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
private _renderHeader() {
const slug = this._childSlug();
const name = this._childName(slug);
const displayName = name ?? (slug ? titleCaseSlug(slug) : null);
const { due, completed } = this._childCounts(slug);
const remaining = this._remaining();
const party = this._partyCounts();
const title = displayName ? `${displayName}'s Quest Log` : "Quest Log";
const initial = (displayName?.charAt(0) || "?").toUpperCase();
return html`
<header class="header">
<span class="crest" aria-hidden="true">
<span class="crest-face">
<span class="initial">${initial}</span>
</span>
</span>
<div class="titles">
<h1 class="title">${title}</h1>
<p class="sub">
${formatDay(this._now, this._timeZone())} · ${completed} of ${due}
claimed
</p>
</div>
<div class="remaining">
<span class="d20" aria-hidden="true">
<span class="d20-numeral">${remaining}</span>
</span>
<div class="remaining-text">
<span class="remaining-count">
${remaining} quest${remaining === 1 ? "" : "s"} left
</span>
<span class="party-line">
Party progress · ${party.completed} of ${party.due} today
</span>
</div>
</div>
</header>
`;
}
private _renderColumn(windowDef: WindowDef) {
const instances = this._instances().filter(
(instance) => instance.window === windowDef.key
);
const open = instances
.filter((instance) => instance.state === "open")
.sort((a, b) =>
(a.due_time ?? "99:99").localeCompare(b.due_time ?? "99:99")
);
const sealed = instances.filter(
(instance) => instance.state === "completed"
);
const count = instances.length;
let stackStyle: string | typeof nothing = nothing;
if (count > 0) {
const rollupReserve =
windowDef.key === "afternoon" && this._otherChildren().length > 0
? 160
: 0;
const available = 644 - rollupReserve;
const gap = Math.min(
24,
Math.max(8, Math.floor((available - count * 74) / Math.max(count - 1, 1)))
);
const minHeight = Math.min(
116,
Math.max(58, Math.floor((available - (count - 1) * gap) / count))
);
const buttonHeight = Math.max(56, Math.min(72, minHeight - 2));
let stackVars = `--nq-p-quest-gap: ${gap}px; --nq-p-quest-min-height: ${minHeight}px; --nq-p-button-height: ${buttonHeight}px`;
if (count >= 5) {
const pad = Math.max(0, Math.min(22, Math.floor((minHeight - buttonHeight - 2) / 2)));
const tile = Math.max(44, Math.min(64, minHeight - 2 * pad - 2));
stackVars += `; --nq-p-quest-pad: ${pad}px; --nq-p-tile-size: ${tile}px`;
}
stackStyle = stackVars;
}
return html`
<section
class="column"
aria-label="${windowDef.name} quests"
style=${stackStyle}
>
<div class="column-head">
${windowDef.icon}
<div class="column-names">
<span class="column-name">${windowDef.name}</span>
<span class="column-range">${windowDef.range}</span>
</div>
<span class="column-count">${sealed.length}/${instances.length}</span>
</div>
<div class="stack">
${repeat(
open,
(instance) => instance.id,
(instance) => this._renderQuest(instance, false, 0)
)}
${repeat(
sealed,
(instance) => instance.id,
(instance, index) => this._renderQuest(instance, true, index)
)}
${windowDef.key === "afternoon" ? this._renderRollup() : nothing}
</div>
</section>
`;
}
private _renderQuest(
instance: PanelInstance,
sealed: boolean,
sealedIndex: number
) {
if (sealed) {
const rotation = SEAL_ROTATIONS[sealedIndex % SEAL_ROTATIONS.length];
const claimedAt = instance.completed_at
? formatInstant(instance.completed_at, this._timeZone())
: "";
return html`
<div class="quest sealed" data-instance-id=${instance.id}>
<span class="tile" aria-hidden="true">${ICON_STAR}</span>
<div class="body">
<span class="quest-title">${instance.title}</span>
<span class="meta">
${claimedAt ? `Claimed ${claimedAt}` : "Claimed"}
</span>
</div>
<span
class="seal"
aria-hidden="true"
style="--seal-rot: ${rotation}deg"
>
${ICON_CHECK}
</span>
</div>
`;
}
const dueAt = formatWallClock(instance.due_time);
const meta = instance.overdue
? {
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
late: true,
}
: {
text: dueAt ? `Due by ${dueAt}` : "Due today",
late: false,
};
return html`
<div
class="quest tappable"
role="button"
tabindex="0"
data-instance-id=${instance.id}
aria-label="Complete ${instance.title}"
@click=${() => this._openConfirm(instance.id)}
@keydown=${(event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this._openConfirm(instance.id);
}
}}
>
<span class="tile" aria-hidden="true">${ICON_STAR}</span>
<div class="body">
<span class="quest-title">${instance.title}</span>
<span class="meta ${meta.late ? "late" : nothing}">${meta.text}</span>
</div>
<button
class="complete"
type="button"
@click=${() => this._openConfirm(instance.id)}
>
${ICON_CHECK}
<span>Complete</span>
</button>
</div>
`;
}
private _renderRollup() {
const others = this._otherChildren();
if (others.length === 0) {
return nothing;
}
const sentences = others.slice(0, 2).map((child) => {
if (!child.present) {
return child.returnsWeekday
? `${child.name} is on travels until ${child.returnsWeekday}.`
: `${child.name} is on travels.`;
}
if (child.due > 0 && child.completed >= child.due) {
return `${child.name} has finished their log.`;
}
if (child.due > child.completed) {
return `${child.name} has ${child.due - child.completed} quests left.`;
}
return `${child.name} has no quests today.`;
});
return html`
<div class="rollup">
<span class="rollup-label">Party roll-up</span>
<p class="rollup-body">${sentences.join(" ")}</p>
</div>
`;
}
private _renderConfirm() {
if (this._confirm === null) {
return nothing;
}
const instance = this._instances().find(
(candidate) => candidate.id === this._confirm
);
if (!instance) {
return nothing;
}
const windowDef = WINDOW_DEFS.find((entry) => entry.key === instance.window);
return html`
<div class="scrim" @click=${() => this._closeConfirm()}>
<div
class="dialog"
role="dialog"
aria-modal="true"
@click=${(event: Event) => event.stopPropagation()}
>
<div class="dialog-header">
<span class="dialog-tile" aria-hidden="true">${ICON_STAR}</span>
<div class="dialog-titles">
<span class="dialog-kicker">
${windowDef?.name ?? "Quest"}
</span>
<span class="dialog-quest-title">${instance.title}</span>
</div>
</div>
<p class="dialog-body">
Mark this quest complete? Once the seal is set, only a parent can
undo it.
</p>
<div class="dialog-buttons">
<button
class="confirm-button not-yet"
type="button"
@click=${() => this._closeConfirm()}
>
${ICON_X}
<span>Not yet</span>
</button>
<button
class="confirm-button do-complete"
type="button"
@click=${() => this._confirmComplete()}
>
${ICON_CHECK}
<span>Complete</span>
</button>
</div>
</div>
</div>
`;
}
private _openConfirm(instanceId: number): void {
this._confirm = instanceId;
this._armConfirmTimer();
}
private _closeConfirm(): void {
this._clearConfirmTimer();
this._confirm = null;
}
private _confirmComplete(): void {
this._closeConfirm();
}
private _armConfirmTimer(): void {
this._clearConfirmTimer();
this._confirmTimer = window.setTimeout(() => {
this._confirmTimer = undefined;
this._closeConfirm();
}, this._confirmSeconds() * 1000);
}
private _clearConfirmTimer(): void {
if (this._confirmTimer !== undefined) {
window.clearTimeout(this._confirmTimer);
this._confirmTimer = undefined;
}
}
private _armIdle(): void {
this._clearIdle();
this._idleTimer = window.setTimeout(() => {
this._idleTimer = undefined;
this._returnToBoard();
}, this._idleSeconds() * 1000);
}
private _clearIdle(): void {
if (this._idleTimer !== undefined) {
window.clearTimeout(this._idleTimer);
this._idleTimer = undefined;
}
}
private _returnToBoard(): void {
const path = this._boardPath();
if (!path) {
return;
}
window.history.pushState(null, "", path);
window.dispatchEvent(new Event("location-changed"));
}
private _stopClock(): void {
if (this._clockTimer !== undefined) {
window.clearInterval(this._clockTimer);
this._clockTimer = undefined;
}
}
private _onActivity = (): void => {
this._armIdle();
if (this._confirm !== null) {
this._armConfirmTimer();
}
};
}
customElements.define("nestquest-quest-log-card", NestQuestQuestLogCard);
registerCustomCard({
type: "nestquest-quest-log-card",
name: "NestQuest Quest Log",
description: "One child's daily quests, grouped by window.",
});