348 files
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user