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"; type StateObject = { state: unknown; attributes?: Record | 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(); 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).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; 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``; const ICON_X = html``; const ICON_STAR = html``; const ICON_SUNRISE = html``; const ICON_SUN = html``; const ICON_MOON = html``; 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 }, _now: { state: true }, _confirm: { state: true }, }; static styles = [unsafeCSS(panelTokens), logStyles]; hass?: HassLike; _config?: CardConfig; _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 { 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() { return html`
${this._renderHeader()}
${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))}
${this._renderConfirm()}
`; } private _timeZone(): string | undefined { return hassTimeZone(this.hass); } private _state(entityId: string): StateObject | null { const states = this.hass?.states as Record | 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 | 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`

${title}

${formatDay(this._now, this._timeZone())} · ${completed} of ${due} claimed

${remaining} quest${remaining === 1 ? "" : "s"} left Party progress · ${party.completed} of ${party.due} today
`; } 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 ? 120 : 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(74, 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`
${windowDef.icon}
${windowDef.name} ${windowDef.range}
${sealed.length}/${instances.length}
${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}
`; } 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`
${instance.title} ${claimedAt ? `Claimed ${claimedAt}` : "Claimed"}
`; } 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`
this._openConfirm(instance.id)} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this._openConfirm(instance.id); } }} >
${instance.title} ${meta.text}
`; } 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`
Party roll-up

${sentences.join(" ")}

`; } 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`
this._closeConfirm()}>
`; } 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.", });