187 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-09 11:57:31 +00:00
1 parent 28fa34d78a
commit 1e1b12d52b
187 files changed
+40899 -32654

No files matched your search

@@ -11,6 +11,9 @@
* no complete-on-behalf, no points adjustments, no reward claiming, no
* settings — there is no option to turn them on.
*
* Each completion gets the child card's feedback (#1039): the chore's
* completion sound, confetti and a short popup — each can be switched off.
*
* PINs are set by a parent in the admin panel and checked on the server
* (taskmate/kiosk/verify_pin, rate-limited); the card never sees a PIN or its
* hash, and only remembers which child is unlocked until it returns to the
@@ -52,6 +55,10 @@ const MAX_TIMEOUT = 600;
const WARN_SECONDS = 10;
// How long the all-done celebration stays up before the picker comes back.
const CELEBRATE_MS = 4000;
// How long the per-chore popup and its confetti stay up (#1039), as on the child card.
const POPUP_MS = 2500;
const CONFETTI_MS = 3500;
const CONFETTI_COLOURS = ["#ff6b9d", "#9b59b6", "#3498db", "#2ecc71", "#f1c40f", "#e67e22"];
const PIN_LENGTH = 4;
// The same six-colour ring the child card uses for per-child tones.
const CHILD_FALLBACK = ["#ff7043", "#42a5f5", "#66bb6a", "#ab47bc", "#ffa726", "#26c6da"];
@@ -76,6 +83,8 @@ class TaskMateKioskCard extends LitElement {
_idle: { type: Number },
_busy: { type: String },
_celebrating: { type: Boolean },
_popup: { type: Object },
_confetti: { type: Array },
_fullscreen: { type: Boolean },
};
}
@@ -94,6 +103,8 @@ class TaskMateKioskCard extends LitElement {
this._busy = "";
this._celebrating = false;
this._armCelebrate = false;
this._popup = null;
this._confetti = [];
this._fullscreen = false;
this._now = new Date();
// Any touch or key press counts as activity and restarts the idle timer.
@@ -137,6 +148,8 @@ class TaskMateKioskCard extends LitElement {
this._ticker = null;
clearTimeout(this._statusTimer);
clearTimeout(this._celebrateTimer);
clearTimeout(this._popupTimer);
clearTimeout(this._confettiTimer);
clearTimeout(this._undoExpiryTimer);
if (typeof document !== "undefined") document.removeEventListener?.("fullscreenchange", this._onFullscreen);
// Leaving the dashboard locks the kiosk again.
@@ -404,6 +417,10 @@ class TaskMateKioskCard extends LitElement {
_resetToPicker() {
clearTimeout(this._celebrateTimer);
clearTimeout(this._popupTimer);
clearTimeout(this._confettiTimer);
this._popup = null;
this._confetti = [];
this._screen = "picker";
this._kid = null;
this._pin = "";
@@ -476,6 +493,7 @@ class TaskMateKioskCard extends LitElement {
} else {
await this.hass.callService("taskmate", "complete_chore", { chore_id: chore.id, child_id: child.id });
}
this._cheer(chore);
} catch (err) {
this._notify(String(err?.message || err));
} finally {
@@ -492,6 +510,7 @@ class TaskMateKioskCard extends LitElement {
await this.hass.callService("taskmate", "undo_chore", {
completion_id: completion.completion_id || completion.id,
});
if (this.config.play_sounds !== false) this._playSound(this.config.undo_sound || "undo");
} catch (err) {
this._notify(String(err?.message || err));
} finally {
@@ -500,6 +519,40 @@ class TaskMateKioskCard extends LitElement {
}
}
// ── Completion feedback (#1039) ──────────────────────────────────────────
/** The child card's reward for a tap: the chore's sound, confetti, a popup. */
_cheer(chore) {
if (this.config.play_sounds !== false) {
this._playSound(chore.completion_sound || this.config.default_sound || "coin");
}
if (this.config.show_confetti !== false) this._spawnConfetti();
if (this.config.show_completion_popup !== false) {
this._popup = { points: this._pointsFor(chore) };
clearTimeout(this._popupTimer);
this._popupTimer = setTimeout(() => { this._popup = null; }, POPUP_MS);
}
}
/** Shared engine in taskmate-sounds.js; custom sounds (#856) resolve
* through the custom_sounds table the attr resolver merges in. */
_playSound(name) {
const engine = window.__taskmate_sounds;
if (!engine) return;
engine.play(name, this._attrs().custom_sounds || []);
}
_spawnConfetti() {
this._confetti = Array.from({ length: 50 }, () => ({
x: Math.random() * 100,
delay: Math.random() * 0.5,
size: Math.random() * 8 + 6,
round: Math.random() > 0.5,
}));
clearTimeout(this._confettiTimer);
this._confettiTimer = setTimeout(() => { this._confetti = []; }, CONFETTI_MS);
}
/** Straight back to the picker once a child finishes their last chore here. */
_maybeCelebrate() {
if (!this._armCelebrate || this._celebrating || this._screen !== "child") return;
@@ -509,6 +562,9 @@ class TaskMateKioskCard extends LitElement {
if (!total || done < total) return;
this._armCelebrate = false;
this._celebrating = true;
// The all-done overlay is the bigger party: it replaces the chore popup.
clearTimeout(this._popupTimer);
this._popup = null;
clearTimeout(this._celebrateTimer);
this._celebrateTimer = setTimeout(() => this._toPicker(), CELEBRATE_MS);
}
@@ -581,6 +637,10 @@ class TaskMateKioskCard extends LitElement {
</button>` : ""}
</div>
<div class="km-main">${body}</div>
${this._confetti.length ? html`
<div class="km-confetti" aria-hidden="true">
${this._confetti.map((p, i) => html`<i style="inset-inline-start:${p.x}%;animation-delay:${p.delay}s;background:${CONFETTI_COLOURS[i % CONFETTI_COLOURS.length]};border-radius:${p.round ? "50%" : "0"};width:${p.size}px;height:${p.size}px"></i>`)}
</div>` : ""}
</ha-card>
`;
}
@@ -684,7 +744,7 @@ class TaskMateKioskCard extends LitElement {
const showPoints = this.config.show_points !== false;
const showStreak = this.config.show_streak !== false;
const showReward = this.config.show_reward_progress !== false;
const warn = this.config.warn_before_return !== false && left <= WARN_SECONDS && left > 0 && !this._celebrating;
const warn = this.config.warn_before_return !== false && left <= WARN_SECONDS && left > 0 && !this._celebrating && !this._popup;
const earnedToday = (attrs.todays_completions || [])
.filter(c => String(c.child_id) === String(child.id))
.reduce((sum, c) => sum + (Number(c.points) || 0), 0);
@@ -756,6 +816,15 @@ class TaskMateKioskCard extends LitElement {
<ha-icon icon="mdi:hand-wave"></ha-icon> ${this._t("kiosk.still_here")}
</button>
</div>` : ""}
${this._popup && !this._celebrating ? html`
<div class="km-idle km-pop" role="status" @click=${() => { this._popup = null; }}>
<div class="km-pop-card">
<div class="km-pop-stars">🌟🌟🌟</div>
<h2>${this._t("child.celebration_title")}</h2>
<p>${this._t("child.celebration_message")}</p>
${showPoints ? html`<div class="km-pop-pts"><ha-icon icon="${icon}"></ha-icon> +${this._popup.points}</div>` : ""}
</div>
</div>` : ""}
${this._celebrating ? html`
<div class="km-idle km-party">
<div class="km-cheer">🎉</div>
@@ -1148,6 +1217,36 @@ class TaskMateKioskCard extends LitElement {
}
.km-cheer { font-size: 96px; line-height: 1; }
/* Per-chore popup + confetti (#1039), after the child card's */
.km-pop { cursor: pointer; animation: km-fade 0.3s ease; }
.km-pop-card {
background: var(--tmd-surface, var(--card-background-color, #fff));
border-radius: calc(var(--tmd-radius, 16px) + 14px); padding: 36px 48px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
animation: km-pop-in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.km-pop-stars { font-size: 3.5rem; line-height: 1; margin-bottom: 12px; animation: km-star 0.6s ease infinite; }
.km-pop h2 { color: var(--tmd-accent, #9b59b6); font-size: 2.4rem; }
.km-pop p { margin: 8px 0 14px; }
.km-pop-pts {
display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 1.8rem; font-weight: 800; color: var(--tmd-gold, #e67e22);
}
.km-pop-pts ha-icon { --mdc-icon-size: 28px; }
.km-confetti { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 4; }
.km-confetti i { position: absolute; top: 0; animation: km-confetti 3s linear forwards; }
@keyframes km-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes km-pop-in { from { transform: scale(0); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes km-star { 0%, 100% { transform: scale(1) rotate(0deg); } 50% { transform: scale(1.2) rotate(10deg); } }
@keyframes km-confetti {
0% { transform: translateY(-100px) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.km-confetti { display: none; }
.km-pop, .km-pop-card, .km-pop-stars { animation: none; }
}
.km-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px; text-align: center; color: var(--tmd-dim, var(--secondary-text-color)); }
.km-empty ha-icon { --mdc-icon-size: 56px; opacity: 0.5; }
@@ -1217,6 +1316,9 @@ class TaskMateKioskCardEditor extends LitElement {
{ name: "show_streak", selector: { boolean: {} } },
{ name: "show_reward_progress", selector: { boolean: {} } },
{ name: "show_picker_progress", selector: { boolean: {} } },
{ name: "play_sounds", selector: { boolean: {} } },
{ name: "show_confetti", selector: { boolean: {} } },
{ name: "show_completion_popup", selector: { boolean: {} } },
];
}
@@ -1230,6 +1332,9 @@ class TaskMateKioskCardEditor extends LitElement {
show_streak: this._t("kiosk.editor.show_streak"),
show_reward_progress: this._t("kiosk.editor.show_reward_progress"),
show_picker_progress: this._t("kiosk.editor.show_picker_progress"),
play_sounds: this._t("kiosk.editor.play_sounds"),
show_confetti: this._t("kiosk.editor.show_confetti"),
show_completion_popup: this._t("kiosk.editor.show_completion_popup"),
})[entry.name] ?? entry.name;
_computeHelper = (entry) => ({
@@ -1237,6 +1342,8 @@ class TaskMateKioskCardEditor extends LitElement {
timeout: this._t("kiosk.editor.timeout_helper"),
warn_before_return: this._t("kiosk.editor.warn_before_return_helper"),
show_picker_progress: this._t("kiosk.editor.show_picker_progress_helper"),
play_sounds: this._t("kiosk.editor.play_sounds_helper"),
show_completion_popup: this._t("kiosk.editor.show_completion_popup_helper"),
})[entry.name] ?? "";
/** Every child, configured ones first in their order, each with shown/require_pin. */
@@ -1295,6 +1402,9 @@ class TaskMateKioskCardEditor extends LitElement {
show_streak: this.config.show_streak !== false,
show_reward_progress: this.config.show_reward_progress !== false,
show_picker_progress: this.config.show_picker_progress !== false,
play_sounds: this.config.play_sounds !== false,
show_confetti: this.config.show_confetti !== false,
show_completion_popup: this.config.show_completion_popup !== false,
};
const rows = this._childRows();
const pins = this._pins || {};