/** * TaskMate Kiosk Card (#930) * * A full-screen view for a shared wall tablet. The family sees a picker of * faces; a child taps theirs, types their 4-digit PIN if they have one, and * gets only their own chores for today with big Done buttons, their points, * streak and progress towards the next reward. After a spell without a tap * (and straight after an "all done" celebration) it goes back to the picker. * * What it deliberately does NOT have: any parent action. No approve/reject, * no complete-on-behalf, no points adjustments, no reward claiming, no * settings — there is no option to turn them on. * * PINs are set by a parent in the admin panel and checked on the server * (taskmate/kiosk/verify_pin, rate-limited); the card never sees a PIN or its * hash, and only remembers which child is unlocked until it returns to the * picker. Completions go through the normal complete path, so the backend's * linked-child rule still decides who may act for whom: taskmate/kiosk/status * reports it per child and the card explains a refusal instead of offering * buttons that would fail. * * The card has ONE layout, so it consumes the design tokens directly: every * --tmd-* is written with the classic value as its fallback. * * Version: 1.0.0 */ const LitElement = customElements.get("hui-masonry-view") ? Object.getPrototypeOf(customElements.get("hui-masonry-view")) : Object.getPrototypeOf(customElements.get("hui-view")); const html = LitElement.prototype.html; const css = LitElement.prototype.css; const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); // Keeps number runs like "3 / 1" reading left to right in RTL text (#995); // identity until the design layer has loaded (it is also what stamps dir). const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s); // "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in // LTR (so it renders pixel-for-pixel as before), the isolated string in RTL. const _ltrRatio = (a, b) => { const s = `${a} / ${b}`; const r = _ltrNums(s); return r === s ? html`${a} / ${b}` : r; }; const DEFAULT_HEADER = "#9b59b6"; const DEFAULT_TIMEOUT = 60; const MIN_TIMEOUT = 15; const MAX_TIMEOUT = 600; // The "Still there?" countdown covers the last WARN_SECONDS of the timeout. const WARN_SECONDS = 10; // How long the all-done celebration stays up before the picker comes back. const CELEBRATE_MS = 4000; const PIN_LENGTH = 4; // The same six-colour ring the child card uses for per-child tones. const CHILD_FALLBACK = ["#ff7043", "#42a5f5", "#66bb6a", "#ab47bc", "#ffa726", "#26c6da"]; const clampTimeout = (v) => { const n = Number(v); if (!Number.isFinite(n)) return DEFAULT_TIMEOUT; return Math.min(MAX_TIMEOUT, Math.max(MIN_TIMEOUT, Math.round(n))); }; class TaskMateKioskCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _screen: { type: String }, _kid: { type: String }, _pin: { type: String }, _pinError: { type: String }, _status: { type: Object }, _statusError: { type: String }, _idle: { type: Number }, _busy: { type: String }, _celebrating: { type: Boolean }, _fullscreen: { type: Boolean }, }; } constructor() { super(); this._screen = "picker"; this._kid = null; this._pin = ""; this._pinError = ""; this._shake = false; this._lockedUntil = 0; this._status = null; this._statusError = ""; this._idle = 0; this._busy = ""; this._celebrating = false; this._armCelebrate = false; this._fullscreen = false; this._now = new Date(); // Any touch or key press counts as activity and restarts the idle timer. this._onActivity = () => { this._idle = 0; }; this._onFullscreen = () => { this._fullscreen = typeof document !== "undefined" && document.fullscreenElement === this; }; this.addEventListener?.("pointerdown", this._onActivity); this.addEventListener?.("keydown", this._onActivity); } setConfig(config) { if (!config) throw new Error("Invalid configuration"); if (config.children !== undefined && !Array.isArray(config.children)) { throw new Error("children must be a list of { child_id, require_pin }"); } this.config = { entity: "sensor.taskmate_overview", ...config }; } getCardSize() { return 12; } static getConfigElement() { return document.createElement("taskmate-kiosk-card-editor"); } static getStubConfig() { return { entity: "sensor.taskmate_overview" }; } connectedCallback() { super.connectedCallback?.(); clearInterval(this._ticker); this._ticker = setInterval(() => this._tick(), 1000); if (typeof document !== "undefined") document.addEventListener?.("fullscreenchange", this._onFullscreen); this._scheduleStatus(0); } disconnectedCallback() { super.disconnectedCallback?.(); clearInterval(this._ticker); this._ticker = null; clearTimeout(this._statusTimer); clearTimeout(this._celebrateTimer); clearTimeout(this._undoExpiryTimer); if (typeof document !== "undefined") document.removeEventListener?.("fullscreenchange", this._onFullscreen); // Leaving the dashboard locks the kiosk again. this._resetToPicker(); } shouldUpdate(changedProps) { if (changedProps.has("hass") && changedProps.size === 1) { return window.__taskmate_hasChanged ? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity) : true; } return true; } updated(changedProps) { super.updated?.(changedProps); // Only TaskMate-relevant hass changes get this far (shouldUpdate), so each // one may have changed who has what left to do: ask the server again. if (changedProps.has("hass")) this._scheduleStatus(300); if (this._screen !== "picker" && !(this._kid && this._entry(this._kid))) { this._toPicker(); return; } this._scheduleUndoExpiry(); this._maybeCelebrate(); } // ── Helpers ────────────────────────────────────────────────────────────── _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } _attrs() { return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || this.hass?.states?.[this.config.entity]?.attributes || {}; } _timeout() { return clampTimeout(this.config.timeout ?? DEFAULT_TIMEOUT); } /** * Stamp the design on the host and settle the banner colour. Set inline on * the host: a shadow