348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
@@ -91,9 +91,17 @@ class TaskMateChildCard extends LitElement {
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const sessions = attrs.active_timed_sessions || [];
const hasRunning = sessions.some(s => s.state === 'running' && s.child_id === this.config?.child_id);
if (hasRunning && !this._timerInterval) {
// A reactive chore's countdown (#674) has to keep ticking too, otherwise it
// sits on a stale minute until the next coordinator push.
const now = Date.now();
const hasDeadline = (attrs.chores || []).some(c => {
if (!c.deadline_at) return false;
const at = new Date(c.deadline_at).getTime();
return !Number.isNaN(at) && at > now;
});
if ((hasRunning || hasDeadline) && !this._timerInterval) {
this._timerInterval = setInterval(() => { this._timerTick++; this.requestUpdate(); }, 1000);
} else if (!hasRunning && this._timerInterval) {
} else if (!hasRunning && !hasDeadline && this._timerInterval) {
this._stopTimerTick();
}
}
@@ -600,6 +608,23 @@ class TaskMateChildCard extends LitElement {
width: 18px; height: 18px; border-radius: 50%;
background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;
}
/* Designed-header avatar wrapper: mirror of the classic avatar-container
so the picker works under every design, not just classic. The picker
itself (.avatar-picker) is absolute, so the header is the positioned
anchor. Base styles here win over the shared kit's .tmd-hd because the
card includes them after the kit in static styles(). */
.tmd-hd { position: relative; }
.tmd-av-wrap { position: relative; display: inline-flex; align-items: center; flex: none; }
.tmd-av-wrap.avatar-clickable { cursor: pointer; }
.tmd-av-edit {
position: absolute; bottom: -2px; right: -2px;
width: 15px; height: 15px; border-radius: 50%;
background: var(--tmd-accent, #7c3aed); color: #fff;
display: grid; place-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.tmd-av-edit ha-icon { --mdc-icon-size: 10px; color: #fff; }
.avatar-edit-dot ha-icon { --mdc-icon-size: 12px; color: white; }
.avatar-picker {
position: absolute; z-index: 20; margin-top: 56px;
@@ -1185,6 +1210,121 @@ class TaskMateChildCard extends LitElement {
width: 6px;
background: var(--fun-red);
}
/* Pre-reader mode (#683) — picture-only, huge targets */
.pre-reader-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 14px;
padding: 4px 0 8px;
}
.pre-tile {
position: relative;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 8px;
min-height: 140px; padding: 16px 10px;
border: 3px solid var(--divider-color, #e0e0e0);
border-radius: 26px;
background: var(--card-background-color, #fff);
font-family: inherit; cursor: pointer;
transition: transform 0.12s ease, border-color 0.12s ease;
}
.pre-tile:active { transform: scale(0.96); }
.pre-tile:focus-visible { outline: 4px solid var(--primary-color); outline-offset: 3px; }
.pre-tile-icon ha-icon { --mdc-icon-size: 64px; color: var(--primary-color); }
.pre-tile-stars { display: flex; gap: 2px; }
.pre-tile-stars ha-icon { --mdc-icon-size: 18px; color: #f1c40f; }
.pre-tile-label {
font-size: 0.85rem; font-weight: 700; text-align: center;
color: var(--secondary-text-color); line-height: 1.2;
}
.pre-tile.done {
border-color: var(--success-color, #2ecc71);
background: rgba(46, 204, 113, 0.10);
}
.pre-tile.done .pre-tile-icon ha-icon { opacity: 0.35; }
.pre-tile.done .pre-tile-stars { opacity: 0.35; }
.pre-tile-tick {
position: absolute; inset: 0;
display: grid; place-items: center;
pointer-events: none;
}
.pre-tile-tick ha-icon {
--mdc-icon-size: 72px;
color: var(--success-color, #2ecc71);
}
.pre-tile.locked { opacity: 0.4; cursor: default; }
.pre-tile.loading { opacity: 0.6; }
/* Chore roulette (#677) */
.roulette {
display: flex; flex-direction: column; gap: 8px;
margin: 4px 0 12px;
}
/* The --tmd-* tokens only resolve under a designed style; the fallbacks
are the original classic values, so classic is unchanged. */
.roulette-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
background: var(--tmd-accent, linear-gradient(135deg, #8e44ad, #c0392b));
color: #fff; border: 0;
border-radius: var(--tmd-radius-sm, 14px);
padding: 12px 16px;
font-family: var(--tmd-font-display, inherit); font-size: 0.95rem;
font-weight: 800; cursor: pointer; width: 100%;
}
.roulette-btn:disabled { opacity: 0.55; cursor: default; }
.roulette-btn.is-spinning ha-icon { animation: tm-roulette-spin 0.6s linear infinite; }
@keyframes tm-roulette-spin { to { transform: rotate(360deg); } }
.roulette-result {
display: flex; align-items: center; gap: 8px;
background: color-mix(in srgb, var(--tmd-accent, #8e44ad) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--tmd-accent, #8e44ad) 35%, transparent);
border-radius: var(--tmd-radius-sm, 12px); padding: 8px 12px;
}
.roulette-mult {
background: var(--tmd-accent, #8e44ad); color: #fff;
border-radius: var(--tmd-radius-sm, 8px); padding: 2px 8px;
font-weight: 900; font-size: 0.85rem;
}
.roulette-name { flex: 1; font-weight: 700; font-size: 0.95rem; }
.roulette-worth {
display: inline-flex; align-items: center; gap: 4px;
font-weight: 800; color: var(--tmd-accent, #8e44ad);
}
.roulette-worth ha-icon { --mdc-icon-size: 16px; }
.roulette-spent {
text-align: center; font-size: 0.8rem;
color: var(--tmd-dim, var(--secondary-text-color)); opacity: 0.8;
}
/* Reactive-chore countdown (#674). Amber by default, red under 5 min.
On its own line, not inline in .chore-name — that name ellipsises, and
a long one clipped the badge down to just the bonus. */
.deadline-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
align-self: flex-start;
background: rgba(255, 152, 0, 0.18);
color: #ef6c00;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
border-radius: 8px;
padding: 2px 7px;
white-space: nowrap;
width: fit-content;
}
.deadline-badge.deadline-urgent {
background: var(--fun-red);
color: #fff;
}
.deadline-bonus {
opacity: 0.85;
font-weight: 900;
}
.mandatory-badge {
display: inline-flex;
align-items: center;
@@ -2091,7 +2231,13 @@ class TaskMateChildCard extends LitElement {
` : '';
})() : ''}
</div>
${childChores.map((chore, index) => html`
${this._renderRoulette(child, childChores, pointsIcon)}
${this.config.pre_reader === true ? html`
<div class="pre-reader-grid">
${childChores.map((chore, index) =>
this._renderPreReaderTile(chore, child, todaysCompletions, index))}
</div>
` : childChores.map((chore, index) => html`
${chore.task_type === 'timed'
? this._renderTimedChoreCard(chore, child, pointsIcon, todaysCompletions, index)
: html`
@@ -2292,10 +2438,20 @@ class TaskMateChildCard extends LitElement {
<ha-icon icon="mdi:clock-outline"></ha-icon>${countdown.label}</span>` : ""}
</div>`;
const body =
design === "playroom" ? this._designPlayroom(child, rows, remaining, tone) :
design === "console" ? this._designConsole(child, rows, remaining, tone) :
this._designCleanpro(child, rows, remaining, tone);
// Pre-reader mode replaces the chore list with picture tiles. It has to be
// handled here as well as in the classic path, or `pre_reader: true` is
// silently ignored under every designed style — including accessible,
// which is the one a child who needs picture tiles is most likely on.
// The tiles keep the designed shell (header, tokens) around them.
const body = this.config.pre_reader === true
? html`
<div class="pre-reader-grid">
${childChores.map((chore, index) =>
this._renderPreReaderTile(chore, child, todaysCompletions, index))}
</div>`
: design === "playroom" ? this._designPlayroom(child, rows, remaining, tone) :
design === "console" ? this._designConsole(child, rows, remaining, tone) :
this._designCleanpro(child, rows, remaining, tone);
return html`<ha-card class="tmd" style="--hd:${hd}">
${this._designHeaderFull(child, design, remaining, rows.length, tone, pendingPoints, pointsIcon)}
@@ -2316,6 +2472,7 @@ class TaskMateChildCard extends LitElement {
${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""}
</div>` : ""}
${sectionLine}
${this._renderRoulette(child, childChores, pointsIcon)}
${body}
${this._renderSwappable(allChores, child, pointsIcon)}
</div>
@@ -2336,13 +2493,25 @@ class TaskMateChildCard extends LitElement {
? (child.level ? `${this._t("child.level_label", { level: child.level })} · ${remaining} ACTIVE` : `${remaining} ACTIVE`)
: design === "cleanpro" ? `${remaining} / ${total}`
: `${remaining} · ${this._t("child.todays_chores")}`;
// Avatar picker: classic makes the avatar tappable; the designed header
// must too, or the picker only ever works on classic. Same eligibility
// rule (allow_avatar_change + more than one unlocked option).
const opts = child.avatar_options || [];
const canChange = this.config.allow_avatar_change !== false
&& opts.filter(o => o.unlocked).length > 1;
return html`
<div class="tmd-hd">
${this._av(child, tone, 34)}
<div class="tmd-av-wrap ${canChange ? "avatar-clickable" : ""}"
@click=${canChange ? () => this._toggleAvatarPicker() : null}
title="${canChange ? this._t("child.avatar_change") : ""}">
${this._av(child, tone, 34)}
${canChange ? html`<span class="tmd-av-edit"><ha-icon icon="mdi:pencil"></ha-icon></span>` : ""}
</div>
<span class="tt">${title}<small>${sub}</small></span>
${remaining === 0 && total > 0 ? html`<span class="pill">🎉</span>` : ""}
${pendingPoints > 0 ? html`<span class="tmd-pending">
<ha-icon icon="mdi:timer-sand"></ha-icon>+${pendingPoints}</span>` : ""}
${canChange && this._avatarPickerOpen ? this._renderAvatarPicker(child, opts) : ""}
</div>`;
}
@@ -2982,6 +3151,144 @@ class TaskMateChildCard extends LitElement {
`;
}
/**
* Countdown badge for a reactive chore (#674) — "empty the washing machine
* within 30 minutes". Reads the deadline fresh on every render, so it ticks
* down with the coordinator's updates. Hidden once the chore is done.
*/
_renderDeadlineBadge(chore, isCompletedForToday) {
if (!chore.deadline_at || isCompletedForToday) return '';
const deadline = new Date(chore.deadline_at);
if (Number.isNaN(deadline.getTime())) return '';
const msLeft = deadline.getTime() - Date.now();
if (msLeft <= 0) return '';
const minsLeft = Math.ceil(msLeft / 60000);
const label = minsLeft >= 60
? this._t('child.deadline_hours', { hours: Math.floor(minsLeft / 60), mins: minsLeft % 60 })
: this._t('child.deadline_minutes', { mins: minsLeft });
// Under five minutes is the point at which it's worth shouting about.
const urgent = minsLeft <= 5;
const bonus = chore.speed_bonus_points || 0;
return html`
<span class="deadline-badge ${urgent ? 'deadline-urgent' : ''}"
title="${this._t('child.deadline_title')}">
${label}${bonus ? html` <span class="deadline-bonus">+${bonus}</span>` : ''}
</span>
`;
}
/**
* Chore roulette (#677) — an opt-in nudge for the child who has stalled.
* Only renders when the parent enabled it in settings AND the card opts in,
* so an existing dashboard never sprouts a new button unasked.
*/
_renderRoulette(child, childChores, pointsIcon) {
if (this.config.show_roulette !== true) return '';
const roulette = child.roulette;
if (!roulette) return ''; // switched off in settings
const picked = roulette.chore_id
? childChores.find(c => String(c.id) === String(roulette.chore_id))
: null;
const spinsLeft = roulette.spins_left ?? 0;
const multiplier = roulette.multiplier || 2;
const spinning = this._spinning === child.id;
return html`
<div class="roulette">
${picked ? html`
<div class="roulette-result">
<span class="roulette-mult">×${multiplier}</span>
<span class="roulette-name">${picked.name}</span>
<span class="roulette-worth">
<ha-icon icon="${pointsIcon}"></ha-icon>
${Math.round((picked.effective_points ?? picked.points ?? 0) * multiplier)}
</span>
</div>
` : ''}
${spinsLeft > 0 ? html`
<button class="roulette-btn ${spinning ? 'is-spinning' : ''}"
?disabled=${spinning || childChores.length === 0}
@click=${() => this._spinRoulette(child)}>
<ha-icon icon="mdi:dice-multiple"></ha-icon>
${picked
? this._t('child.roulette_respin', { count: spinsLeft })
: this._t('child.roulette_spin', { multiplier })}
</button>
` : html`
<div class="roulette-spent">${this._t('child.roulette_no_spins')}</div>
`}
</div>
`;
}
async _spinRoulette(child) {
if (this._spinning) return;
this._spinning = child.id;
this.requestUpdate();
try {
await this.hass.callService('taskmate', 'spin_roulette', { child_id: child.id });
} catch (err) {
// "No spins left" / "Nothing left to spin for" arrive here as
// ServiceValidationError — show them rather than failing silently.
this.dispatchEvent(new CustomEvent('hass-notification', {
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
}));
} finally {
this._spinning = null;
this.requestUpdate();
}
}
/**
* Pre-reader mode (#683): a picture-only tile for children who can't read
* yet. No chore name, no numbers — a big icon, a row of stars for the
* points, and a huge tick when it's done.
*/
_renderPreReaderTile(chore, child, todaysCompletions = [], choreIndex = 0) {
const dailyLimit = chore.daily_limit || 1;
// Counted the same way the standard row does: pending completions hold a
// place too, and a parent completing on behalf lands under "__parent__".
const childCompletionsToday = todaysCompletions.filter(
(comp) => comp.chore_id === chore.id
&& (comp.child_id === child.id || comp.child_id === "__parent__")
&& !comp.bonus_subtask_id
);
const isDone = childCompletionsToday.length >= dailyLimit;
const isLoading = this._loading[chore.id];
const available = chore._isAvailableForChild !== false && !chore._isLockedPreview;
// Stars, not digits: a 4-year-old can count pictures, not read "+3".
const points = chore.effective_points ?? chore.points ?? 0;
const stars = Math.max(1, Math.min(5, Math.round(points / 2) || 1));
const icon = chore.icon
|| this._getTimeCategoryIcon(chore.time_category)
|| 'mdi:checkbox-marked-circle-outline';
return html`
<button
class="pre-tile ${isDone ? 'done' : ''} ${isLoading ? 'loading' : ''} ${available ? '' : 'locked'}"
?disabled=${isLoading || !available}
aria-label="${chore.name}"
title="${chore.name}"
@click=${() => (isDone
? this._handleUndo(chore, child, childCompletionsToday)
: this._handleComplete(chore, child))}
>
<div class="pre-tile-icon"><ha-icon icon="${icon}"></ha-icon></div>
${isDone ? html`<div class="pre-tile-tick"><ha-icon icon="mdi:check-bold"></ha-icon></div>` : ''}
<div class="pre-tile-stars">
${Array.from({ length: stars }, () => html`<ha-icon icon="mdi:star"></ha-icon>`)}
</div>
${this.config.pre_reader_labels === true
? html`<div class="pre-tile-label">${chore.name}</div>` : ''}
</button>
`;
}
_renderChoreCard(chore, child, pointsIcon, todaysCompletions = [], choreIndex = 0) {
const isLoading = this._loading[chore.id];
const isCelebrating = this._celebrating === chore.id;
@@ -3095,6 +3402,7 @@ class TaskMateChildCard extends LitElement {
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}${chore.mandatory ? html`<span class="mandatory-badge">⚠ ${this._t('child.mandatory')}</span>` : ''}</div>
${this._renderDeadlineBadge(chore, isCompletedForToday)}
${chore.difficulty ? html`<span class="difficulty-badge difficulty-${chore.difficulty}">${this._t('child.difficulty_' + chore.difficulty) || chore.difficulty}</span>` : ''}
${this.config.show_description && chore.description ? html`
<div class="chore-description">${chore.description}</div>
@@ -4133,6 +4441,8 @@ class TaskMateChildCardEditor extends LitElement {
},
{ name: 'show_countdown', selector: { boolean: {} } },
{ name: 'show_description', selector: { boolean: {} } },
{ name: 'pre_reader', selector: { boolean: {} } },
{ name: 'pre_reader_labels', selector: { boolean: {} } },
{ name: 'debug', selector: { boolean: {} } },
];
}
@@ -4148,6 +4458,8 @@ class TaskMateChildCardEditor extends LitElement {
elapsed_time_mode: this._t('child.editor.missed_time_chores'),
show_countdown: this._t('child.editor.show_countdown'),
show_description: this._t('child.editor.show_description'),
pre_reader: this._t('child.editor.pre_reader'),
pre_reader_labels: this._t('child.editor.pre_reader_labels'),
debug: this._t('child.editor.show_debug'),
};
return labels[entry.name] ?? entry.name;
@@ -4178,6 +4490,8 @@ class TaskMateChildCardEditor extends LitElement {
elapsed_time_mode: this.config.elapsed_time_mode || 'dim',
show_countdown: this.config.show_countdown !== false,
show_description: this.config.show_description === true,
pre_reader: this.config.pre_reader === true,
pre_reader_labels: this.config.pre_reader_labels === true,
debug: this.config.debug === true,
};