117 files

This commit is contained in:
Home Assistant Version Control
2026-08-08 10:38:07 +00:00
parent c31830deeb
commit ca54a47de7
119 changed files with 9737 additions and 623 deletions
@@ -43,9 +43,11 @@ class TaskMateChildCard extends LitElement {
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
return window.__taskmate_hasChanged
const flashed = this._trackEarnedBadges();
const relevant = window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
return flashed || relevant;
}
return true;
}
@@ -66,19 +68,17 @@ class TaskMateChildCard extends LitElement {
// Badge strip state
this._earnedBadges = [];
this._justEarnedBadge = null;
this._badgeEventUnsub = null;
}
connectedCallback() {
super.connectedCallback();
this._subscribeBadgeEvents();
this._seenBadgeIds = null;
this._justEarnedTimeout = null;
}
disconnectedCallback() {
super.disconnectedCallback();
this._stopTimerTick();
this._badgeEventUnsub?.();
this._badgeEventUnsub = null;
if (this._justEarnedTimeout) {
clearTimeout(this._justEarnedTimeout);
this._justEarnedTimeout = null;
}
if (this._audioContext) {
this._audioContext.close().catch(() => {});
this._audioContext = null;
@@ -118,27 +118,37 @@ class TaskMateChildCard extends LitElement {
return fn ? fn(this.hass, key, params) : key;
}
_subscribeBadgeEvents() {
if (this._badgeEventUnsub || this._badgeSubscribing) return;
if (!this.hass?.connection) return;
this._badgeSubscribing = true;
this.hass.connection.subscribeEvents((event) => {
const data = event.data || {};
const configChild = this.config?.child_id;
if (configChild && data.child_id && String(data.child_id) !== String(configChild)) return;
if (data.badge_id) {
this._justEarnedBadge = String(data.badge_id);
this.requestUpdate();
setTimeout(() => { this._justEarnedBadge = null; this.requestUpdate(); }, 1800);
}
}, "taskmate_badge_earned").then(unsub => {
this._badgeSubscribing = false;
if (!this.isConnected) {
unsub();
return;
}
this._badgeEventUnsub = unsub;
}).catch(() => { this._badgeSubscribing = false; });
/* Newly-earned badges are detected by diffing this child's badges sensor
`earned` list across hass updates (#752). The card used to subscribe to the
custom `taskmate_badge_earned` event, but Home Assistant refuses a
custom-event subscription from a non-admin user — logging an error every
time — so the strip never lit up for a child. `state_changed` is
allowlisted for everyone, so the diff works for every user. */
_trackEarnedBadges() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const child = (attrs.children || []).find(c => c.id === this.config?.child_id)
|| (!this.config?.child_id && (attrs.children || [])[0]);
const earned = this._resolveBadgesEntity(child)?.attributes?.earned;
if (!Array.isArray(earned)) return false;
const ids = new Set(earned.map(b => window.__taskmate_badge_id(b)).filter(Boolean));
// First observation is the baseline — never flash a badge earned earlier.
if (this._seenBadgeIds === null) {
this._seenBadgeIds = ids;
return false;
}
const fresh = [...ids].filter(id => !this._seenBadgeIds.has(id));
this._seenBadgeIds = ids;
if (!fresh.length) return false;
this._justEarnedBadge = fresh[0];
clearTimeout(this._justEarnedTimeout);
this._justEarnedTimeout = setTimeout(() => {
this._justEarnedBadge = null;
this.requestUpdate();
}, 1800);
return true;
}
_tierColor(tier) {
@@ -874,7 +884,7 @@ class TaskMateChildCard extends LitElement {
overflow: hidden;
}
/* Chore number wrapper (icon removed) */
/* Chore number wrapper — holds the number-or-picture badge */
.chore-number-wrapper {
display: flex;
align-items: center;
@@ -902,6 +912,14 @@ class TaskMateChildCard extends LitElement {
flex-shrink: 0;
}
/* A chore picture sits where the digit would, so it has to carry the
same white-on-colour weight the numeral gets from its text-shadow. */
.chore-number-badge ha-icon {
--mdc-icon-size: 22px;
color: #fff;
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.25));
}
.chore-card:hover .chore-number-badge {
transform: rotate(5deg) scale(1.1);
}
@@ -1137,10 +1155,6 @@ class TaskMateChildCard extends LitElement {
filter: saturate(0.7);
}
.chore-card.completed .chore-icon-container {
background: rgba(255, 255, 255, 0.8);
}
.chore-card.completed .chore-name {
color: var(--primary-text-color);
opacity: 0.7;
@@ -1608,6 +1622,7 @@ class TaskMateChildCard extends LitElement {
.chore-card { padding: 10px 12px; gap: 8px; min-height: 54px; flex-wrap: nowrap; }
.chore-info { flex: 1; min-width: 0; overflow: hidden; }
.chore-number-badge { width: 30px; height: 30px; min-width: 30px; font-size: 1rem; }
.chore-number-badge ha-icon { --mdc-icon-size: 18px; }
.chore-name { font-size: 0.95rem; }
.chore-points { font-size: 0.82rem; }
.chore-checkbox { width: 34px; height: 34px; min-width: 34px; border-radius: 8px; }
@@ -1969,6 +1984,15 @@ class TaskMateChildCard extends LitElement {
.tmd-undochip[disabled] { opacity: 0.6; cursor: default; }
.q-undo { background: var(--tmd-surface-2); color: var(--tmd-dim); border: 1px solid var(--tmd-border); padding: 7px 10px; font-size: 14px; }
/* Designed: an explicit chore picture in the glyph slot. The emoji it
replaces is sized by font-size, which an ha-icon ignores, so each row
type restates its own size. Inherits the row accent so the icon reads
as part of the style rather than a pasted-in glyph. */
.tmd-glyph-icon { color: var(--ac, var(--tmd-accent)); display: block; }
.tmd-chore .ch-emoji .tmd-glyph-icon { --mdc-icon-size: 22px; }
.tmd-quest .q-emoji .tmd-glyph-icon { --mdc-icon-size: 19px; }
.tmd-check .c-emoji .tmd-glyph-icon { --mdc-icon-size: 18px; }
/* Designed: pending-points + countdown chips on the header/section */
.tmd-pending {
margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
@@ -2205,7 +2229,7 @@ class TaskMateChildCard extends LitElement {
<div class="badge-strip" @click=${() => this._openBadgesView()} title="${this._t('badges.label')}">
<span class="badge-strip-label">${this._t('badges.label')}</span>
${earnedBadges.slice(0, 5).map(b => html`
<div class="badge-mini tier-${b.tier} ${this._justEarnedBadge && String(this._justEarnedBadge) === String(b.id) ? 'just-earned' : ''}"
<div class="badge-mini tier-${b.tier} ${this._justEarnedBadge === window.__taskmate_badge_id(b) ? 'just-earned' : ''}"
style="--t: ${this._tierColor(b.tier)}">
<ha-icon icon="${b.icon || 'mdi:medal'}"></ha-icon>
</div>
@@ -2332,6 +2356,20 @@ class TaskMateChildCard extends LitElement {
return "⭐";
}
/** What a designed chore row shows in its glyph slot.
*
* An explicit picture wins over the keyword guess (#745) — otherwise
* choosing mdi:watering-can changed nothing, because _choreEmoji only ever
* matched on words. The guess stays as the fallback so chores with no
* picture, which is all of them until someone sets one, look as they did.
* The icon inherits the row's accent so it reads as part of the style.
*/
_choreGlyph(chore) {
return chore.icon
? html`<ha-icon icon="${chore.icon}" class="tmd-glyph-icon"></ha-icon>`
: this._choreEmoji(chore);
}
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
_isChoreDone(chore, child, todaysCompletions) {
const childCompletionsToday = (todaysCompletions || []).filter(
@@ -2411,7 +2449,7 @@ class TaskMateChildCard extends LitElement {
return {
chore, child, done, loading, onAct, index: i, dimmed,
tone: this._designTone(i),
emoji: this._choreEmoji(chore),
glyph: this._choreGlyph(chore),
points: chore.effective_points ?? chore.points,
timed: chore.task_type === "timed",
mandatory: chore.mandatory === true,
@@ -2465,7 +2503,7 @@ class TaskMateChildCard extends LitElement {
<div class="tmd-badges" @click=${() => this._openBadgesView()} title="${this._t("badges.label")}">
<span class="lbl">${this._t("badges.label")}</span>
${earnedBadges.slice(0, 5).map(b => html`
<div class="tmd-badge-mini ${this._justEarnedBadge && String(this._justEarnedBadge) === String(b.id) ? "just-earned" : ""}"
<div class="tmd-badge-mini ${this._justEarnedBadge === window.__taskmate_badge_id(b) ? "just-earned" : ""}"
style="--t:${this._tierColor(b.tier)}">
<ha-icon icon="${b.icon || "mdi:medal"}"></ha-icon>
</div>`)}
@@ -2569,7 +2607,7 @@ class TaskMateChildCard extends LitElement {
${rows.map(r => r.timed ? this._designTimed(r) : html`
<div class="tmd-chore ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
<div class="num-badge" style="${r.done ? "--ac:var(--tmd-good)" : ""}">${r.done ? "✓" : r.index + 1}</div>
<span class="ch-emoji">${r.emoji}</span>
<span class="ch-emoji">${r.glyph}</span>
<div class="ch-mid">
<div class="ch-name">${r.chore.name}</div>
${r.done ? "" : html`<div class="chip soft" style="margin-top:3px">+${r.points} ⭐</div>`}
@@ -2589,7 +2627,7 @@ class TaskMateChildCard extends LitElement {
${rows.map(r => r.timed ? this._designTimed(r) : html`
<div class="tmd-quest ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
<div class="num q-num" style="${r.done ? "color:var(--tmd-good)" : ""}">${r.done ? "✓" : String(r.index + 1).padStart(2, "0")}</div>
<span class="q-emoji">${r.emoji}</span>
<span class="q-emoji">${r.glyph}</span>
<div class="q-mid">
<div class="q-name">${r.chore.name}</div>
${r.done
@@ -2611,7 +2649,7 @@ class TaskMateChildCard extends LitElement {
${rows.map(r => r.timed ? this._designTimed(r) : html`
<div class="tmd-check ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
<div class="c-num" style="${r.done ? "--ac:var(--tmd-good)" : ""}">${r.done ? "✓" : r.index + 1}</div>
<span class="c-emoji">${r.emoji}</span>
<span class="c-emoji">${r.glyph}</span>
<div class="c-mid">
<div class="c-name">${r.chore.name}</div>
${this._designChoreMeta(r)}
@@ -3289,6 +3327,23 @@ class TaskMateChildCard extends LitElement {
`;
}
/** The coloured badge at the head of a chore row.
*
* A chore's picture takes the digit's place when one is set (#745) — the
* number is only positional, whereas the icon is what a child actually
* recognises. Chores with no picture keep their number, which is every
* chore that predates the field. Shared by the standard and timed rows so
* a picture can't work on one and be invisible on the other.
*/
_choreNumberBadge(chore, colorClass, choreNumber) {
return html`
<div class="chore-number-badge ${colorClass}">
${chore.icon
? html`<ha-icon icon="${chore.icon}"></ha-icon>`
: choreNumber}
</div>`;
}
_renderChoreCard(chore, child, pointsIcon, todaysCompletions = [], choreIndex = 0) {
const isLoading = this._loading[chore.id];
const isCelebrating = this._celebrating === chore.id;
@@ -3398,7 +3453,7 @@ class TaskMateChildCard extends LitElement {
>
<div class="chore-info">
<div class="chore-number-wrapper">
<div class="chore-number-badge ${colorClass}">${choreNumber}</div>
${this._choreNumberBadge(chore, colorClass, choreNumber)}
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}${chore.mandatory ? html`<span class="mandatory-badge">⚠ ${this._t('child.mandatory')}</span>` : ''}</div>
@@ -3525,7 +3580,7 @@ class TaskMateChildCard extends LitElement {
<div class="timed-chore-card ${state} ${isLoading ? 'loading' : ''}">
<div class="timed-top-row">
<div class="chore-number-wrapper">
<div class="chore-number-badge ${colorClass}">${choreNumber}</div>
${this._choreNumberBadge(chore, colorClass, choreNumber)}
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}</div>