331 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-04 05:26:42 +00:00
1 parent 22ba8b4648
commit c4d601bfba
331 files changed
+10227 -50159

No files matched your search

@@ -0,0 +1,807 @@
/**
* 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 <style> in the template is ordered before
// adoptedStyleSheets and would lose to the :host default.
const configured = this.config.header_color;
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
this.style.setProperty("--cb-accent", _safeColor(configured, DEFAULT_ACCENT));
} else {
this.style.removeProperty("--cb-accent");
}
return design;
}
// ── actions ─────────────────────────────────────────────────────────────
_playSound(name) {
const engine = window.__taskmate_sounds;
if (engine) engine.play(name, this._attrs().custom_sounds || []);
}
_showToast(message, undo = null) {
clearTimeout(this._toastTimer);
this._toast = { message, undo };
this._toastTimer = setTimeout(() => { this._toast = null; }, TOAST_MS);
}
async _tap(item, chore, kid, board) {
if (this.config.tap_to_complete === false) return;
if (item.status !== "todo" && item.status !== "missed") return;
if (this._needsChildCard(chore)) {
this._showToast(this._t("chore_board.finish_on_card", { chore: chore.name, name: kid.name }));
return;
}
const key = `${chore.id}|${kid.id}`;
if (this._busy[key]) return;
this._busy = { ...this._busy, [key]: true };
const count = (Number(item.count) || 0) + 1;
const limit = Math.max(1, Number(item.limit) || 1);
const finished = count >= limit;
const status = !finished ? "todo" : chore.requires_approval === false ? "done" : "pending";
this._optimistic = { ...this._optimistic, [key]: { status, count, board } };
try {
// The button entity first, so automations triggered by it fire — the
// same order the child card uses.
const button = window.__taskmate_find_button && window.__taskmate_find_button(this.hass, kid.id, "complete", chore.id);
if (button) {
await this.hass.callService("button", "press", { entity_id: button });
} else {
await this.hass.callService("taskmate", "complete_chore", { chore_id: chore.id, child_id: kid.id });
}
this._playSound(chore.completion_sound || "coin");
const msg = status === "pending" ? "chore_board.sent_for_approval" : "chore_board.marked_done";
this._showToast(this._t(msg, { chore: chore.name, name: kid.name }), { chore_id: chore.id, child_id: kid.id });
} catch (err) {
const next = { ...this._optimistic };
delete next[key];
this._optimistic = next;
this._showToast(this._t("chore_board.error", { message: String(err?.message || err) }));
} finally {
this._busy = { ...this._busy, [key]: false };
}
}
/** Take back the completion the last tap made. */
async _undo(target) {
clearTimeout(this._toastTimer);
this._toast = null;
const latest = (this._attrs().todays_completions || [])
.filter(c => c.chore_id === target.chore_id && c.child_id === target.child_id && !c.bonus_subtask_id)
.sort((a, b) => new Date(b.completed_at || 0) - new Date(a.completed_at || 0))[0];
const completionId = latest && (latest.completion_id || latest.id);
if (!completionId) {
this._showToast(this._t("chore_board.undo_failed"));
return;
}
let service;
if (window.__taskmate_is_parent && window.__taskmate_is_parent(this.hass)) {
service = "reject_chore";
} else {
// A child's own tick, inside the undo window the server publishes (#918).
const until = Date.parse(latest.child_undo_until || "");
if (latest.child_undo_pending === true || (Number.isFinite(until) && until > Date.now())) {
service = "undo_chore";
} else {
this._showToast(this._t("child.undo_not_allowed"));
return;
}
}
try {
await this.hass.callService("taskmate", service, { completion_id: completionId });
const next = { ...this._optimistic };
delete next[`${target.chore_id}|${target.child_id}`];
this._optimistic = next;
this._playSound("undo");
} catch (err) {
this._showToast(this._t("chore_board.error", { message: String(err?.message || err) }));
}
}
// ── render ──────────────────────────────────────────────────────────────
render() {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const attrs = this._attrs();
const title = this.config.title || this._t("chore_board.title");
const showWeek = this.config.show_week !== false;
const view = showWeek ? (this._view || (this.config.default_view === "week" ? "week" : "today")) : "today";
const kids = this._kids(attrs);
const board = attrs.chore_board;
let body;
if (!this.hass.states[this.config.entity]) {
body = html`<div class="cb-empty">${this._t("common.entity_not_found", { entity: this.config.entity })}</div>`;
} else if (!board) {
body = html`<div class="cb-empty">${this._t("chore_board.unavailable")}</div>`;
} else if (!kids.length) {
body = html`<div class="cb-empty">${this._t("chore_board.no_children")}</div>`;
} else if (view === "week") {
body = this._renderWeek(attrs, kids, board);
} else {
body = this._renderToday(attrs, kids, board);
}
return html`
<ha-card>
<div class="cb-hd">
<ha-icon class="cb-hd-icon" icon="mdi:view-grid-outline"></ha-icon>
<div class="cb-hd-title">${title}</div>
${showWeek ? html`
<div class="cb-seg" role="tablist">
${["today", "week"].map(v => html`
<button type="button" role="tab" class="${view === v ? "on" : ""}" aria-selected="${view === v ? "true" : "false"}"
@click=${() => { this._view = v; }}>${this._t(v === "week" ? "chore_board.this_week" : "chore_board.today")}</button>`)}
</div>` : ""}
</div>
<div class="cb-body ${this._toast ? "cb-has-toast" : ""}">${body}</div>
${this._toast ? html`
<div class="cb-toast" role="status">
<span>${this._toast.message}</span>
${this._toast.undo ? html`<button type="button" @click=${() => this._undo(this._toast.undo)}>${this._t("chore_board.undo")}</button>` : ""}
</div>` : ""}
</ha-card>
`;
}
_avatar(attrs, kid) {
const a = kid.avatar || "";
const inner = a.startsWith("mdi:")
? html`<ha-icon icon="${a}"></ha-icon>`
: a ? html`<img src="${a}" alt="">` : (kid.name || "?").charAt(0).toUpperCase();
return html`<span class="cb-av" style="--cb-kc:${this._kidColour(attrs, kid.id)}">${inner}</span>`;
}
/** Each shown child's chores, grouped by time period. */
_cells(attrs, kids, board, periods) {
const choreById = Object.fromEntries((attrs.chores || []).map(c => [c.id, c]));
const known = new Set(periods.map(p => p.id));
const entries = this._boardEntries(board);
const cells = {};
const totals = {};
for (const kid of kids) {
const entry = entries.find(e => String(e.child_id) === String(kid.id));
let done = 0;
let due = 0;
for (const item of (entry && entry.chores) || []) {
const chore = choreById[item.chore_id];
if (!chore) continue;
due += 1;
if (item.status === "done" || item.status === "pending") done += 1;
const slot = known.has(chore.time_category) ? chore.time_category : "anytime";
(cells[`${slot}|${kid.id}`] = cells[`${slot}|${kid.id}`] || []).push({ item, chore });
}
totals[kid.id] = { done, due };
}
return { cells, totals };
}
_visibleRows(kids, periods, cells) {
const finished = list => list.every(({ item }) => item.status === "done" || item.status === "pending");
return periods.filter(p => {
const lists = kids.map(k => cells[`${p.id}|${k.id}`] || []);
if (!lists.some(l => l.length)) return false;
return !(this.config.hide_done_periods && lists.every(finished));
});
}
_chip(item, chore, kid, board) {
const statusLabel = this._t(`chore_board.status_${item.status}`);
const limit = Math.max(1, Number(item.limit) || 1);
const count = limit > 1 ? html` <small>${_ltrNums(`${item.count || 0}/${limit}`)}</small>` : "";
const extra = this._needsChildCard(chore)
? html`<ha-icon class="cb-x" icon="${chore.require_photo ? "mdi:camera-outline" : chore.task_type === "timed" ? "mdi:timer-sand" : chore.team ? "mdi:account-group-outline" : "mdi:pencil-outline"}"></ha-icon>`
: "";
const inner = html`<i class="cb-dot" aria-hidden="true"></i><span class="cb-n">${chore.name}${count}</span>${extra}<span class="cb-sr">${statusLabel}</span>`;
const tappable = this.config.tap_to_complete !== false && (item.status === "todo" || item.status === "missed");
const key = `${chore.id}|${kid.id}`;
return tappable
? html`<button type="button" class="cb-chip ${item.status}" title="${chore.name} · ${statusLabel}"
?disabled=${!!this._busy[key]} @click=${() => this._tap(item, chore, kid, board)}>${inner}</button>`
: html`<span class="cb-chip ${item.status}" title="${chore.name} · ${statusLabel}">${inner}</span>`;
}
_legend() {
if (this.config.show_legend === false) return "";
return html`<div class="cb-legend">${STATUSES.map(s => html`
<span class="cb-chip ${s}"><i class="cb-dot" aria-hidden="true"></i><span class="cb-n">${this._t(`chore_board.status_${s}`)}</span></span>`)}</div>`;
}
_renderToday(attrs, kids, board) {
const periods = this._periods(attrs);
const { cells, totals } = this._cells(attrs, kids, board, periods);
const rows = this._visibleRows(kids, periods, cells);
const anything = kids.some(k => totals[k.id].due);
if (!anything) return html`<div class="cb-empty">${this._t("chore_board.empty")}</div>`;
if (!rows.length) return html`<div class="cb-empty">${this._t("chore_board.all_done")}</div>${this._legend()}`;
if (kids.length === 1) {
const kid = kids[0];
const { done, due } = totals[kid.id];
return html`
<div class="cb-list">
<div class="cb-prog-row">
${this._avatar(attrs, kid)}<span class="cb-prog-name">${kid.name}</span>
<span class="cb-prog-n">${_ltrNums(`${done}/${due}`)}</span>
</div>
<div class="cb-prog"><i style="width:${due ? Math.round((done / due) * 100) : 100}%;background:${this._kidColour(attrs, kid.id)}"></i></div>
${rows.map(p => html`
<div class="cb-grp"><ha-icon icon="${p.icon}"></ha-icon>${p.label}</div>
${(cells[`${p.id}|${kid.id}`] || []).map(({ item, chore }) => this._chip(item, chore, kid, board))}`)}
</div>
${this._legend()}`;
}
return html`
<div class="cb-wrap">
<div class="cb-board" style="--cb-cols:${kids.length}">
<div class="cb-sticky"></div>
${kids.map(k => html`
<div class="cb-kid">${this._avatar(attrs, k)}<span>${k.name}</span>
<small>${_ltrNums(`${totals[k.id].done}/${totals[k.id].due}`)}</small></div>`)}
${rows.map(p => html`
<div class="cb-slot cb-sticky"><ha-icon icon="${p.icon}"></ha-icon><span>${p.label}</span></div>
${kids.map(k => {
const items = cells[`${p.id}|${k.id}`] || [];
return html`<div class="cb-cell">${items.length
? items.map(({ item, chore }) => this._chip(item, chore, k, board))
: html`<span class="cb-none">–</span>`}</div>`;
})}`)}
</div>
</div>
${this._legend()}`;
}
_renderWeek(attrs, kids, board) {
const history = board.history || {};
const today = board.board && board.board.date;
const first = kids.map(k => history[k.id]).find(Boolean) || [];
const lang = this.hass?.locale?.language || this.hass?.language || undefined;
const dayName = iso => {
if (iso === today) return this._t("chore_board.today");
try {
return new Date(`${iso}T12:00:00`).toLocaleDateString(lang, { weekday: "short" });
} catch (_e) {
return iso;
}
};
const gaps = kids.some(k => (history[k.id] || []).some(d => d.due == null));
return html`
<div class="cb-wrap">
<div class="cb-week" style="--cb-days:${first.length || 7}">
<div></div>
${first.map(d => html`<div class="cb-wh ${d.date === today ? "today" : ""}">${dayName(d.date)}</div>`)}
${kids.map(k => html`
<div class="cb-wk">${this._avatar(attrs, k)}<span>${k.name}</span></div>
${(history[k.id] || []).map(d => {
if (d.due == null) return html`<div class="cb-wc none" title="${this._t("chore_board.not_recorded")}">–</div>`;
const pct = d.due ? Math.round((d.done / d.due) * 100) : 100;
return html`<div class="cb-wc" style="--cb-kc:${this._kidColour(attrs, k.id)};--cb-p:${pct}"
title="${dayName(d.date)} · ${d.done}/${d.due}">${d.due ? _ltrNums(`${d.done}/${d.due}`) : "–"}</div>`;
})}`)}
</div>
</div>
${gaps ? html`<p class="cb-hint">${this._t("chore_board.week_hint")}</p>` : ""}`;
}
static get styles() {
const base = css`
:host { display: block; --cb-accent: var(--tmd-accent, #3f51b5); }
ha-card {
position: relative;
overflow: hidden;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
color: var(--tmd-text, var(--primary-text-color));
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
font-family: var(--tmd-font-body, inherit);
}
.cb-hd {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px;
background: var(--cb-accent);
color: var(--tmd-hd-text, #fff);
}
:host([data-tm-design="playroom"]) .cb-hd {
background: linear-gradient(135deg, var(--cb-accent), color-mix(in srgb, var(--cb-accent) 72%, #fff));
}
.cb-hd-icon { --mdc-icon-size: 26px; flex: none; opacity: .95; }
.cb-hd-title {
flex: 1; min-width: 0;
font-family: var(--tmd-font-display, inherit);
font-size: 1.2rem; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cb-seg { display: inline-flex; flex: none; background: rgba(255, 255, 255, .18); border-radius: 9px; padding: 2px; gap: 2px; }
.cb-seg button {
background: none; border: 0; cursor: pointer;
color: inherit; opacity: .85;
font: inherit; font-size: .8rem; font-weight: 500;
padding: 5px 11px; border-radius: 7px; min-height: 30px;
}
.cb-seg button.on { background: #fff; color: var(--cb-accent); opacity: 1; }
:host([data-tm-design="accessible"]) .cb-seg button.on { color: #111; outline: 2px solid #111; }
.cb-body { padding: 10px 16px 14px; }
/* Room for the toast, so it never sits over the last row. */
.cb-body.cb-has-toast { padding-bottom: 66px; }
.cb-empty { text-align: center; padding: 28px 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-wrap { overflow-x: auto; margin: 0 -4px; padding: 0 4px; }
.cb-board {
display: grid; grid-template-columns: 110px repeat(var(--cb-cols), minmax(140px, 1fr));
font-size: 13px;
}
.cb-board > div {
padding: 8px 10px; min-width: 0;
border-bottom: 1px solid var(--tmd-border, var(--divider-color, rgba(127, 127, 127, .2)));
}
.cb-sticky {
position: sticky; inset-inline-start: 0; z-index: 1;
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
}
.cb-kid { display: flex; align-items: center; gap: 7px; font-weight: 600; font-size: 12.5px; }
.cb-kid span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-kid small {
margin-inline-start: auto; font-weight: 500; font-variant-numeric: tabular-nums;
color: var(--tmd-dim, var(--secondary-text-color));
}
.cb-slot { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-slot ha-icon { --mdc-icon-size: 16px; flex: none; }
.cb-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.cb-none { color: var(--tmd-dim, var(--secondary-text-color)); opacity: .5; }
.cb-av {
width: 24px; height: 24px; flex: none; border-radius: 50%; overflow: hidden;
display: inline-grid; place-items: center;
background: var(--cb-kc, #9b59b6); color: #fff;
font-size: 11px; font-weight: 700;
}
.cb-av ha-icon { --mdc-icon-size: 16px; }
.cb-av img { width: 100%; height: 100%; object-fit: cover; }
.cb-chip {
display: inline-flex; align-items: center; gap: 7px; max-width: 100%;
background: none; border: 0; padding: 4px 5px; margin-inline-start: -5px;
border-radius: 6px; min-height: 28px;
font: inherit; font-size: 12.5px; color: inherit; text-align: start;
}
button.cb-chip { cursor: pointer; }
button.cb-chip:hover { background: var(--tmd-surface-2, rgba(127, 127, 127, .12)); }
button.cb-chip[disabled] { opacity: .6; cursor: default; }
.cb-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-n small { color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-x { --mdc-icon-size: 14px; flex: none; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-dot {
width: 14px; height: 14px; flex: none; border-radius: 50%; box-sizing: border-box;
border: 2px solid var(--tmd-dim, var(--secondary-text-color));
}
.cb-chip.done .cb-dot { background: var(--tmd-good, #2ecc71); border-color: var(--tmd-good, #2ecc71); }
.cb-chip.done .cb-n { color: var(--tmd-dim, var(--secondary-text-color)); text-decoration: line-through; }
.cb-chip.pending .cb-dot {
border-color: var(--tmd-warn, #f39c12);
background: color-mix(in srgb, var(--tmd-warn, #f39c12) 30%, transparent);
}
.cb-chip.missed .cb-dot { border-color: var(--tmd-bad, #e74c3c); }
.cb-chip.missed .cb-n { color: var(--tmd-bad, #e74c3c); }
.cb-sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap;
}
.cb-legend {
display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px;
font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color));
}
.cb-legend .cb-chip { padding: 0; margin: 0; min-height: 0; font-size: 12px; }
.cb-legend .cb-chip .cb-n { color: inherit; text-decoration: none; }
.cb-list { display: flex; flex-direction: column; align-items: stretch; }
.cb-list .cb-chip { font-size: 14px; min-height: 36px; margin-inline-start: 0; }
.cb-prog-row { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.cb-prog-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-prog-n { font-variant-numeric: tabular-nums; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-prog {
height: 6px; border-radius: 3px; overflow: hidden; margin: 8px 0 4px;
background: var(--tmd-surface-2, var(--divider-color, rgba(127, 127, 127, .2)));
}
.cb-prog > i { display: block; height: 100%; }
.cb-grp {
display: flex; align-items: center; gap: 6px; margin: 10px 0 2px;
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
color: var(--tmd-dim, var(--secondary-text-color));
}
.cb-grp ha-icon { --mdc-icon-size: 15px; }
.cb-week {
display: grid; grid-template-columns: 100px repeat(var(--cb-days), minmax(38px, 1fr));
gap: 6px; align-items: center; font-size: 12px;
}
.cb-wh { text-align: center; font-weight: 500; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-wh.today { color: var(--tmd-text, var(--primary-text-color)); font-weight: 600; }
.cb-wk { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 13px; }
.cb-wk span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-wc {
height: 32px; border-radius: 7px;
display: grid; place-items: center;
font-weight: 600; font-variant-numeric: tabular-nums;
background: color-mix(in srgb, var(--cb-kc) calc(15% + var(--cb-p) * .7%), var(--tmd-surface-2, rgba(127, 127, 127, .12)));
}
.cb-wc.none {
background: var(--tmd-surface-2, rgba(127, 127, 127, .12));
color: var(--tmd-dim, var(--secondary-text-color)); font-weight: 400;
}
.cb-hint { margin: 10px 0 0; font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
.cb-toast {
position: absolute; inset-inline: 12px; bottom: 12px; z-index: 3;
display: flex; align-items: center; gap: 12px;
padding-block: 8px; padding-inline: 14px 8px; border-radius: 8px;
background: #323232; color: #fff; font-size: 13px;
box-shadow: 0 4px 12px rgba(0, 0, 0, .4);
}
.cb-toast span { flex: 1; min-width: 0; }
.cb-toast button {
flex: none; background: none; border: 0; cursor: pointer;
color: var(--primary-color, #03a9f4); font: inherit; font-weight: 600;
padding: 6px 10px; min-height: 32px;
}
:host([data-tm-design="accessible"]) .cb-toast button { color: #ffd54f; text-decoration: underline; }
`;
// The token block is :host-scoped, so it only takes effect when included
// in THIS card's styles — a document-level rule cannot cross into the
// shadow root.
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
}
class TaskMateChoreBoardCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
setConfig(config) { this.config = config; }
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
_buildSchema() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const children = attrs.children || [];
return [
{ name: "entity", selector: { entity: { domain: "sensor" } } },
{ name: "title", selector: { text: {} } },
{
name: "children",
selector: { select: { multiple: true, mode: "list", options: children.map(c => ({ value: c.id, label: c.name })) } },
},
{
name: "default_view",
selector: {
select: {
mode: "dropdown",
options: [
{ value: "today", label: this._t("chore_board.today") },
{ value: "week", label: this._t("chore_board.this_week") },
],
},
},
},
{ name: "show_week", selector: { boolean: {} } },
{ name: "tap_to_complete", selector: { boolean: {} } },
{ name: "show_legend", selector: { boolean: {} } },
{ name: "hide_done_periods", selector: { boolean: {} } },
{
name: "card_design",
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: "global", label: "Use global default" }],
mode: "dropdown",
},
},
},
];
}
_computeLabel = (entry) => ({
entity: this._t("common.editor.overview_entity"),
title: this._t("common.editor.card_title"),
children: this._t("chore_board.editor.children"),
default_view: this._t("chore_board.editor.default_view"),
show_week: this._t("chore_board.editor.show_week"),
tap_to_complete: this._t("chore_board.editor.tap_to_complete"),
show_legend: this._t("chore_board.editor.show_legend"),
hide_done_periods: this._t("chore_board.editor.hide_done_periods"),
card_design: this._t("common.design.field_label"),
}[entry.name] ?? entry.name);
_computeHelper = (entry) => ({
entity: this._t("common.editor.overview_entity_helper"),
children: this._t("chore_board.editor.children_helper"),
tap_to_complete: this._t("chore_board.editor.tap_to_complete_helper"),
hide_done_periods: this._t("chore_board.editor.hide_done_periods_helper"),
}[entry.name] ?? "");
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || "",
title: this.config.title || "",
children: Array.isArray(this.config.children) ? this.config.children : [],
default_view: this.config.default_view || "today",
show_week: this.config.show_week !== false,
tap_to_complete: this.config.tap_to_complete !== false,
show_legend: this.config.show_legend !== false,
hide_done_periods: this.config.hide_done_periods === true,
card_design: this.config.card_design || "global",
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker("header_color", DEFAULT_ACCENT)}
`;
}
_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
const current = this.config[key] || defaultValue;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue, current,
label: this._t("common.editor.header_colour"),
helper: this._t("common.editor.header_colour_helper"),
resetLabel: this._t("common.reset"),
onInput: (v) => this._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}
// Defaults are the absence of a key, so a saved card only carries what was
// changed from them.
static _isDefault(key, value) {
if (value === "" || value === null || value === undefined) return true;
if (key === "card_design") return value === "global";
if (key === "default_view") return value === "today";
if (key === "children") return Array.isArray(value) && value.length === 0;
if (["show_week", "tap_to_complete", "show_legend"].includes(key)) return value === true;
if (key === "hide_done_periods") return value === false;
return false;
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
if (TaskMateChoreBoardCardEditor._isDefault(key, value)) delete newConfig[key];
else newConfig[key] = value;
}
this._fire(newConfig);
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === null || value === "" || value === undefined) delete newConfig[key];
this._fire(newConfig);
}
_fire(config) {
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config }, bubbles: true, composed: true,
}));
}
static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
.colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); }
.colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; }
.colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; }
.colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; }
.colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; }
.colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; }
.colour-presets { display: flex; gap: 6px; flex-wrap: wrap; }
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
}
customElements.define("taskmate-chore-board-card", TaskMateChoreBoardCard);
customElements.define("taskmate-chore-board-card-editor", TaskMateChoreBoardCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-chore-board-card",
name: "TaskMate Chore Board",
description: "Every child's chores for today by time of day — tap one to complete it",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-chore-board-card", "overview"),
});