/** * TaskMate Routine Card (#676) * * A guided, one-task-at-a-time flow for morning and bedtime routines. The * child card is a checklist — good for scanning, poor for a five-year-old * being walked through getting ready. This shows a single task at a time with * a big Done button, a progress bar, and a celebration at the end. * * Availability comes from the backend's own chore_availability matrix rather * than a re-implementation of the child card's filter, so the weather gate, * reactive deadlines, dependencies and rotation are all honoured for free. * * 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); const TIME_CATEGORIES = ["all", "morning", "afternoon", "evening", "night", "anytime"]; // The card has ONE layout, so it consumes the design tokens directly rather // than carrying a second _renderDesigned() template like the multi-layout // cards do. Every --tmd-* reference below is written with the card's original // value as the fallback, so "classic" — which defines no tokens — renders // exactly as it did before the design system was wired in. const DEFAULT_ACCENT = "#7c3aed"; class TaskMateRoutineCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _index: { type: Number }, _finished: { type: Boolean }, _busy: { type: Boolean }, }; } constructor() { super(); this._index = 0; this._finished = false; this._busy = false; // Chores completed during THIS run, so the finish screen can total what // was just earned rather than everything the child has done today. this._runCompleted = new Map(); // Tasks the child chose to leave for later. Kept for this run only. this._skipped = new Set(); } setConfig(config) { if (!config.entity) throw new Error("You need to define an entity"); if (!config.child_id) throw new Error("You need to define a child_id"); if (config.time_category && !TIME_CATEGORIES.includes(config.time_category)) { throw new Error(`time_category must be one of: ${TIME_CATEGORIES.join(", ")}`); } this.config = config; } getCardSize() { return 8; } static getStubConfig() { return { entity: "sensor.taskmate_overview", child_id: "", time_category: "morning" }; } updated(changedProps) { super.updated?.(changedProps); this._scheduleUndoExpiry(); } disconnectedCallback() { super.disconnectedCallback(); clearTimeout(this._undoExpiryTimer); this._undoExpiryTimer = null; } // ── Child undo (#918) ─────────────────────────────────────────────────── // A step finished in this run can be taken back while the server says so: // child_undo_pending (not yet reviewed) or child_undo_until (auto-approved, // until that instant). Neither is set while the global window is 0. _undoableCompletion(chore, now = Date.now()) { const child = this._child(); if (!child || !chore) return null; const latest = (this._attrs().todays_completions || []) .filter(c => String(c.child_id) === String(child.id) && String(c.chore_id) === String(chore.id) && !c.bonus_subtask_id) .sort((a, b) => new Date(b.completed_at || 0) - new Date(a.completed_at || 0))[0]; if (!latest || !(latest.completion_id || latest.id)) return null; if (latest.child_undo_pending === true) return latest; const until = Date.parse(latest.child_undo_until || ""); return Number.isFinite(until) && until > now ? latest : null; } /** Re-render when the soonest undo window closes, so its button goes away. */ _scheduleUndoExpiry() { clearTimeout(this._undoExpiryTimer); this._undoExpiryTimer = null; const child = this._child?.(); if (!child) return; const now = Date.now(); const deadlines = (this._attrs().todays_completions || []) .filter(c => String(c.child_id) === String(child.id)) .map(c => Date.parse(c.child_undo_until || "")) .filter(t => Number.isFinite(t) && t > now); if (!deadlines.length) return; this._undoExpiryTimer = setTimeout(() => { this._undoExpiryTimer = null; this.requestUpdate(); }, Math.min(Math.min(...deadlines) - now + 50, 2 ** 31 - 1)); } async _undo(chore, completion) { if (this._busy) return; this._busy = true; try { // The service takes the child from the stored completion itself. await this.hass.callService("taskmate", "undo_chore", { completion_id: completion.completion_id || completion.id, }); this._runCompleted.delete(String(chore.id)); } catch (err) { this.dispatchEvent(new CustomEvent("hass-notification", { detail: { message: String(err?.message || err) }, bubbles: true, composed: true, })); } finally { this._busy = false; this.requestUpdate(); } } shouldUpdate(changedProps) { if (changedProps.has("hass")) { return window.__taskmate_hasChanged ? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity) : true; } return true; } _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 || {}; } /** * Resolve the active design, stamp data-tm-design on the host (which is what * makes the :host-scoped token block apply inside this shadow root), and * settle the header colour. * * The accent is set as an inline property on the host rather than from a *