/** * TaskMate Chore Board Card (#1017) * * The admin panel's Today-page chore board as a dashboard card: rows are the * family's time periods, columns are the children, and each cell lists that * child's chores for the period with where they stand (done, awaiting * approval, to do, missed). A This week toggle swaps in done/total per child * for the last seven days. * * Tapping a chore that is still to do completes it for that child — the same * button press / complete_chore call the child card makes — and offers Undo * on a short toast. Chores that need the child card's own flow (a photo, a * timer, a note or a team join) say so instead of completing. * * Reads `chore_board` from sensor.taskmate_chore_board, and `children`, * `chores`, `time_periods` and `todays_completions` from the overview entity * and its companions, all through the attribute resolver. * * Single layout, so it consumes the design tokens directly (like the bounty * card): every --tmd-* reference carries the classic value as its fallback. * Layout classes carry a cb- prefix — the shared design kit defines its own * .btn / .bar / .av / .chip. */ 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 _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s); const DEFAULT_ACCENT = "#3f51b5"; // Child colours by position; a design's own palette wins when it has one. const CHILD_COLOURS = ["#ff6b9d", "#3498db", "#1abc9c", "#e67e22", "#9b59b6", "#2ecc71"]; const BUILTIN_PERIODS = ["morning", "afternoon", "evening", "night"]; const STATUSES = ["done", "pending", "todo", "missed"]; const TOAST_MS = 5000; class TaskMateChoreBoardCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _view: { type: String }, _toast: { type: Object }, _busy: { type: Object }, }; } constructor() { super(); this._view = null; this._toast = null; this._busy = {}; // `${chore}|${child}` -> { status, count, board } — a tap's result shown // until the board the tap was made on is replaced by a newer one. this._optimistic = {}; this._toastTimer = null; } setConfig(config) { if (!config || !config.entity) throw new Error("You need to define an entity"); this.config = config; } static getStubConfig() { return { entity: "sensor.taskmate_overview" }; } static getConfigElement() { return document.createElement("taskmate-chore-board-card-editor"); } getCardSize() { return 6; } disconnectedCallback() { super.disconnectedCallback(); clearTimeout(this._toastTimer); } 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; } _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 || {}; } /** The children to show, in the family's order; none picked means everyone. */ _kids(attrs) { const all = attrs.children || []; const picked = Array.isArray(this.config.children) ? this.config.children.map(String) : []; return picked.length ? all.filter(c => picked.includes(String(c.id))) : all; } _kidIndex(attrs, id) { return Math.max(0, (attrs.children || []).findIndex(c => String(c.id) === String(id))); } _kidColour(attrs, id) { const i = this._kidIndex(attrs, id); return `var(--tmd-c${(i % 6) + 1}, ${CHILD_COLOURS[i % CHILD_COLOURS.length]})`; } /** Time periods in the order of the day, then Anytime for everything else. */ _periods(attrs) { const hhmm = (s) => { const [h, m] = String(s || "").split(":").map(Number); return Number.isFinite(h) ? h + (m || 0) / 60 : 0; }; let periods; if (Array.isArray(attrs.time_periods) && attrs.time_periods.length) { periods = attrs.time_periods .filter(p => p && p.id) .map(p => ({ id: p.id, label: p.label || "", icon: p.icon || "mdi:clock-outline", start: hhmm(p.start) })) .sort((a, b) => a.start - b.start); } else { periods = [ { id: "morning", icon: "mdi:weather-sunset-up" }, { id: "afternoon", icon: "mdi:weather-sunny" }, { id: "evening", icon: "mdi:weather-sunset-down" }, { id: "night", icon: "mdi:weather-night" }, ]; } const label = p => p.label || (BUILTIN_PERIODS.includes(p.id) ? this._t(`common.${p.id}`) : p.id); return [ ...periods.filter(p => p.id !== "anytime").map(p => ({ id: p.id, icon: p.icon, label: label(p) })), { id: "anytime", icon: "mdi:clock-outline", label: this._t("common.anytime") }, ]; } /** The board, with any tap still waiting for the server laid over it. */ _boardEntries(board) { const entries = (board && board.board && board.board.children) || []; return entries.map(e => ({ ...e, chores: (e.chores || []).map(item => { const key = `${item.chore_id}|${e.child_id}`; const opt = this._optimistic[key]; if (!opt) return item; if (opt.board !== board) { delete this._optimistic[key]; return item; } return { ...item, status: opt.status, count: opt.count }; }), })); } /** A chore whose completion needs the child card: a photo, timer, note or team. */ _needsChildCard(chore) { return !!(chore.require_photo || chore.task_type === "timed" || chore.open_ended || chore.team); } _applyDesign() { const design = window.__taskmate_design ? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity) : "classic"; // Inline on the host: a