Files
Home-Assistant/custom_components/taskmate/www/taskmate-child-card.js
T

4271 lines
158 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TaskMate Child Card
* A FUN, kid-friendly Lovelace card for completing chores!
* Designed for children ages 5-10 with big buttons, bright colors, and celebrations!
*
* Version: 0.0.1 - Checkbox-style completion
* Last Updated: 2026-01-01
*
* Features:
* - Fun synthesized completion sounds (coin, levelup, fanfare, chime, powerup, undo)
* - No external sound files needed - all sounds generated via Web Audio API
* - Per-chore sound configuration
* - Card-level default_sound and undo_sound config options
* - Chore numbers with colorful, kid-friendly badges
* - Clickable chore rows with checkbox visual indicator
*/
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);
class TaskMateChildCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_loading: { type: Object },
_celebrating: { type: String },
_confetti: { type: Array },
_optimisticCompletions: { type: Object },
_earnedBadges: { type: Array },
_justEarnedBadge: { type: String },
_avatarPickerOpen: { type: Boolean },
_photoCapture: { type: Object },
};
}
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
return window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
}
return true;
}
constructor() {
super();
this._loading = {};
this._celebrating = null;
this._confetti = [];
// Optimistic completions: track chores that were just completed
// These are used to immediately hide the DONE button before the server confirms
this._optimisticCompletions = {};
// Audio context for generating sounds (lazy initialized)
this._audioContext = null;
// Timer intervals for live counters (timed tasks)
this._timerInterval = null;
this._timerTick = 0;
// Badge strip state
this._earnedBadges = [];
this._justEarnedBadge = null;
this._badgeEventUnsub = null;
}
connectedCallback() {
super.connectedCallback();
this._subscribeBadgeEvents();
}
disconnectedCallback() {
super.disconnectedCallback();
this._stopTimerTick();
this._badgeEventUnsub?.();
this._badgeEventUnsub = null;
if (this._audioContext) {
this._audioContext.close().catch(() => {});
this._audioContext = null;
}
}
updated(changedProperties) {
super.updated(changedProperties);
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| 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) {
this._timerInterval = setInterval(() => { this._timerTick++; this.requestUpdate(); }, 1000);
} else if (!hasRunning && this._timerInterval) {
this._stopTimerTick();
}
}
_stopTimerTick() {
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
}
_t(key, params) {
const fn = window.__taskmate_localize;
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; });
}
_tierColor(tier) {
return {
bronze: '#cd7f32',
silver: '#c0c0c0',
gold: '#f1c40f',
platinum: '#67e8f9',
}[tier] || '#888';
}
_openBadgesView() {
const slug = this.config?.child_id
? String(this.config.child_id).toLowerCase().replace(/\s+/g, '_')
: '';
window.history.pushState(null, '', `/taskmate-admin?section=badges${slug ? '&child=' + slug : ''}`);
window.dispatchEvent(new PopStateEvent('popstate'));
}
_toggleAvatarPicker() {
this._avatarPickerOpen = !this._avatarPickerOpen;
this.requestUpdate();
}
_renderAvatarPicker(child, options) {
return html`
<div class="avatar-picker">
${options.map(o => html`
<button class="avatar-opt ${o.unlocked ? '' : 'locked'} ${o.icon === child.avatar ? 'current' : ''}"
?disabled=${!o.unlocked}
title="${o.unlocked ? (o.label || '') : (this._t('child.avatar_locked') + (o.requirement ? ' — ' + o.requirement : ''))}"
@click=${o.unlocked ? () => this._chooseAvatar(child, o.icon) : null}>
<ha-icon icon="${o.unlocked ? o.icon : 'mdi:lock'}"></ha-icon>
${o.label ? html`<span>${o.label}</span>` : ''}
</button>
`)}
</div>
`;
}
async _chooseAvatar(child, icon) {
this._avatarPickerOpen = false;
this.requestUpdate();
if (icon === child.avatar) return;
try {
await this.hass.callService('taskmate', 'choose_avatar', {
child_id: this.config.child_id,
icon,
});
} catch (err) {
if (this.hass.callService) {
this.hass.callService('persistent_notification', 'create', {
title: 'TaskMate',
message: this._t('child.avatar_change_failed'),
});
}
}
}
/**
* Get or create the AudioContext (lazy initialization)
* Must be called after user interaction due to browser autoplay policies
*/
_getAudioContext() {
if (!this._audioContext) {
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Resume if suspended (required after user interaction)
if (this._audioContext.state === 'suspended') {
this._audioContext.resume();
}
return this._audioContext;
}
/**
* Play a completion sound using Web Audio API
* Generates fun synthesized sounds - no external files needed!
* @param {string} soundName - Name of the sound to play (coin, levelup, fanfare, chime, powerup)
*/
_playSound(soundName) {
// Don't play if sound is "none" or not specified
if (!soundName || soundName === 'none') {
return;
}
try {
const ctx = this._getAudioContext();
const now = ctx.currentTime;
switch (soundName) {
case 'coin':
this._playCoinSound(ctx, now);
break;
case 'levelup':
this._playLevelUpSound(ctx, now);
break;
case 'fanfare':
this._playFanfareSound(ctx, now);
break;
case 'chime':
this._playChimeSound(ctx, now);
break;
case 'powerup':
this._playPowerUpSound(ctx, now);
break;
case 'undo':
this._playUndoSound(ctx, now);
break;
case 'fart1':
this._playAudioFile('fart1.mp3');
break;
case 'fart2':
this._playAudioFile('fart2.mp3');
break;
case 'fart3':
this._playAudioFile('fart3.mp3');
break;
case 'fart4':
this._playAudioFile('fart4.mp3');
break;
case 'fart5':
this._playAudioFile('fart5.mp3');
break;
case 'fart6':
this._playAudioFile('fart6.mp3');
break;
case 'fart7':
this._playAudioFile('fart7.mp3');
break;
case 'fart8':
this._playAudioFile('fart8.mp3');
break;
case 'fart9':
this._playAudioFile('fart9.mp3');
break;
case 'fart10':
this._playAudioFile('fart10.mp3');
break;
case 'fart_random': {
// Pick a random fart sound (1-10)
const randomFartNum = Math.floor(Math.random() * 10) + 1;
this._playAudioFile(`fart${randomFartNum}.mp3`);
break;
}
default:
this._playCoinSound(ctx, now);
}
} catch (e) {
}
}
/**
* Coin collect sound - classic video game coin pickup
* Two quick ascending tones
*/
_playCoinSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// First tone (E6)
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.frequency.value = 1318.5; // E6
osc1.type = 'square';
gain1.gain.setValueAtTime(0.5, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
osc1.start(startTime);
osc1.stop(startTime + 0.1);
// Second tone (B6) - higher
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.frequency.value = 1975.5; // B6
osc2.type = 'square';
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
osc2.start(startTime + 0.08);
osc2.stop(startTime + 0.25);
}
/**
* Level up sound - triumphant ascending arpeggio
*/
_playLevelUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// C major arpeggio going up: C5, E5, G5, C6
const notes = [523.25, 659.25, 783.99, 1046.5];
const duration = 0.12;
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'square';
const noteStart = startTime + i * duration;
gain.gain.setValueAtTime(0.6, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.15);
});
// Final sustained chord
const chordNotes = [523.25, 659.25, 783.99]; // C major chord
const chordStart = startTime + notes.length * duration;
chordNotes.forEach((freq) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, chordStart);
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
osc.start(chordStart);
osc.stop(chordStart + 0.55);
});
}
/**
* Fanfare sound - celebratory trumpet-like fanfare
*/
_playFanfareSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.2;
// Fanfare pattern: G4, G4, G4, E4, G4, C5 (classic celebration pattern)
const pattern = [
{ freq: 392.00, duration: 0.1, delay: 0 }, // G4
{ freq: 392.00, duration: 0.1, delay: 0.12 }, // G4
{ freq: 392.00, duration: 0.15, delay: 0.24 }, // G4
{ freq: 329.63, duration: 0.15, delay: 0.42 }, // E4
{ freq: 392.00, duration: 0.15, delay: 0.6 }, // G4
{ freq: 523.25, duration: 0.4, delay: 0.78 }, // C5 (long final note)
];
pattern.forEach(({ freq, duration, delay }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sawtooth';
const noteStart = startTime + delay;
gain.gain.setValueAtTime(0.5, noteStart);
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.05);
});
}
/**
* Chime sound - simple pleasant bell chime
*/
_playChimeSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// Bell-like sound using multiple harmonics
const fundamental = 880; // A5
const harmonics = [1, 2, 3, 4.2]; // Slight inharmonicity for bell-like quality
harmonics.forEach((harmonic, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = fundamental * harmonic;
osc.type = 'sine';
// Higher harmonics decay faster
const amplitude = 0.5 / (i + 1);
const decayTime = 0.8 / (i + 1);
gain.gain.setValueAtTime(amplitude, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
osc.start(startTime);
osc.stop(startTime + decayTime + 0.1);
});
}
/**
* Power up sound - ascending sweep with sparkle
*/
_playPowerUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// Ascending sweep
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(200, startTime);
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
gain1.gain.setValueAtTime(0.4, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
osc1.start(startTime);
osc1.stop(startTime + 0.4);
// Sparkle notes at the end
const sparkleNotes = [1318.5, 1567.98, 1975.5]; // E6, G6, B6
sparkleNotes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sine';
const noteStart = startTime + 0.25 + i * 0.05;
gain.gain.setValueAtTime(0.3, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
osc.start(noteStart);
osc.stop(noteStart + 0.25);
});
}
/**
* Undo sound - sad descending "womp womp" style
* Two descending tones that sound disappointed/sad
*/
_playUndoSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// First "womp" - descending tone
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(311.13, startTime); // Eb4
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25); // Bb3
gain1.gain.setValueAtTime(0.6, startTime);
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
osc1.start(startTime);
osc1.stop(startTime + 0.35);
// Second "womp" - even lower descending tone (the sad part)
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(233.08, startTime + 0.3); // Bb3
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7); // Eb3
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
osc2.start(startTime + 0.3);
osc2.stop(startTime + 0.8);
// Optional: add a subtle low vibrato for extra sadness
const osc3 = ctx.createOscillator();
const gain3 = ctx.createGain();
osc3.connect(gain3);
gain3.connect(masterGain);
osc3.type = 'sine';
osc3.frequency.setValueAtTime(116.54, startTime + 0.5); // Bb2 (sub bass)
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
osc3.start(startTime + 0.5);
osc3.stop(startTime + 0.85);
}
/**
* Play an audio file from the www folder
* Used for fart sounds (real audio files, not synthesized)
* CC0 public domain from BigSoundBank.com
* @param {string} filename - The audio file name (e.g., 'fart1.mp3')
*/
_playAudioFile(filename) {
try {
// Build the URL to the audio file in the www folder
const audio = new Audio(`/local/taskmate/${filename}`);
audio.volume = 1.0;
audio.play().catch(e => {
console.warn(`[TaskMate] Failed to play ${filename}:`, e);
});
} catch (e) {
console.warn(`[TaskMate] Error preparing audio ${filename}:`, e);
}
}
static get styles() {
const base = css`
:host {
display: block;
--fun-pink: #ff6b9d;
--fun-purple: #9b59b6;
--fun-blue: #3498db;
--fun-green: #2ecc71;
--fun-yellow: #f1c40f;
--fun-orange: #e67e22;
--fun-red: #e74c3c;
--fun-cyan: #1abc9c;
}
ha-card { overflow: hidden; }
/* ── Header — matches overview/weekly/streak card style ── */
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--taskmate-header-bg, #9b59b6);
color: white;
gap: 12px;
min-width: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.avatar-container {
width: 48px;
height: 48px;
min-width: 48px;
border-radius: 50%;
background: rgba(255,255,255,0.15);
border: 2px solid rgba(255,255,255,0.3);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-container ha-icon {
--mdc-icon-size: 30px;
color: white;
}
.avatar-clickable { cursor: pointer; position: relative; }
.avatar-edit-dot {
position: absolute; bottom: -2px; right: -2px;
width: 18px; height: 18px; border-radius: 50%;
background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;
}
.avatar-edit-dot ha-icon { --mdc-icon-size: 12px; color: white; }
.avatar-picker {
position: absolute; z-index: 20; margin-top: 56px;
background: var(--card-background-color, #fff);
border: 1px solid var(--divider-color, rgba(0,0,0,0.12));
border-radius: 12px; padding: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.18);
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; max-width: 280px;
}
.avatar-opt {
display: flex; flex-direction: column; align-items: center; gap: 2px;
border: 1px solid var(--divider-color, rgba(0,0,0,0.12)); border-radius: 10px;
background: var(--secondary-background-color, #f5f5f5);
padding: 8px 4px; cursor: pointer; color: var(--primary-text-color, #222);
font-size: 10px; line-height: 1.1;
}
.avatar-opt ha-icon { --mdc-icon-size: 24px; color: var(--primary-text-color, #222); }
.avatar-opt.current { border-color: var(--primary-color, #9b59b6); box-shadow: 0 0 0 2px var(--primary-color, #9b59b6) inset; }
.avatar-opt.locked { opacity: 0.45; cursor: not-allowed; }
.child-name-container {
min-width: 0;
flex: 1;
overflow: hidden;
}
.child-name {
font-size: 1.15rem;
font-weight: 700;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.child-level {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.level-badge {
font-size: 0.7rem;
font-weight: 800;
letter-spacing: .03em;
color: white;
background: rgba(255, 255, 255, 0.25);
padding: 1px 8px;
border-radius: 999px;
white-space: nowrap;
}
.level-xp-track {
flex: 1;
max-width: 90px;
height: 5px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
overflow: hidden;
}
.level-xp-fill {
height: 100%;
border-radius: 999px;
background: white;
transition: width 0.4s ease;
}
/* Points pill — right side of header, compact like overview card */
.points-display {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.15);
border: 1px solid rgba(255,255,255,0.25);
padding: 6px 12px;
border-radius: 20px;
flex-shrink: 0;
white-space: nowrap;
}
.stars-row {
display: flex;
align-items: center;
gap: 6px;
}
.stars-section {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex-shrink: 1;
}
.stars-value {
font-size: 1.1rem;
font-weight: 800;
line-height: 1;
display: flex;
align-items: center;
gap: 3px;
white-space: nowrap;
color: white;
}
.stars-value.my-stars { color: white; }
.stars-value.waiting-stars {
color: rgba(255,255,255,0.75);
font-size: 0.9rem;
}
.stars-value ha-icon { --mdc-icon-size: 16px; flex-shrink: 0; }
.stars-value.my-stars ha-icon { color: var(--fun-yellow); }
.stars-value.waiting-stars ha-icon { color: var(--fun-orange); }
.stars-label {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
color: rgba(255,255,255,0.7);
display: none;
}
.stars-divider {
width: 1px;
height: 18px;
background: rgba(255,255,255,0.35);
margin: 0 4px;
flex-shrink: 0;
}
/* Chores container */
.chores-container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--card-background-color, #fff);
min-height: 100px;
}
.section-title {
font-size: 1.4rem;
font-weight: 700;
color: var(--fun-purple);
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding: 4px 0;
}
.section-title ha-icon { --mdc-icon-size: 28px; }
.section-title-text { flex: 1; }
/* Individual chore card - optimized for tablet touch, ENTIRE ROW IS CLICKABLE */
.chore-card {
background: var(--card-background-color, #fff);
border-radius: 20px;
padding: 16px 18px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
gap: 12px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
border: 3px solid transparent;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
position: relative;
overflow: hidden;
min-height: 68px;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
box-sizing: border-box;
}
.chore-card:nth-child(odd) {
border-color: var(--fun-blue);
background: var(--card-background-color, #fff);
background: color-mix(in srgb, var(--fun-blue) 10%, var(--card-background-color, #fff));
}
.chore-card:nth-child(even) {
border-color: var(--fun-pink);
background: var(--card-background-color, #fff);
background: color-mix(in srgb, var(--fun-pink) 10%, var(--card-background-color, #fff));
}
.chore-card:nth-child(3n) {
border-color: var(--fun-green);
background: var(--card-background-color, #fff);
background: color-mix(in srgb, var(--fun-green) 10%, var(--card-background-color, #fff));
}
.chore-card:nth-child(4n) {
border-color: var(--fun-orange);
background: var(--card-background-color, #fff);
background: color-mix(in srgb, var(--fun-orange) 10%, var(--card-background-color, #fff));
}
/* Touch/hover feedback - works for both touch and mouse */
.chore-card:active {
transform: scale(0.98);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.chore-card:focus {
outline: none;
}
.chore-card:focus-visible {
outline: 2px solid var(--primary-color, #2196f3);
outline-offset: 2px;
}
@media (hover: hover) {
.chore-card:hover {
transform: scale(1.02);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
}
.chore-card.loading {
opacity: 0.6;
pointer-events: none;
}
.chore-card.celebrating {
animation: celebrate-wiggle 0.5s ease-in-out;
}
@keyframes celebrate-wiggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-3deg); }
75% { transform: rotate(3deg); }
}
.chore-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* Chore number wrapper (icon removed) */
.chore-number-wrapper {
display: flex;
align-items: center;
gap: 12px;
position: relative;
}
/* Fun chore number badge */
.chore-number-badge {
width: 38px;
height: 38px;
min-width: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
font-weight: 800;
color: white;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2), inset 0 2px 4px rgba(255, 255, 255, 0.3);
transform: rotate(-5deg);
transition: transform 0.2s ease;
font-family: 'Comic Sans MS', 'Chalkboard SE', 'Marker Felt', sans-serif;
flex-shrink: 0;
}
.chore-card:hover .chore-number-badge {
transform: rotate(5deg) scale(1.1);
}
/* Cycle through fun colors for number badges */
.chore-number-badge.color-0 { background: linear-gradient(135deg, #ff6b9d 0%, #ff4081 100%); } /* Pink */
.chore-number-badge.color-1 { background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); } /* Blue */
.chore-number-badge.color-2 { background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%); } /* Green */
.chore-number-badge.color-3 { background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); } /* Orange */
.chore-number-badge.color-4 { background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); } /* Purple */
.chore-number-badge.color-5 { background: linear-gradient(135deg, #1abc9c 0%, #16a085 100%); } /* Teal */
.chore-number-badge.color-6 { background: linear-gradient(135deg, #f1c40f 0%, #f39c12 100%); } /* Yellow */
.chore-number-badge.color-7 { background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); } /* Red */
/* Completed state for number badge */
.chore-card.completed .chore-number-badge {
filter: saturate(0.5);
opacity: 0.7;
}
/* Checkbox for chore completion */
.chore-checkbox {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 10px;
border: 3px solid var(--divider-color, #bdc3c7);
background: var(--card-background-color, #fff);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
flex-shrink: 0;
align-self: center;
}
.chore-checkbox ha-icon {
--mdc-icon-size: 24px;
color: transparent;
transition: all 0.2s ease;
}
/* Unchecked state - hover effect */
.chore-card:not(.completed):hover .chore-checkbox {
border-color: var(--fun-green);
background: rgba(46, 204, 113, 0.1);
}
/* Checked state */
.chore-card.completed .chore-checkbox {
border-color: var(--fun-green);
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
box-shadow: 0 3px 10px rgba(46, 204, 113, 0.4);
}
.chore-card.completed .chore-checkbox ha-icon {
color: white;
}
.chore-details {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0; /* Allow text truncation */
}
.chore-name {
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-text-color);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chore-description {
font-size: 0.82rem;
color: var(--secondary-text-color);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 1px;
}
.difficulty-badge {
display: inline-block;
align-self: flex-start;
font-size: 0.68rem;
font-weight: 600;
text-transform: capitalize;
padding: 1px 8px;
border-radius: 9px;
margin-top: 2px;
}
.difficulty-easy { background: rgba(76, 175, 80, 0.16); color: #2e7d32; }
.difficulty-hard { background: rgba(244, 67, 54, 0.16); color: #c62828; }
.difficulty-medium { background: rgba(255, 152, 0, 0.16); color: #ef6c00; }
.swap-section { margin-top: 14px; }
.swap-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border: 1px dashed var(--divider-color, #e0e0e0);
border-radius: 12px;
margin-bottom: 6px;
}
.swap-info { min-width: 0; }
.swap-info .chore-name { font-size: 1rem; }
.swap-info .chore-points { font-size: 0.8rem; opacity: 0.8; }
.swap-btn {
flex-shrink: 0;
border: none;
border-radius: 10px;
padding: 8px 14px;
font-weight: 600;
cursor: pointer;
background: var(--primary-color, #9b59b6);
color: #fff;
display: inline-flex;
align-items: center;
gap: 4px;
}
.swap-btn[disabled] { opacity: 0.6; cursor: default; }
.swap-btn.requested { background: var(--success-color, #4caf50); }
.chore-points {
display: flex;
align-items: center;
gap: 6px;
font-size: 1rem;
color: var(--fun-orange);
font-weight: 600;
margin-top: 2px;
}
.chore-points ha-icon {
--mdc-icon-size: 18px;
color: var(--fun-yellow);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Chore not due today — dimmed/greyed */
.chore-card.not-due-today {
opacity: 0.45;
filter: grayscale(0.6);
pointer-events: none;
}
/* Recurring chore not yet available — dimmed/greyed */
.chore-card.recurrence-unavailable {
opacity: 0.45;
filter: grayscale(0.6);
pointer-events: none;
}
/* Time period elapsed without completion — dimmed/greyed */
.chore-card.time-elapsed {
opacity: 0.45;
filter: grayscale(0.6);
pointer-events: none;
}
/* Locked preview — chore is visible but not yet claimable because its
time-of-day window has not started. Dim with a padlock badge. */
.chore-card.chore-locked {
opacity: 0.55;
filter: grayscale(0.5);
cursor: not-allowed;
}
.chore-card.chore-locked .chore-lock-icon {
color: var(--secondary-text-color);
}
.recurrence-label {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.72rem;
font-weight: 600;
color: var(--secondary-text-color);
background: color-mix(in srgb, var(--primary-text-color, #212121) 8%, transparent);
border-radius: 8px;
padding: 1px 6px;
margin-top: 3px;
}
.recurrence-label ha-icon { --mdc-icon-size: 12px; }
/* Recurring chore not yet available — dimmed/greyed */
.chore-card.recurrence-unavailable {
opacity: 0.45;
filter: grayscale(0.6);
pointer-events: none;
}
.recurrence-label {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.72rem;
font-weight: 600;
color: var(--secondary-text-color);
background: color-mix(in srgb, var(--primary-text-color, #212121) 8%, transparent);
border-radius: 8px;
padding: 1px 6px;
margin-top: 3px;
}
.recurrence-label ha-icon {
--mdc-icon-size: 12px;
}
/* Chore card in completed state - faded green styling */
.chore-card.completed {
opacity: 0.75;
border-style: dashed;
border-color: var(--fun-green) !important;
background: linear-gradient(135deg,
rgba(46, 204, 113, 0.25) 0%,
rgba(39, 174, 96, 0.35) 100%) !important;
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;
}
.chore-card.completed .chore-points {
color: var(--primary-text-color);
opacity: 0.6;
}
/* Bonus sub-task cards */
.chore-card.bonus-subtask {
margin-left: 24px;
border-left: 3px solid var(--fun-amber, #f39c12);
background: linear-gradient(135deg,
rgba(243, 156, 18, 0.08) 0%,
rgba(241, 196, 15, 0.12) 100%);
padding: 8px 12px;
min-height: unset;
}
.chore-card.bonus-subtask .bonus-badge {
background: linear-gradient(135deg, #f39c12, #f1c40f) !important;
width: 22px;
height: 22px;
min-width: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.chore-card.bonus-subtask .bonus-badge ha-icon {
color: #fff;
}
.bonus-name {
font-size: 0.88em;
}
.bonus-label {
font-size: 0.65em;
font-weight: 700;
letter-spacing: 0.5px;
color: #f39c12;
background: rgba(243, 156, 18, 0.15);
padding: 1px 5px;
border-radius: 3px;
margin-left: 6px;
}
.chore-card.bonus-subtask.completed {
border-color: var(--fun-green) !important;
border-left: 3px solid var(--fun-green);
}
/* Mandatory chores (#532): red border + tint + left spine + badge. */
.chore-card.mandatory {
border-color: var(--fun-red) !important;
background: color-mix(in srgb, var(--fun-red) 9%, var(--card-background-color, #fff)) !important;
}
.chore-card.mandatory::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
background: var(--fun-red);
}
.mandatory-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--fun-red);
color: #fff;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
border-radius: 8px;
padding: 2px 7px;
margin-left: 8px;
vertical-align: middle;
white-space: nowrap;
}
/* Reset countdown — inline beside section title */
.reset-countdown {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.72rem;
font-weight: 600;
color: var(--secondary-text-color);
background: var(--secondary-background-color, #f5f5f5);
border-radius: 20px;
padding: 2px 8px 2px 6px;
white-space: nowrap;
flex-shrink: 0;
}
.reset-countdown ha-icon {
--mdc-icon-size: 12px;
flex-shrink: 0;
}
.reset-countdown.soon {
color: var(--fun-orange);
background: rgba(230,126,34,0.12);
font-weight: 700;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.empty-state ha-icon {
--mdc-icon-size: 80px;
color: var(--fun-green);
margin-bottom: 16px;
animation: bounce 1s ease infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.empty-state .message {
font-size: 1.6rem;
font-weight: bold;
color: var(--fun-purple);
margin-bottom: 8px;
}
.empty-state .submessage {
font-size: 1.1rem;
color: var(--secondary-text-color);
}
/* Celebration overlay */
.celebration-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fade-in 0.3s ease;
}
/* ── Photo-evidence badge on chore rows ── */
.photo-badge {
display: inline-flex; align-items: center; gap: 4px;
background: var(--fun-orange); color: #fff;
font-size: 0.66rem; font-weight: 700;
border-radius: 999px; padding: 2px 8px; white-space: nowrap;
}
/* ── Photo-evidence capture overlay ── */
.photo-overlay {
position: fixed; inset: 0; background: rgba(20, 20, 30, 0.72);
display: flex; align-items: center; justify-content: center;
z-index: 10000; padding: 18px; animation: fade-in 0.2s ease;
}
.photo-sheet {
background: var(--card-background-color, #fff);
border-radius: 22px; width: 100%; max-width: 330px;
padding: 20px 18px 18px; text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: pop-in 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.photo-title { font-size: 1.15rem; font-weight: 800; color: var(--fun-purple); }
.photo-for { font-size: 0.85rem; color: var(--secondary-text-color, #6b7280); margin: 2px 0 16px; }
.photo-dropzone {
border: 2px dashed var(--fun-purple); border-radius: 16px;
padding: 26px 14px; cursor: pointer;
background: color-mix(in srgb, var(--fun-purple) 6%, var(--card-background-color, #fff));
}
.photo-dropzone-icon { font-size: 2.6rem; line-height: 1; }
.photo-dropzone-label { margin-top: 8px; font-weight: 700; color: var(--fun-purple); }
.photo-dropzone-hint { font-size: 0.72rem; color: var(--secondary-text-color, #6b7280); margin-top: 4px; }
.photo-preview {
position: relative; border-radius: 14px; overflow: hidden;
background: #000; aspect-ratio: 4 / 3;
display: flex; align-items: center; justify-content: center;
}
.photo-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-size-tag {
position: absolute; bottom: 8px; right: 8px;
background: rgba(0, 0, 0, 0.6); color: #fff;
font-size: 0.66rem; padding: 3px 8px; border-radius: 999px;
}
.photo-btn-row { display: flex; gap: 10px; margin-top: 16px; }
.photo-btn {
flex: 1; border: none; border-radius: 14px; padding: 13px 10px;
font-weight: 800; font-size: 0.95rem; cursor: pointer;
}
.photo-btn.ghost { background: var(--secondary-background-color, #f5f5f5); color: var(--primary-text-color, #33373d); }
.photo-btn.primary { background: var(--fun-green); color: #fff; }
.photo-cancel {
background: none; border: none; cursor: pointer;
color: var(--secondary-text-color, #9aa0a6); font-size: 0.85rem;
margin-top: 10px; width: 100%; padding: 4px;
}
.photo-error {
background: color-mix(in srgb, var(--fun-red) 12%, var(--card-background-color, #fff));
color: var(--fun-red); border-radius: 12px; padding: 10px;
font-size: 0.82rem; font-weight: 600; margin-top: 12px;
}
.photo-uploading { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 18px 0; }
.photo-uploading-label { font-weight: 700; color: var(--fun-purple); }
.photo-spinner {
width: 42px; height: 42px; border-radius: 50%;
border: 4px solid var(--secondary-background-color, #eee);
border-top-color: var(--fun-purple); animation: photo-spin 1s linear infinite;
}
@keyframes photo-spin { to { transform: rotate(360deg); } }
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.celebration-content {
background: var(--card-background-color, #fff);
border-radius: 30px;
padding: 40px 50px;
text-align: center;
animation: pop-in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
position: relative;
overflow: hidden;
}
@keyframes pop-in {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.celebration-stars {
font-size: 4rem;
margin-bottom: 16px;
animation: star-bounce 0.6s ease infinite;
}
@keyframes star-bounce {
0%, 100% { transform: scale(1) rotate(0deg); }
50% { transform: scale(1.2) rotate(10deg); }
}
.celebration-title {
font-size: 2.5rem;
font-weight: bold;
color: var(--fun-purple);
margin-bottom: 8px;
}
.celebration-message {
font-size: 1.3rem;
color: var(--secondary-text-color);
margin-bottom: 16px;
}
.celebration-points {
font-size: 1.8rem;
font-weight: bold;
color: var(--fun-orange);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.celebration-points ha-icon {
--mdc-icon-size: 28px;
color: var(--fun-yellow);
}
/* Confetti */
.confetti-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10000;
overflow: hidden;
}
.confetti {
position: absolute;
width: 10px;
height: 10px;
animation: confetti-fall 3s linear forwards;
}
@keyframes confetti-fall {
0% {
transform: translateY(-100px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
/* Error state */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--error-color, #f44336);
text-align: center;
}
.error-state ha-icon {
--mdc-icon-size: 48px;
margin-bottom: 16px;
}
/* ── Responsive — header and chore cards always stay horizontal ── */
/* Small phones ≤380px */
@media (max-width: 380px) {
.card-header { padding: 10px 12px; gap: 8px; }
.avatar-container { width: 36px; height: 36px; min-width: 36px; }
.avatar-container ha-icon { --mdc-icon-size: 22px; }
.chores-container { padding: 10px; gap: 8px; }
.section-title { font-size: 1rem; margin-bottom: 4px; }
.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-name { font-size: 0.95rem; }
.chore-points { font-size: 0.82rem; }
.chore-checkbox { width: 34px; height: 34px; min-width: 34px; border-radius: 8px; }
.chore-checkbox ha-icon { --mdc-icon-size: 20px; }
}
/* Standard phones 381600px */
@media (min-width: 381px) and (max-width: 600px) {
.card-header { padding: 12px 14px; gap: 10px; }
.avatar-container { width: 40px; height: 40px; min-width: 40px; }
.avatar-container ha-icon { --mdc-icon-size: 26px; }
.chores-container { padding: 12px; gap: 9px; }
.section-title { font-size: 1.05rem; margin-bottom: 6px; }
.chore-card { padding: 12px 14px; gap: 10px; min-height: 58px; flex-wrap: nowrap; }
.chore-info { flex: 1; min-width: 0; overflow: hidden; }
.chore-number-badge { width: 34px; height: 34px; min-width: 34px; font-size: 1.1rem; }
.chore-name { font-size: 1rem; }
.chore-checkbox { width: 36px; height: 36px; min-width: 36px; }
.chore-checkbox ha-icon { --mdc-icon-size: 22px; }
}
/* Landscape phones */
@media (max-width: 900px) and (max-height: 500px) {
.card-header { padding: 8px 14px; }
.chores-container { padding: 8px; gap: 6px; }
.chore-card { padding: 8px 12px; min-height: 48px; flex-wrap: nowrap; }
.chore-name { font-size: 0.95rem; }
}
/* Tablets 6011023px */
@media (min-width: 601px) and (max-width: 1023px) {
.chore-card { padding: 14px 16px; flex-wrap: nowrap; }
.chore-name { font-size: 1.1rem; }
}
/* Desktop ≥1024px — keep compact, never inflate */
@media (min-width: 1024px) {
.chores-container { padding: 16px; gap: 10px; }
.section-title { font-size: 1.2rem; }
.chore-card { padding: 14px 18px; min-height: 62px; flex-wrap: nowrap; }
.chore-number-badge { width: 36px; height: 36px; min-width: 36px; font-size: 1.15rem; }
.chore-name { font-size: 1.05rem; }
.chore-points { font-size: 0.92rem; }
.chore-checkbox { width: 38px; height: 38px; min-width: 38px; border-radius: 9px; }
.chore-checkbox ha-icon { --mdc-icon-size: 22px; }
}
/* ── Timed task card ─── */
.timed-chore-card {
border-radius: 20px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
border: 3px solid var(--fun-cyan);
background: color-mix(in srgb, var(--fun-cyan) 10%, var(--card-background-color, #fff));
transition: transform 0.15s ease, box-shadow 0.15s ease;
-webkit-user-select: none;
user-select: none;
}
.timed-chore-card.loading { opacity: 0.6; pointer-events: none; }
.timed-top-row {
display: flex;
align-items: center;
gap: 12px;
}
.timed-rate {
margin-left: auto;
font-size: 0.8rem;
font-weight: 700;
color: var(--fun-cyan);
background: rgba(26, 188, 156, 0.12);
padding: 4px 10px;
border-radius: 12px;
white-space: nowrap;
}
.timer-display {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 10px 16px;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.03));
border-radius: 14px;
}
.timer-counter {
font-family: 'SF Mono', 'Fira Code', 'Courier New', monospace;
font-size: 2rem;
font-weight: 700;
color: var(--primary-text-color);
letter-spacing: 0.05em;
min-width: 100px;
text-align: center;
}
.timer-counter.running { color: var(--fun-green); }
.timer-counter.paused { color: var(--fun-orange); }
.timer-points-live {
font-size: 1.1rem;
font-weight: 700;
color: var(--fun-orange);
display: flex;
align-items: center;
gap: 4px;
}
.timer-points-live ha-icon { --mdc-icon-size: 18px; color: var(--fun-yellow); }
.recording-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--fun-green);
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
.paused-badge {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
color: var(--fun-orange);
background: rgba(230, 126, 34, 0.12);
padding: 3px 8px;
border-radius: 6px;
letter-spacing: 0.03em;
}
.timer-actions {
display: flex;
gap: 10px;
justify-content: center;
}
.timer-btn {
border: none;
border-radius: 14px;
padding: 12px 24px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: transform 0.1s ease, box-shadow 0.15s ease;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.timer-btn:active { transform: scale(0.95); }
.timer-btn.start, .timer-btn.resume {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
box-shadow: 0 4px 12px rgba(46, 204, 113, 0.35);
flex: 1;
}
.timer-btn.start:hover, .timer-btn.resume:hover {
box-shadow: 0 6px 16px rgba(46, 204, 113, 0.5);
transform: scale(1.02);
}
.timer-btn.pause {
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
color: white;
box-shadow: 0 4px 12px rgba(243, 156, 18, 0.35);
flex: 1;
}
.timer-btn.pause:hover {
box-shadow: 0 6px 16px rgba(243, 156, 18, 0.5);
transform: scale(1.02);
}
.timer-btn.stop {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: white;
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.35);
padding: 12px 20px;
}
.timer-btn.stop:hover {
box-shadow: 0 6px 16px rgba(231, 76, 60, 0.5);
transform: scale(1.02);
}
.btn-icon { font-size: 1.2rem; }
.daily-cap-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
color: var(--secondary-text-color);
padding: 0 4px;
}
.cap-track {
flex: 1;
height: 6px;
background: var(--divider-color, rgba(0, 0, 0, 0.06));
border-radius: 3px;
overflow: hidden;
}
.cap-fill {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--fun-cyan), var(--fun-green));
transition: width 0.3s ease;
}
.cap-fill.warning {
background: linear-gradient(90deg, var(--fun-orange), var(--fun-red));
}
.cap-label {
font-weight: 600;
white-space: nowrap;
}
.cap-label.near-cap { color: var(--fun-red); font-weight: 700; }
/* ── Badge strip ── */
.badge-strip {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid var(--divider-color, #e0e0e0);
}
.vacation-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: linear-gradient(135deg, #ffe9b3, #ffd27f);
color: #6b4e00;
font-weight: 700;
font-size: 1rem;
border-bottom: 1px solid var(--divider-color, #e0e0e0);
}
.vacation-banner ha-icon { --mdc-icon-size: 24px; }
.badge-strip:hover { background: var(--secondary-background-color, rgba(0,0,0,0.03)); }
.badge-strip-label {
font-size: 11px;
color: var(--secondary-text-color);
margin-right: 2px;
white-space: nowrap;
}
.badge-mini {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 16px;
color: #1a1a1a;
border: 2px solid var(--t);
background: var(--t);
flex-shrink: 0;
}
.badge-mini.just-earned { animation: badge-earn-pulse 1.6s ease-out; }
@keyframes badge-earn-pulse {
0% { transform: scale(0.6); }
50% { transform: scale(1.15); box-shadow: 0 0 0 12px rgba(255,255,255,0.0); }
100% { transform: scale(1); }
}
.badge-strip-more {
font-size: 11px;
color: var(--primary-color);
margin-left: 2px;
white-space: nowrap;
}
/* ══════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro)
Shared .tmd kit + tokens come from taskmate-design.js styles().
Only card-specific layout classes live here.
Ported from docs/design/redesigns/frag/03-child.html.
══════════════════════════════════════════════════════════════════ */
.tmd-chores { display: grid; gap: 11px; }
.tmd-chore {
display: flex; align-items: center; gap: 10px;
background: var(--tmd-surface-2);
border-radius: 18px; padding: 11px 13px;
}
.tmd-chore .num-badge {
width: 30px; height: 30px; border-radius: 12px; flex: none;
display: grid; place-items: center;
font-family: var(--tmd-font-display); font-weight: 800; color: #fff;
background: var(--ac, var(--tmd-accent));
}
.tmd-chore .ch-emoji { font-size: 22px; flex: none; }
.tmd-chore .ch-mid { flex: 1; min-width: 0; }
.tmd-chore .ch-name {
font-weight: 800; font-size: 15px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tmd-chore.done .ch-name { text-decoration: line-through; }
.tmd-chore.done { opacity: 0.6; }
.tmd-chore .done-chip {
background: transparent; border-color: transparent; color: var(--tmd-good);
}
/* Console — quest log rows */
.tmd-quest {
display: flex; align-items: center; gap: 10px;
background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
border-radius: 8px; padding: 10px 11px;
}
.tmd-quest .q-num { width: 22px; font-size: 14px; color: var(--tmd-accent); }
.tmd-quest .q-emoji { font-size: 19px; flex: none; }
.tmd-quest .q-mid { flex: 1; min-width: 0; }
.tmd-quest .q-name {
font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tmd-quest.done { opacity: 0.55; }
.tmd-quest.done .q-name { text-decoration: line-through; }
.tmd-quest .q-xp { font-size: 11px; color: var(--tmd-gold); }
.tmd-quest .q-cleared { font-size: 11px; color: var(--tmd-good); }
/* Clean Pro — tidy checklist */
.tmd-checklist { display: grid; gap: 0; }
.tmd-check {
display: flex; align-items: center; gap: 10px; padding: 11px 2px;
}
.tmd-check + .tmd-check { border-top: 1px solid var(--tmd-border); }
.tmd-check .c-num {
width: 26px; height: 26px; border-radius: 8px; flex: none;
display: grid; place-items: center; font-size: 12px;
font-family: var(--tmd-font-display); font-weight: 800; color: #fff;
background: var(--ac, var(--tmd-accent));
}
.tmd-check .c-emoji { font-size: 18px; flex: none; }
.tmd-check .c-mid { flex: 1; min-width: 0; }
.tmd-check .c-name {
font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tmd-check.done { opacity: 0.55; }
.tmd-check.done .c-name { text-decoration: line-through; }
.tmd-check .c-pts { background: transparent; }
.tmd-check .c-done {
background: transparent; border-color: transparent; color: var(--tmd-good);
}
/* Designed: shared chore meta (mandatory / photo / description / dim states) */
.tmd-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 3px; }
.tmd-tag {
display: inline-flex; align-items: center; gap: 3px;
font-size: 10.5px; font-weight: 800; line-height: 1;
padding: 3px 7px; border-radius: 999px;
background: var(--tmd-surface-2); color: var(--tmd-dim);
border: 1px solid var(--tmd-border);
}
.tmd-tag.mandatory { background: color-mix(in srgb, var(--tmd-bad) 16%, transparent); color: var(--tmd-bad); border-color: transparent; }
.tmd-tag.photo { background: color-mix(in srgb, var(--tmd-accent) 14%, transparent); color: var(--tmd-accent); border-color: transparent; }
.tmd-desc { font-size: 11.5px; color: var(--tmd-dim); margin-top: 3px; white-space: normal; line-height: 1.3; }
.tmd-chore.mandatory, .tmd-quest.mandatory, .tmd-check.mandatory {
box-shadow: inset 3px 0 0 0 var(--tmd-bad);
padding-left: 20px;
}
.tmd-chore.dimmed, .tmd-quest.dimmed, .tmd-check.dimmed { opacity: 0.5; }
/* Clickable done-state chips (tap to undo) */
.tmd-undochip { cursor: pointer; font: inherit; font-weight: 800; line-height: 1; }
.tmd-undochip:hover { filter: brightness(0.97); }
.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: pending-points + countdown chips on the header/section */
.tmd-pending {
margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; font-weight: 800; padding: 4px 9px; border-radius: 999px;
background: rgba(255,255,255,.22); color: #fff;
}
.tmd-section {
display: flex; align-items: center; gap: 8px; margin-bottom: 11px;
font-family: var(--tmd-font-display); font-weight: 800; font-size: 14px; color: var(--tmd-text);
}
.tmd-countdown {
margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; font-weight: 700; color: var(--tmd-dim);
}
.tmd-countdown.soon { color: var(--tmd-warn); }
/* Designed: badge strip */
.tmd-badges {
display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
padding: 9px 11px; margin-bottom: 11px;
background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
border-radius: var(--tmd-radius-sm); cursor: pointer;
}
.tmd-badges .lbl { font-size: 11px; font-weight: 800; color: var(--tmd-dim); text-transform: uppercase; letter-spacing: .04em; }
.tmd-badge-mini {
width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center;
background: var(--t, var(--tmd-accent)); color: #fff;
}
.tmd-badge-mini ha-icon { --mdc-icon-size: 16px; color: #fff; }
.tmd-badges .more { font-size: 11px; font-weight: 800; color: var(--tmd-accent); }
/* Designed: vacation banner + swappable section */
.tmd-vacation {
display: flex; align-items: center; gap: 7px; padding: 9px 11px; margin-bottom: 11px;
background: color-mix(in srgb, var(--tmd-warn) 16%, transparent); color: var(--tmd-warn);
border-radius: var(--tmd-radius-sm); font-weight: 700; font-size: 12.5px;
}
.tmd-swaps { margin-top: 12px; }
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
setConfig(config) {
if (!config.entity) {
throw new Error("Please define an entity");
}
if (!config.child_id) {
throw new Error("Please define a child_id");
}
this.config = {
time_category: "anytime",
debug: false,
default_sound: "coin",
undo_sound: "undo",
due_days_mode: "hide", // "hide" = hide chores not due today, "dim" = show greyed out
elapsed_time_mode: "dim", // "dim" | "hide" | "show" — chores whose time period has passed without completion
show_countdown: true, // Show midnight reset countdown below section title
show_due_days_only: true, // Whether to apply due_days filtering at all
show_badges: true, // Show badge strip between points and chores
header_color: '#9b59b6',
...config,
};
}
getCardSize() {
return 4;
}
static getConfigElement() {
return document.createElement("taskmate-child-card-editor");
}
static getStubConfig() {
return {
entity: "sensor.taskmate_overview",
child_id: "",
time_category: "morning",
};
}
render() {
if (!this.hass || !this.config) {
return html``;
}
const design = window.__taskmate_design
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
: "classic";
if (design !== "classic") return this._renderDesigned(design);
const entity = this.hass.states[this.config.entity];
if (!entity) {
return html`
<ha-card>
<div class="error-state">
<ha-icon icon="mdi:alert-circle"></ha-icon>
<div>${this._t('common.entity_not_found', { entity: this.config.entity })}</div>
</div>
</ha-card>
`;
}
// Get child info
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const children = attrs.children || [];
const child = children.find(c => c.id === this.config.child_id)
|| (!this.config.child_id && children[0]);
if (!child) {
return html`
<ha-card>
<div class="error-state">
<ha-icon icon="mdi:account-alert"></ha-icon>
<div>${this._t('child.child_not_found', { child_id: this.config.child_id })}</div>
</div>
</ha-card>
`;
}
// Get chores for this child and time category
const allChores = attrs.chores || [];
// Log raw data for debugging assignment issues
// DEBUG: Create debug info object for visible debugging
const debugInfo = {
configChildId: this.config.child_id,
foundChildId: child.id,
foundChildName: child.name,
totalChores: allChores.length,
sampleChores: allChores.slice(0, 3).map(c => ({
name: c.name,
assigned_to: c.assigned_to,
assigned_to_type: typeof c.assigned_to,
isArray: Array.isArray(c.assigned_to)
}))
};
const childChores = this._filterAndSortChores(allChores, child);
// Log the filtering result
// Store debug info for rendering
this._debugInfo = {
...debugInfo,
filteredCount: childChores.length
};
const pointsIcon = attrs.points_icon || "mdi:star";
const pointsName = attrs.points_name || this._t('common.stars');
// Avatar now in children array directly
const avatar = child.avatar || "mdi:account-circle";
// Resolve badges sensor entity for this child
const badgesEntity = this._resolveBadgesEntity(child);
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
// Get pending points for this child
const pendingPoints = child.pending_points || 0;
// Get today's completions for this child (with timezone-aware filtering as fallback)
// The backend provides todays_completions, but we also apply client-side filtering
// to ensure timezone correctness matches the HA frontend timezone
const allCompletions = attrs.todays_completions || attrs.completions || [];
const todaysCompletions = this._filterCompletionsForToday(allCompletions);
return html`
<ha-card>
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, '#9b59b6')}; }</style>
<div class="card-header">
<div class="header-left">
${(() => {
const opts = child.avatar_options || [];
const unlocked = opts.filter(o => o.unlocked);
const canChange = this.config.allow_avatar_change !== false && unlocked.length > 1;
return html`
<div class="avatar-container ${canChange ? 'avatar-clickable' : ''}"
@click=${canChange ? () => this._toggleAvatarPicker() : null}
title="${canChange ? this._t('child.avatar_change') : ''}">
<ha-icon icon="${avatar}"></ha-icon>
${canChange ? html`<span class="avatar-edit-dot"><ha-icon icon="mdi:pencil"></ha-icon></span>` : ''}
</div>
${canChange && this._avatarPickerOpen ? this._renderAvatarPicker(child, opts) : ''}
`;
})()}
<div class="child-name-container">
<div class="child-name">${child.name}</div>
${child.level ? html`
<div class="child-level" title="${child.level_progress || 0} / ${child.level_target || 100} XP">
<span class="level-badge">${this._t('child.level_label', { level: child.level })}</span>
<div class="level-xp-track">
<div class="level-xp-fill" style="width: ${Math.max(0, Math.min(100, Math.round(((child.level_progress || 0) / (child.level_target || 100)) * 100)))}%"></div>
</div>
</div>
` : ''}
</div>
</div>
<div class="points-display">
<div class="stars-row">
<div class="stars-section">
<div class="stars-value my-stars">
<ha-icon icon="${pointsIcon}"></ha-icon>
${child.points}
</div>
<div class="stars-label my-stars">${pointsName}</div>
</div>
${pendingPoints > 0 ? html`
<div class="stars-divider"></div>
<div class="stars-section">
<div class="stars-value waiting-stars">
<ha-icon icon="mdi:timer-sand"></ha-icon>
+${pendingPoints}
</div>
<div class="stars-label waiting-stars">${this._t('common.pending')}</div>
</div>
` : ''}
</div>
</div>
</div>
${attrs.vacation_active ? html`
<div class="vacation-banner">
<ha-icon icon="mdi:palm-tree"></ha-icon>
<span>${this._t('child.on_vacation')}${attrs.vacation_name ? ` · ${attrs.vacation_name}` : ''}</span>
</div>
` : ''}
${showBadges ? html`
<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' : ''}"
style="--t: ${this._tierColor(b.tier)}">
<ha-icon icon="${b.icon || 'mdi:medal'}"></ha-icon>
</div>
`)}
${earnedBadges.length > 5 ? html`<span class="badge-strip-more">+${earnedBadges.length - 5} →</span>` : ''}
</div>
` : ''}
<div class="chores-container">
${childChores.length === 0
? this._renderEmptyState()
: html`
<div class="section-title">
<ha-icon icon="${this._getTimeCategoryIcon(this.config.time_category)}"></ha-icon>
<span class="section-title-text">${this._getDynamicTitle()}</span>
${this.config.show_countdown !== false ? (() => {
const countdown = this._getMidnightCountdown();
return countdown ? html`
<div class="reset-countdown ${countdown.soon ? 'soon' : ''}">
<ha-icon icon="mdi:clock-outline"></ha-icon>
<span>${countdown.label}</span>
</div>
` : '';
})() : ''}
</div>
${childChores.map((chore, index) => html`
${chore.task_type === 'timed'
? this._renderTimedChoreCard(chore, child, pointsIcon, todaysCompletions, index)
: html`
${this._renderChoreCard(chore, child, pointsIcon, todaysCompletions, index)}
${this._renderBonusSubtasks(chore, child, pointsIcon, todaysCompletions)}
`}
`)}
`}
</div>
${this._renderSwappable(allChores, child, pointsIcon)}
${this._celebrating ? this._renderCelebration() : ""}
${this._confetti.length > 0 ? this._renderConfetti() : ""}
${this._renderPhotoCapture()}
<input type="file" id="tm-photo-input" accept="image/*" capture="environment"
style="display:none" @change="${this._onPhotoSelected}">
${this.config.debug === true ? html`
<!-- DEBUG PANEL -->
<div style="margin-top: 20px; padding: 10px; background: #333; color: #0f0; font-family: monospace; font-size: 11px; border-radius: 8px;">
<div><strong>DEBUG INFO:</strong></div>
<div>Config child_id: "${this.config.child_id}"</div>
<div>Found child.id: "${this._debugInfo?.foundChildId}"</div>
<div>Found child.name: "${this._debugInfo?.foundChildName}"</div>
<div>Total chores: ${this._debugInfo?.totalChores}</div>
<div>Filtered chores: ${this._debugInfo?.filteredCount}</div>
<div style="margin-top: 5px;"><strong>Sample chores assigned_to:</strong></div>
${(this._debugInfo?.sampleChores || []).map(c => html`
<div>- ${c.name}: ${JSON.stringify(c.assigned_to)} (isArray: ${c.isArray})</div>
`)}
</div>
` : ""}
</ha-card>
`;
}
/* ══════════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
Ported from docs/design/redesigns/frag/03-child.html.
Reuses the real chore data + the existing complete/undo handlers, and the
classic sub-renderers for timed chores, bonus subtasks, celebration,
confetti and photo capture — so sounds/timers/photo/swap/badges all run in
designed mode too. Honours every setConfig + editor option.
══════════════════════════════════════════════════════════════════════ */
// The per-child badges sensor is entity-id'd from the child NAME
// ("TaskMate <Name> Badges" → sensor.taskmate_<name>_badges), NOT the
// child_id — so resolve it robustly or the badge strip never finds it.
_resolveBadgesEntity(child) {
if (!this.hass || !child) return null;
const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
const nameSlug = slug(child.name);
const candidates = [
`sensor.taskmate_${nameSlug}_badges`,
`sensor.taskmate_badges_${slug(this.config.child_id)}`,
];
for (const id of candidates) if (this.hass.states[id]) return this.hass.states[id];
if (nameSlug) {
for (const eid in this.hass.states) {
if (/^sensor\.taskmate_.*_badges$/.test(eid) && eid.includes(nameSlug)) return this.hass.states[eid];
}
}
return null;
}
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
_av(child, tone, size) {
const a = child.avatar || "";
const inner = a.startsWith("mdi:")
? html`<ha-icon icon="${a}"></ha-icon>`
: a
? html`<img src="${a}" alt="${child.name}">`
: (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
}
_choreEmoji(chore) {
const n = `${chore.name || ""} ${chore.icon || ""}`.toLowerCase();
const map = [
[/(teeth|brush|tooth)/, "🪥"], [/(bed|sleep)/, "🛏️"],
[/(dog|pet|cat|feed)/, "🐶"], [/(tidy|clean|sweep|broom)/, "🧹"],
[/(dish|wash|plate)/, "🍽️"], [/(trash|bin|rubbish|garbage)/, "🗑️"],
[/(homework|study|read|book)/, "📚"], [/(water|plant|garden)/, "🪴"],
[/(laundry|clothes|fold)/, "🧺"], [/(shower|bath)/, "🛁"],
[/(cook|food|kitchen)/, "🍳"], [/(toy|play)/, "🧸"],
[/(music|practice|piano)/, "🎵"], [/(car|wash)/, "🚗"],
[/(exercise|run|sport)/, "🏃"], [/(vacuum)/, "🧽"],
];
for (const [re, e] of map) if (re.test(n)) return e;
return "⭐";
}
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
_isChoreDone(chore, child, todaysCompletions) {
const childCompletionsToday = (todaysCompletions || []).filter(
(comp) => comp.chore_id === chore.id
&& (comp.child_id === child.id || comp.child_id === "__parent__")
&& !comp.bonus_subtask_id
);
let count = childCompletionsToday.length;
const optimisticKey = `${chore.id}_${child.id}`;
const opt = this._optimisticCompletions && this._optimisticCompletions[optimisticKey];
if (opt) {
const actualTs = childCompletionsToday.map(c => c.completed_at ? new Date(c.completed_at).getTime() : 0);
const optTs = opt.timestamps || [opt.timestamp || Date.now()];
for (const t of optTs) {
if (!actualTs.some(a => Math.abs(a - t) < 2000)) count += 1;
}
}
const done = count >= (chore.daily_limit || 1);
return { done, completions: childCompletionsToday };
}
_renderDesigned(design) {
const entity = this.hass.states[this.config.entity];
const hd = _safeColor(this.config.header_color, "#ff7043");
const header = (title, sub, pill) => html`
<div class="tmd-hd">
${pill || ""}
<span class="ic">🧒</span>
<span class="tt">${title}${sub ? html`<small>${sub}</small>` : ""}</span>
</div>`;
if (!entity) {
return html`<ha-card class="tmd" style="--hd:${hd}">
${header(this._t("common.entity_not_found", { entity: this.config.entity }))}
<div class="tmd-bd"><div class="tmd-empty">${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div>
</ha-card>`;
}
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const children = attrs.children || [];
const child = children.find(c => c.id === this.config.child_id) || (!this.config.child_id && children[0]);
if (!child) {
return html`<ha-card class="tmd" style="--hd:${hd}">
${header(this._t("child.child_not_found", { child_id: this.config.child_id }))}
<div class="tmd-bd"><div class="tmd-empty">${this._t("child.child_not_found", { child_id: this.config.child_id })}</div></div>
</ha-card>`;
}
const allChores = attrs.chores || [];
const childChores = this._filterAndSortChores(allChores, child);
const allCompletions = attrs.todays_completions || attrs.completions || [];
const todaysCompletions = this._filterCompletionsForToday(allCompletions);
const pointsIcon = attrs.points_icon || "mdi:star";
const dueDaysMode = this.config.due_days_mode || "hide";
const elapsedTimeMode = this.config.elapsed_time_mode || "dim";
// Annotate each chore with done-state + handlers, preserving the classic path's logic.
const rows = childChores.map((chore, i) => {
const { done, completions } = this._isChoreDone(chore, child, todaysCompletions);
const loading = !!this._loading[chore.id];
const onAct = () => {
if (loading) return;
if (done) this._handleUndo(chore, child, completions);
else this._handleComplete(chore, child);
};
// Mirror the classic chore card's dim states (due-days "dim" / elapsed "dim" /
// locked preview). "hide" modes are already filtered out upstream.
const notDueToday = chore._hasDueDays && !chore._isDueToday;
const dimmed = !done && (
(notDueToday && dueDaysMode === "dim") ||
(chore._isTimeElapsed && elapsedTimeMode === "dim") ||
chore._isLockedPreview === true
);
return {
chore, child, done, loading, onAct, index: i, dimmed,
tone: this._designTone(i),
emoji: this._choreEmoji(chore),
points: chore.effective_points ?? chore.points,
timed: chore.task_type === "timed",
mandatory: chore.mandatory === true,
photo: chore.require_photo === true,
pointsIcon,
todaysCompletions,
};
});
const remaining = rows.filter(r => !r.done).length;
const tone = this._designTone(children.indexOf(child));
// Badge strip (show_badges) — resolve the per-child badges sensor like classic.
const badgesEntity = this._resolveBadgesEntity(child);
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
const pendingPoints = child.pending_points || 0;
const countdown = this.config.show_countdown !== false ? this._getMidnightCountdown() : null;
const sectionLine = rows.length === 0 ? "" : html`
<div class="tmd-section">
<span>${this._getDynamicTitle()}</span>
${countdown ? html`<span class="tmd-countdown ${countdown.soon ? "soon" : ""}">
<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);
return html`<ha-card class="tmd" style="--hd:${hd}">
${this._designHeaderFull(child, design, remaining, rows.length, tone, pendingPoints, pointsIcon)}
<div class="tmd-bd">
${attrs.vacation_active ? html`
<div class="tmd-vacation">
<ha-icon icon="mdi:palm-tree"></ha-icon>
<span>${this._t("child.on_vacation")}${attrs.vacation_name ? ` · ${attrs.vacation_name}` : ""}</span>
</div>` : ""}
${showBadges ? html`
<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" : ""}"
style="--t:${this._tierColor(b.tier)}">
<ha-icon icon="${b.icon || "mdi:medal"}"></ha-icon>
</div>`)}
${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""}
</div>` : ""}
${sectionLine}
${body}
${this._renderSwappable(allChores, child, pointsIcon)}
</div>
${this._celebrating ? this._renderCelebration() : ""}
${this._confetti.length > 0 ? this._renderConfetti() : ""}
${this._renderPhotoCapture()}
<input type="file" id="tm-photo-input" accept="image/*" capture="environment"
style="display:none" @change="${this._onPhotoSelected}">
</ha-card>`;
}
/** Designed header: avatar/title, remaining pill, pending-points chip. */
_designHeaderFull(child, design, remaining, total, tone, pendingPoints, pointsIcon) {
const title = design === "console"
? `${(child.name || "").toUpperCase()} · ${this._t("child.todays_chores").toUpperCase()}`
: this._t("points_display.single_title", { name: child.name });
const sub = design === "console"
? (child.level ? `${this._t("child.level_label", { level: child.level })} · ${remaining} ACTIVE` : `${remaining} ACTIVE`)
: design === "cleanpro" ? `${remaining} / ${total}`
: `${remaining} · ${this._t("child.todays_chores")}`;
return html`
<div class="tmd-hd">
${this._av(child, tone, 34)}
<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>` : ""}
</div>`;
}
/** Shared meta tags (mandatory / photo / description) for a designed chore row. */
_designChoreMeta(r) {
const showDesc = this.config.show_description === true && r.chore.description;
return html`
${r.mandatory || r.photo ? html`
<div class="tmd-meta">
${r.mandatory ? html`<span class="tmd-tag mandatory">⚠ ${this._t("child.mandatory")}</span>` : ""}
${r.photo ? html`<span class="tmd-tag photo">📷 ${this._t("child.photo_needed")}</span>` : ""}
</div>` : ""}
${showDesc ? html`<div class="tmd-desc">${r.chore.description}</div>` : ""}`;
}
_designHeader(child, tt, sub, tone, pillText) {
return html`
<div class="tmd-hd">
${this._av(child, tone, 34)}
<span class="tt">${tt}${sub ? html`<small>${sub}</small>` : ""}</span>
${pillText ? html`<span class="pill">${pillText}</span>` : ""}
</div>`;
}
_designDoneBtn(r, label, cls) {
return html`<button class="btn ${cls || ""}"
?disabled=${r.loading}
@click=${(e) => { e.stopPropagation(); r.onAct(); }}>${label}</button>`;
}
// Done-state chip is CLICKABLE so a completed chore can be undone (parity
// with the classic card, where tapping a done chore undoes it). r.onAct()
// calls _handleUndo when the chore is done.
_designUndoChip(r, label, cls) {
return html`<button class="chip ${cls || "done-chip"} tmd-undochip"
?disabled=${r.loading}
title="${this._t("child.tap_to_undo")}"
@click=${(e) => { e.stopPropagation(); r.onAct(); }}>${label}</button>`;
}
/** Render a timed chore inside designed mode, reusing the classic timed card +
* its full start/pause/stop/celebration/sound/cap logic. */
_designTimed(r) {
return this._renderTimedChoreCard(r.chore, r.child, r.pointsIcon, r.todaysCompletions, r.index);
}
/** Bonus subtasks for a designed chore (reuses classic renderer + handlers). */
_designBonus(r) {
return this._renderBonusSubtasks(r.chore, r.child, r.pointsIcon, r.todaysCompletions);
}
_designPlayroom(child, rows, remaining, tone) {
if (rows.length === 0) return html`<div class="tmd-empty">${this._t("child.all_done")}</div>`;
return html`<div class="tmd-chores">
${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>
<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>`}
${this._designChoreMeta(r)}
</div>
${r.done
? this._designUndoChip(r, html`${this._t("child.done") || "Done"}! 🎉`)
: this._designDoneBtn(r, r.photo ? `📷 ${this._t("child.done") || "DONE"}` : (this._t("child.done") || "DONE"), "good")}
</div>
${this._designBonus(r)}`)}
</div>`;
}
_designConsole(child, rows, remaining, tone) {
if (rows.length === 0) return html`<div class="tmd-empty">${this._t("child.all_done")}</div>`;
return html`<div class="grid">
${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>
<div class="q-mid">
<div class="q-name">${r.chore.name}</div>
${r.done
? html`<div class="num q-cleared">CLEARED</div>`
: html`<div class="num q-xp">+${r.points} XP</div>`}
${this._designChoreMeta(r)}
</div>
${r.done
? this._designUndoChip(r, html`↩`, "q-undo")
: this._designDoneBtn(r, r.photo ? `📷 ${this._t("child.done") || "CLAIM"}` : (this._t("child.done") || "CLAIM"))}
</div>
${this._designBonus(r)}`)}
</div>`;
}
_designCleanpro(child, rows, remaining, tone) {
if (rows.length === 0) return html`<div class="tmd-empty">${this._t("child.all_done")}</div>`;
return html`<div class="tmd-checklist">
${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>
<div class="c-mid">
<div class="c-name">${r.chore.name}</div>
${this._designChoreMeta(r)}
</div>
${r.done
? this._designUndoChip(r, this._t("child.done") || "Completed", "c-done")
: html`<span class="chip c-pts">+${r.points}</span>
${this._designDoneBtn(r, r.photo ? `📷 ${this._t("child.done") || "Done"}` : (this._t("child.done") || "Done"), "sm")}`}
</div>
${this._designBonus(r)}`)}
</div>`;
}
_renderSwappable(allChores, child, pointsIcon) {
// Rotation chores whose turn belongs to another child today, that this
// child is in the pool for — the child can ask a parent to swap them in.
if (this.config.show_swaps === false) return "";
// "Rotation" chores = single-assignee rotating modes. Mirror the backend
// (async_request_swap): everything except everyone/unassigned is swappable.
// We only surface ones currently assigned to another child today that this
// child is in the pool for.
const swappable = (allChores || []).filter(c =>
!["everyone", "unassigned"].includes(c.assignment_mode || "everyone") &&
c.enabled !== false &&
c.assignment_current_child_id &&
c.assignment_current_child_id !== child.id &&
(!Array.isArray(c.assigned_to) || c.assigned_to.length === 0 || c.assigned_to.includes(child.id))
);
if (swappable.length === 0) return "";
if (!this._swapRequested) this._swapRequested = new Set();
return html`
<div class="swap-section">
<div class="section-title">
<ha-icon icon="mdi:swap-horizontal"></ha-icon>
<span class="section-title-text">${this._t('child.swap_section_title') || 'Take over a chore'}</span>
</div>
${swappable.map(c => {
const key = `swap_${c.id}`;
const loading = this._loading[key];
const requested = this._swapRequested.has(c.id);
return html`
<div class="swap-row">
<div class="swap-info">
<div class="chore-name">${c.name}</div>
<div class="chore-points"><ha-icon icon="${pointsIcon}"></ha-icon>+${c.effective_points ?? c.points}</div>
</div>
<button class="swap-btn ${requested ? 'requested' : ''}"
?disabled=${loading || requested}
@click=${() => this._handleRequestSwap(c, child)}>
${requested
? html`<ha-icon icon="mdi:check"></ha-icon> ${this._t('child.swap_requested') || 'Requested'}`
: (this._t('child.swap_request') || 'Request')}
</button>
</div>
`;
})}
</div>
`;
}
async _handleRequestSwap(chore, child) {
const key = `swap_${chore.id}`;
if (this._loading[key]) return;
this._loading = { ...this._loading, [key]: true };
this.requestUpdate();
try {
await this.hass.callService('taskmate', 'request_swap', {
chore_id: chore.id,
requester_id: child.id,
});
if (!this._swapRequested) this._swapRequested = new Set();
this._swapRequested.add(chore.id);
} catch (error) {
console.error('Failed to request swap:', error);
} finally {
this._loading = { ...this._loading, [key]: false };
this.requestUpdate();
}
}
_filterAndSortChores(chores, child) {
const childId = String(child.id || "");
const childName = child.name;
const choreOrder = child.chore_order || [];
// Debug logging to diagnose assignment filtering issues
// Get today's day of week from sensor (set by backend) or compute client-side
const entity = this.hass?.states?.[this.config.entity];
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity?.attributes || {};
const todayDow = entity?.attributes?.today_day_of_week ||
new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
const dueDaysMode = this.config.due_days_mode || 'hide';
const showDueDaysOnly = this.config.show_due_days_only !== false;
const elapsedTimeMode = this.config.elapsed_time_mode || 'dim';
// First, filter chores for this child and time category
const filteredChores = chores.filter(chore => {
// Check if chore is disabled (one-shot chores that are completed or expired)
if (chore.enabled === false) return false;
const disabledFor = chore.disabled_for || [];
if (disabledFor.includes(childId)) return false;
// Check time category. The card's configured filter matches as before;
// on top of that, chores currently in their claim window (including the
// post-period grace) and any future-period chores are let through so
// they can render as locked previews or keep claimable during grace.
const isLockedPreview = this._isChorePreviewLocked(chore);
const inClaimWindow = this._isChoreInClaimWindow(chore);
chore._isLockedPreview = isLockedPreview;
const matchesCardFilter =
this.config.time_category === "all" ||
chore.time_category === this.config.time_category ||
chore.time_category === "anytime";
const matchesTime = matchesCardFilter || isLockedPreview || inClaimWindow;
// Check assignment
let assignedTo = chore.assigned_to;
if (!Array.isArray(assignedTo)) assignedTo = [];
const assignedToStrings = assignedTo.map(id => String(id));
const isAssignedToAll = assignedToStrings.length === 0;
let isAssignedToChild = isAssignedToAll || assignedToStrings.includes(childId);
// Dynamic assignment. Rotation modes (alternating / random / balanced):
// only the currently-active child sees the chore — the backend caches
// today's pick in assignment_current_child_id. first_come is competitive:
// the whole pool sees it (there is no single active child) until one
// member wins the race. Either way, once any eligible pool member has
// completed the chore today (e.g. a parent credited the off-rotation
// child), the chore is done for the whole pool and should disappear for
// everyone — including today's active child.
const assignmentMode = chore.assignment_mode || 'everyone';
if (assignmentMode !== 'everyone' && isAssignedToChild) {
const activeId = chore.assignment_current_child_id ? String(chore.assignment_current_child_id) : '';
if (assignmentMode !== 'first_come') {
isAssignedToChild = activeId !== '' && activeId === String(childId);
}
if (isAssignedToChild) {
const poolIds = assignedToStrings.length > 0
? assignedToStrings
: (attrs.children || []).map(c => String(c.id));
const poolSet = new Set(poolIds);
// first_come is a single-winner race regardless of any configured limit.
const dailyLimit = assignmentMode === 'first_come' ? 1 : (chore.daily_limit || 1);
const allTodayCompletions = attrs.todays_completions || [];
const poolCompletionsToday = allTodayCompletions.filter(
comp => comp.chore_id === chore.id && (poolSet.has(String(comp.child_id)) || comp.child_id === "__parent__") && !comp.bonus_subtask_id
).length;
if (poolCompletionsToday >= dailyLimit) {
// Bonus sub-tasks render inside the parent card; if the active
// child still has any pending, keep the chore visible so they
// remain reachable. Only applies to single-active-child rotation
// modes (mirrors the backend, which guards this on active_child_id).
const bonusSubtasks = activeId ? (chore.bonus_subtasks || []) : [];
const completedBonusIds = new Set(
allTodayCompletions
.filter(c => c.chore_id === chore.id && String(c.child_id) === activeId && c.bonus_subtask_id)
.map(c => c.bonus_subtask_id)
);
const hasPendingBonus = bonusSubtasks.some(b => b && b.id && !completedBonusIds.has(b.id));
if (!hasPendingBonus) {
isAssignedToChild = false;
}
}
}
}
// Check visibility_entity — if set, chore is only visible if entity matches visibility_state
// Supports exact match, numeric comparisons, and attributes
const visibilityEntity = chore.visibility_entity || '';
const visibilityState = chore.visibility_state || 'on';
const visibilityOperator = chore.visibility_operator || 'equals';
let visibilityOK = true;
if (visibilityEntity) {
const entityState = this.hass?.states?.[visibilityEntity];
if (entityState) {
const state = entityState.state;
let matched = false;
// Check numeric comparisons if operator is not "equals"
if (visibilityOperator !== 'equals') {
try {
const threshold = parseFloat(visibilityState);
const value = parseFloat(state);
if (!isNaN(value) && !isNaN(threshold)) {
if (visibilityOperator === 'gte') matched = value >= threshold;
else if (visibilityOperator === 'lte') matched = value <= threshold;
else if (visibilityOperator === 'gt') matched = value > threshold;
else if (visibilityOperator === 'lt') matched = value < threshold;
else if (visibilityOperator === 'not_equals') matched = value !== threshold;
visibilityOK = matched;
}
} catch (e) {
// Fall through to string matching
}
}
if (!matched) {
// Check state (case-insensitive matching)
const stateMatches = state.toLowerCase() === visibilityState.toLowerCase();
// For not_equals, we want the opposite of equality
if (visibilityOperator === 'not_equals') {
visibilityOK = !stateMatches;
} else {
// For equals and other operators, match is success
visibilityOK = stateMatches;
}
if (!visibilityOK && visibilityOperator !== 'not_equals') {
// Check attributes for a matching value (only for equals operator)
if (entityState.attributes) {
for (const attrValue of Object.values(entityState.attributes)) {
if (String(attrValue).toLowerCase() === visibilityState.toLowerCase()) {
visibilityOK = true;
break;
}
}
}
}
}
} else {
visibilityOK = true; // Entity doesn't exist, default to visible
}
}
chore._visibilityEntity = visibilityEntity;
chore._visibilityOK = visibilityOK;
// Check due_days — if chore has due_days set and today isn't one of them
const dueDays = chore.due_days || [];
const hasDueDays = dueDays.length > 0;
const isDueToday = !hasDueDays || dueDays.includes(todayDow);
// If due_days filtering is on and mode is "hide", exclude not-due chores
if (showDueDaysOnly && hasDueDays && !isDueToday && dueDaysMode === 'hide') {
return false;
}
// Store due status on chore object for rendering
chore._isDueToday = isDueToday;
chore._hasDueDays = hasDueDays;
// Mark whether this chore's time period has elapsed (grace-aware).
chore._isTimeElapsed = this._isTimePeriodElapsed(chore);
// If elapsed_time_mode is "hide", exclude elapsed-and-incomplete chores at filter stage.
// Completed chores are handled at render time so we can't check here — use a sentinel.
if (elapsedTimeMode === 'hide' && chore._isTimeElapsed) {
return false;
}
// Only return chore if visibility entity allows it
return matchesTime && isAssignedToChild && visibilityOK;
});
// Debug: Log the filtered results
// If no custom order is set, return filtered chores as-is
if (choreOrder.length === 0) {
return filteredChores;
}
// Sort by the child's custom chore order
// Chores in the order list appear first, in their specified order
// Chores not in the order list appear after, in their default order
return filteredChores.sort((a, b) => {
const indexA = choreOrder.indexOf(a.id);
const indexB = choreOrder.indexOf(b.id);
// If both are in the order list, sort by their position
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
// If only one is in the order list, it comes first
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// If neither is in the order list, maintain original order
return 0;
});
}
_getTimeCategoryIcon(category) {
const period = this._getTimePeriods().find(p => p.id === category);
if (period) return period.icon;
const icons = {
morning: "mdi:weather-sunset-up",
afternoon: "mdi:weather-sunny",
evening: "mdi:weather-sunset-down",
night: "mdi:weather-night",
anytime: "mdi:clock-outline",
all: "mdi:clock-outline",
};
return icons[category] || icons.anytime;
}
_getTimeCategoryLabel(category) {
const period = this._getTimePeriods().find(p => p.id === category);
if (period && period.label) return period.label;
const keyMap = {
morning: 'common.morning',
afternoon: 'common.afternoon',
evening: 'common.evening',
night: 'common.night',
anytime: 'common.anytime',
all: 'common.all',
};
return keyMap[category] ? this._t(keyMap[category]) : category;
}
_getDynamicTitle() {
const category = this.config.time_category;
const period = this._getTimePeriods().find(p => p.id === category);
if (period && period.label) return period.label;
const keyMap = {
morning: 'child.morning_chores',
afternoon: 'child.afternoon_chores',
evening: 'child.evening_chores',
night: 'child.night_chores',
anytime: 'child.todays_chores',
all: 'child.todays_chores',
};
return keyMap[category] ? this._t(keyMap[category]) : this._t('child.todays_chores');
}
_getTimezone() {
// Get timezone from Home Assistant config, fallback to browser timezone
return this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// Ordered list of user-defined periods: [{id, label, icon, start, end}]
// with start/end as decimal hours. Falls back to the legacy fixed four
// when the integration hasn't published time_periods yet.
_getTimePeriods() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
if (Array.isArray(attrs.time_periods) && attrs.time_periods.length) {
return attrs.time_periods
.filter(p => p && p.id)
.map(p => ({
id: p.id,
label: p.label || "",
icon: p.icon || "mdi:clock-outline",
start: this._parseHHMM(p.start, 0, 0),
end: this._parseHHMM(p.end, 23, 59),
}))
.sort((a, b) => a.start - b.start);
}
const tb = attrs.time_boundaries || {};
return [
{ id: "morning", label: "", icon: "mdi:weather-sunset-up", start: this._parseHHMM(tb.morning_start, 6, 0), end: this._parseHHMM(tb.morning_end, 12, 0) },
{ id: "afternoon", label: "", icon: "mdi:weather-sunny", start: this._parseHHMM(tb.afternoon_start, 12, 0), end: this._parseHHMM(tb.afternoon_end, 17, 0) },
{ id: "evening", label: "", icon: "mdi:weather-sunset-down", start: this._parseHHMM(tb.evening_start, 17, 0), end: this._parseHHMM(tb.evening_end, 21, 0) },
{ id: "night", label: "", icon: "mdi:weather-night", start: this._parseHHMM(tb.night_start, 21, 0), end: this._parseHHMM(tb.night_end, 23, 59) },
];
}
_getTimeBoundaries() {
const boundaries = {};
for (const p of this._getTimePeriods()) boundaries[p.id] = [p.start, p.end];
return boundaries;
}
_parseHHMM(str, defH, defM) {
if (!str) return defH + defM / 60;
const [h, m] = str.split(':').map(Number);
if (isNaN(h)) return defH + defM / 60;
return h + (m || 0) / 60;
}
_getCurrentTimePeriod() {
if (this.config.debug_time_period) return this.config.debug_time_period;
const tz = this._getTimezone();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
}).formatToParts(new Date());
let hour = parseInt(parts.find(p => p.type === 'hour')?.value, 10);
const minute = parseInt(parts.find(p => p.type === 'minute')?.value, 10) || 0;
if (isNaN(hour)) hour = 0;
if (hour === 24) hour = 0;
const now = hour + minute / 60;
const periods = this._getTimePeriods();
for (let i = periods.length - 1; i >= 0; i--) {
if (now >= periods[i].start) return periods[i].id;
}
// Before the first period starts (e.g. 02:00) we treat it as the first
// period of the day, matching the legacy pre-morning behaviour.
return periods[0]?.id || 'morning';
}
_getPeriodHours(period) {
const b = this._getTimeBoundaries();
const range = b[period];
if (!range) return null;
return [range[0], range[1]];
}
// Current {hour, minute} in HA timezone. Honours debug_time_period by
// pinning to the period's start so preview/grace logic remains deterministic.
_getCurrentHourMinute() {
if (this.config.debug_time_period) {
const hours = this._getPeriodHours(this.config.debug_time_period);
if (hours) return { hour: Math.floor(hours[0]), minute: Math.round((hours[0] % 1) * 60) };
}
const tz = this._getTimezone();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
}).formatToParts(new Date());
let hour = parseInt(parts.find(p => p.type === 'hour')?.value, 10);
const minute = parseInt(parts.find(p => p.type === 'minute')?.value, 10) || 0;
if (isNaN(hour)) hour = 0;
if (hour === 24) hour = 0;
return { hour, minute };
}
// True iff the chore's period is strictly later than the current period
// (any future period today, not just the immediately next one). Never true
// for "anytime"; no cross-midnight preview.
_isChorePreviewLocked(chore) {
const cat = chore?.time_category;
if (!cat || cat === 'anytime') return false;
const order = this._getTimePeriods().map(p => p.id);
const choreIndex = order.indexOf(cat);
const currentIndex = order.indexOf(this._getCurrentTimePeriod());
if (choreIndex < 0 || currentIndex < 0) return false;
return choreIndex > currentIndex;
}
// True iff the current HA-local time is within the chore's claim window,
// i.e. between period start and (period end + claim_allowance_minutes),
// capped at midnight for night-period chores.
_isChoreInClaimWindow(chore) {
const cat = chore?.time_category;
if (!cat) return false;
if (cat === 'anytime') return true;
const hours = this._getPeriodHours(cat);
if (!hours) return false;
const [startH, endH] = hours;
const allowance = Math.max(0, parseInt(chore.claim_allowance_minutes, 10) || 0);
const startMinutes = startH * 60;
const endMinutes = Math.min(24 * 60, endH * 60 + allowance);
const { hour, minute } = this._getCurrentHourMinute();
const nowMinutes = hour * 60 + minute;
return nowMinutes >= startMinutes && nowMinutes < endMinutes;
}
// Returns true when the chore's time_category period has passed without completion.
// "anytime" chores never elapse. A chore still inside its claim grace window is
// not considered elapsed. Already-completed chores are handled at the call site.
_isTimePeriodElapsed(choreOrCategory) {
const cat = typeof choreOrCategory === 'string'
? choreOrCategory
: choreOrCategory?.time_category;
if (!cat || cat === 'anytime') return false;
const order = {};
this._getTimePeriods().forEach((p, i) => { order[p.id] = i; });
const choreIndex = order[cat];
if (choreIndex === undefined) return false;
const currentIndex = order[this._getCurrentTimePeriod()];
if (currentIndex === undefined || currentIndex <= choreIndex) return false;
if (typeof choreOrCategory === 'object' && this._isChoreInClaimWindow(choreOrCategory)) {
return false;
}
return true;
}
_getDatePartsInTimezone(date) {
const timezone = this._getTimezone();
// Get year, month, day in the HA timezone
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// en-CA formats as YYYY-MM-DD
const dateStr = formatter.format(date);
const [year, month, day] = dateStr.split("-").map(Number);
return { year, month, day };
}
_isToday(date) {
const now = new Date();
const todayParts = this._getDatePartsInTimezone(now);
const dateParts = this._getDatePartsInTimezone(date);
return (
dateParts.year === todayParts.year &&
dateParts.month === todayParts.month &&
dateParts.day === todayParts.day
);
}
_filterCompletionsForToday(completions) {
// Filter completions to only include those completed today (in HA timezone)
return completions.filter(comp => {
if (!comp.completed_at) return false;
const completedDate = new Date(comp.completed_at);
return this._isToday(completedDate);
});
}
_getMidnightCountdown() {
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new Date();
// Get tomorrow midnight in HA timezone
const tomorrow = new Date(now.toLocaleDateString("en-CA", { timeZone: tz }) + "T00:00:00");
tomorrow.setDate(tomorrow.getDate() + 1);
const diffMs = tomorrow - now;
if (diffMs <= 0) return null;
const totalMins = Math.floor(diffMs / 60000);
const hours = Math.floor(totalMins / 60);
const mins = totalMins % 60;
const soon = totalMins <= 60; // less than 1 hour
let label;
if (hours > 0) {
label = this._t('child.chores_reset_hours_mins', { hours, mins });
} else if (mins > 0) {
label = this._t('child.chores_reset_mins', { mins });
} else {
label = this._t('child.chores_resetting_soon');
}
return { label, soon };
}
_renderEmptyState() {
return html`
<div class="empty-state">
<ha-icon icon="mdi:party-popper"></ha-icon>
<div class="message">${this._t('child.all_done')}</div>
<div class="submessage">${this._t('child.no_chores_right_now')}</div>
</div>
`;
}
_renderChoreCard(chore, child, pointsIcon, todaysCompletions = [], choreIndex = 0) {
const isLoading = this._loading[chore.id];
const isCelebrating = this._celebrating === chore.id;
// Check how many times this chore was completed today by this child
// Both pending (awaiting approval) AND approved completions count toward the daily limit
const childCompletionsToday = todaysCompletions.filter(
(comp) => comp.chore_id === chore.id && (comp.child_id === child.id || comp.child_id === "__parent__") && !comp.bonus_subtask_id
);
let completionsToday = childCompletionsToday.length;
const dailyLimit = chore.daily_limit || 1;
// Check for optimistic completions (chores just completed but not yet confirmed by HA)
const optimisticKey = `${chore.id}_${child.id}`;
const optimisticData = this._optimisticCompletions && this._optimisticCompletions[optimisticKey];
const hasOptimisticCompletion = !!optimisticData;
// If we have an optimistic completion, always count it toward the limit
// This is defensive - we'd rather show "completed" incorrectly than allow double-completions
// The optimistic completion will be cleaned up once we verify the server state reflects it
if (hasOptimisticCompletion) {
// Calculate how many optimistic completions we've tracked that aren't yet in the data
const optimisticCount = optimisticData.count || 1;
// Get timestamps from actual completions for this chore/child today
const actualTimestamps = childCompletionsToday.map(comp =>
comp.completed_at ? new Date(comp.completed_at).getTime() : 0
);
// Count how many of our optimistic completions are NOT yet reflected in the data
// An optimistic completion is "reflected" if there's an actual completion within 2 seconds of it
const optimisticTimestamps = optimisticData.timestamps || [optimisticData.timestamp || Date.now()];
let unreflectedOptimistic = 0;
for (const optTs of optimisticTimestamps) {
const isReflected = actualTimestamps.some(actTs => Math.abs(actTs - optTs) < 2000);
if (!isReflected) {
unreflectedOptimistic += 1;
}
}
completionsToday += unreflectedOptimistic;
}
const isCompletedForToday = completionsToday >= dailyLimit;
// Check if the most recent completion is pending approval
const hasPendingCompletion = childCompletionsToday.some((comp) => !comp.approved) || hasOptimisticCompletion;
// Chore number (1-indexed for display) and color class (cycle through 8 colors)
const choreNumber = choreIndex + 1;
const colorClass = `color-${choreIndex % 8}`;
// Click handler for the entire row
const notDueToday = chore._hasDueDays && !chore._isDueToday && this.config.due_days_mode === 'dim';
const recurrenceDoneMode = this.config.recurrence_done_mode || 'dim';
const notAvailableRecurrence = chore._isRecurring && !chore._isAvailableForChild && recurrenceDoneMode === 'dim';
// Elapsed: only dim incomplete chores — completed ones keep their green "done" style
const elapsedTimeMode = this.config.elapsed_time_mode || 'dim';
const timeElapsed = chore._isTimeElapsed && !isCompletedForToday && elapsedTimeMode === 'dim';
const isLockedPreview = !!chore._isLockedPreview && !isCompletedForToday;
const periodStartHour = isLockedPreview
? (this._getPeriodHours(chore.time_category)?.[0] ?? null)
: null;
const lockedUntilLabel = isLockedPreview && periodStartHour !== null
? this._t('child.chore_locked_until', { time: `${String(Math.floor(periodStartHour)).padStart(2, '0')}:${String(Math.round((periodStartHour % 1) * 60)).padStart(2, '0')}` })
: '';
const handleRowClick = () => {
if (isLoading) return;
if (notDueToday) return; // Dim mode — not interactive
if (notAvailableRecurrence) return; // Recurrence window not open — not interactive
if (isLockedPreview) return; // Next-period preview — not yet claimable
if (timeElapsed) return; // Time period passed — not interactive
if (isCompletedForToday) {
this._handleUndo(chore, child, childCompletionsToday);
} else {
this._handleComplete(chore, child);
}
};
const isInteractive = !(isLoading || notDueToday || notAvailableRecurrence || isLockedPreview || timeElapsed);
const handleRowKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRowClick();
}
};
const titleText = isLockedPreview
? (lockedUntilLabel || this._t('child.chore_locked_until_generic'))
: notDueToday
? this._t('child.not_scheduled_for_today')
: timeElapsed
? this._t('child.time_has_passed')
: isCompletedForToday
? this._t('child.click_to_undo')
: this._t('child.click_to_complete');
return html`
<div
class="chore-card ${chore.mandatory ? "mandatory" : ""} ${isLoading ? "loading" : ""} ${isCelebrating ? "celebrating" : ""} ${isCompletedForToday ? "completed" : ""} ${notDueToday ? "not-due-today" : ""} ${notAvailableRecurrence ? "recurrence-unavailable" : ""} ${timeElapsed ? "time-elapsed" : ""} ${isLockedPreview ? "chore-locked" : ""}"
role="button"
tabindex="${isInteractive ? '0' : '-1'}"
aria-disabled="${isInteractive ? 'false' : 'true'}"
aria-label="${chore.name}"
@click="${handleRowClick}"
@keydown="${handleRowKeyDown}"
title="${titleText}"
>
<div class="chore-info">
<div class="chore-number-wrapper">
<div class="chore-number-badge ${colorClass}">${choreNumber}</div>
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}${chore.mandatory ? html`<span class="mandatory-badge">⚠ ${this._t('child.mandatory')}</span>` : ''}</div>
${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>
` : ''}
${chore._isRecurring && !chore._isAvailableForChild ? html`
<div class="recurrence-label">
<ha-icon icon="mdi:clock-outline"></ha-icon>
${chore.recurrence ? chore.recurrence.replace(/_/g, ' ') : this._t('child.recurring')}
</div>
` : ''}
<div class="chore-points">
<ha-icon icon="${pointsIcon}"></ha-icon>
+${chore.effective_points ?? chore.points}
${dailyLimit > 1 ? html`<span style="font-size: 0.8em; opacity: 0.7;">(${completionsToday}/${dailyLimit})</span>` : ''}
</div>
${chore.require_photo && !isCompletedForToday ? html`
<div class="recurrence-label">
<span class="photo-badge">📷 ${this._t('child.photo_needed')}</span>
</div>
` : ''}
</div>
</div>
<div class="chore-checkbox">
${isLoading
? html`<ha-icon icon="mdi:loading" style="animation: spin 1s linear infinite; color: var(--fun-purple);"></ha-icon>`
: isLockedPreview
? html`<ha-icon icon="mdi:lock-clock" class="chore-lock-icon"></ha-icon>`
: html`<ha-icon icon="mdi:check-bold"></ha-icon>`}
</div>
</div>
`;
}
_renderTimedChoreCard(chore, child, pointsIcon, todaysCompletions, choreIndex) {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|| this.hass?.states?.[this.config.entity]?.attributes || {};
const timedSessions = attrs.active_timed_sessions || [];
const session = timedSessions.find(s => s.chore_id === chore.id && s.child_id === child.id);
const state = session ? session.state : 'idle';
const isLoading = this._loading[`timed_${chore.id}`];
const colorClass = `color-${choreIndex % 8}`;
const choreNumber = choreIndex + 1;
// Calculate elapsed seconds
let elapsed = session ? (session.total_seconds_today || 0) : 0;
if (state === 'running' && session && session.current_segment_start) {
const startMs = new Date(session.current_segment_start).getTime();
const nowMs = Date.now();
elapsed += Math.max(0, Math.floor((nowMs - startMs) / 1000));
}
// Calculate earned points
const rateSeconds = (chore.timed_rate_minutes || 5) * 60;
const ratePoints = chore.timed_rate_points || 10;
const earnedPoints = Math.floor(elapsed / rateSeconds) * ratePoints;
// Format time as MM:SS
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const timeStr = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
// Rate display
const rateLabel = chore.timed_rate_minutes === 1
? `${ratePoints} ${this._t('child.pts_per_min')}`
: `${ratePoints} ${this._t('child.pts_per_n_min', {count: chore.timed_rate_minutes})}`;
// Daily cap info
const maxMin = chore.timed_max_daily_minutes || 0;
const usedMin = Math.floor(elapsed / 60);
const capPct = maxMin > 0 ? Math.min(100, (elapsed / (maxMin * 60)) * 100) : 0;
const nearCap = maxMin > 0 && capPct >= 85;
const handleStart = (e) => {
e.stopPropagation();
if (isLoading) return;
this._loading = { ...this._loading, [`timed_${chore.id}`]: true };
this.hass.callService('taskmate', 'start_timed_task', {
chore_id: chore.id, child_id: child.id
}).then(() => {
this._loading = { ...this._loading, [`timed_${chore.id}`]: false };
}).catch(() => {
this._loading = { ...this._loading, [`timed_${chore.id}`]: false };
});
};
const handlePause = (e) => {
e.stopPropagation();
if (isLoading) return;
this._loading = { ...this._loading, [`timed_${chore.id}`]: true };
this.hass.callService('taskmate', 'pause_timed_task', {
chore_id: chore.id, child_id: child.id
}).then(() => {
this._loading = { ...this._loading, [`timed_${chore.id}`]: false };
}).catch(() => {
this._loading = { ...this._loading, [`timed_${chore.id}`]: false };
});
};
const handleStop = (e) => {
e.stopPropagation();
if (isLoading) return;
this._celebrationPoints = earnedPoints;
this._loading = { ...this._loading, [`timed_${chore.id}`]: true };
this.hass.callService('taskmate', 'stop_timed_task', {
chore_id: chore.id, child_id: child.id
}).then(() => {
this._loading = { ...this._loading, [`timed_${chore.id}`]: false };
const sound = chore.completion_sound || this.config.default_sound || 'coin';
this._playSound(sound);
this._celebrating = chore.id;
this._launchConfetti();
setTimeout(() => { this._celebrating = null; this._celebrationPoints = null; this.requestUpdate(); }, 2000);
}).catch(() => {
this._loading = { ...this._loading, [`timed_${chore.id}`]: false };
});
};
return html`
<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>
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}</div>
</div>
<div class="timed-rate">${rateLabel}</div>
</div>
${state !== 'idle' ? html`
<div class="timer-display">
${state === 'running' ? html`<div class="recording-dot"></div>` : ''}
${state === 'paused' ? html`<div class="paused-badge">${this._t('child.paused') || 'PAUSED'}</div>` : ''}
<div class="timer-counter ${state}">${timeStr}</div>
<div class="timer-points-live">
<ha-icon icon="${pointsIcon}"></ha-icon>
${earnedPoints}
</div>
</div>
` : ''}
<div class="timer-actions">
${state === 'idle' ? html`
<button class="timer-btn start" @click="${handleStart}" ?disabled="${isLoading}">
<span class="btn-icon">&#9654;</span>
${this._t('child.start') || 'START'}
</button>
` : ''}
${state === 'running' ? html`
<button class="timer-btn pause" @click="${handlePause}" ?disabled="${isLoading}">
<span class="btn-icon">&#9208;</span>
${this._t('child.pause') || 'PAUSE'}
</button>
<button class="timer-btn stop" @click="${handleStop}" ?disabled="${isLoading}">
<span class="btn-icon">&#9209;</span>
${this._t('child.done') || 'DONE'}
</button>
` : ''}
${state === 'paused' ? html`
<button class="timer-btn resume" @click="${handleStart}" ?disabled="${isLoading}">
<span class="btn-icon">&#9654;</span>
${this._t('child.resume') || 'RESUME'}
</button>
<button class="timer-btn stop" @click="${handleStop}" ?disabled="${isLoading}">
<span class="btn-icon">&#9209;</span>
${this._t('child.done') || 'DONE'}
</button>
` : ''}
</div>
${maxMin > 0 ? html`
<div class="daily-cap-bar">
<span class="cap-label ${nearCap ? 'near-cap' : ''}">${this._t('child.timed_cap_label', {used: usedMin, max: maxMin})}</span>
<div class="cap-track">
<div class="cap-fill ${nearCap ? 'warning' : ''}" style="width: ${capPct}%"></div>
</div>
</div>
` : ''}
</div>
`;
}
_renderBonusSubtasks(chore, child, pointsIcon, todaysCompletions) {
const subtasks = chore.bonus_subtasks;
if (!subtasks || subtasks.length === 0) return '';
// Check if parent chore is completed today (including optimistic)
const parentCompletions = todaysCompletions.filter(
c => c.chore_id === chore.id && (c.child_id === child.id || c.child_id === "__parent__") && !c.bonus_subtask_id
);
const optimisticKey = `${chore.id}_${child.id}`;
const hasOptimistic = this._optimisticCompletions && this._optimisticCompletions[optimisticKey];
const parentDone = parentCompletions.length > 0 || hasOptimistic;
if (!parentDone) return '';
return html`${subtasks.map(subtask => {
const bonusKey = `${chore.id}_bonus_${subtask.id}_${child.id}`;
const bonusOptimistic = this._optimisticCompletions && this._optimisticCompletions[bonusKey];
const bonusDone = todaysCompletions.some(
c => c.chore_id === chore.id && c.child_id === child.id && c.bonus_subtask_id === subtask.id
) || bonusOptimistic;
const isLoading = this._loading[bonusKey];
const handleClick = () => {
if (isLoading) return;
if (bonusDone) {
this._handleUndoBonusSubtask(chore, subtask, child, todaysCompletions);
} else {
this._handleCompleteBonusSubtask(chore, subtask, child);
}
};
return html`
<div
class="chore-card bonus-subtask ${bonusDone ? 'completed' : ''} ${isLoading ? 'loading' : ''}"
role="button"
tabindex="0"
@click="${handleClick}"
title="${bonusDone ? this._t('child.click_to_undo') : this._t('child.bonus_tooltip', {name: subtask.name})}"
>
<div class="chore-info">
<div class="chore-number-wrapper">
<div class="chore-number-badge bonus-badge">
<ha-icon icon="mdi:star-plus" style="--mdc-icon-size:14px"></ha-icon>
</div>
</div>
<div class="chore-details">
<div class="chore-name bonus-name">${subtask.name}</div>
<div class="chore-points">
<ha-icon icon="${pointsIcon}"></ha-icon>
+${subtask.points}
<span class="bonus-label">${this._t('child.bonus_label')}</span>
</div>
</div>
</div>
<div class="chore-checkbox">
${isLoading
? html`<ha-icon icon="mdi:loading" style="animation: spin 1s linear infinite; color: var(--fun-purple);"></ha-icon>`
: html`<ha-icon icon="mdi:check-bold"></ha-icon>`}
</div>
</div>
`;
})}`;
}
async _handleCompleteBonusSubtask(chore, subtask, child) {
const bonusKey = `${chore.id}_bonus_${subtask.id}_${child.id}`;
this._loading = { ...this._loading, [bonusKey]: true };
this.requestUpdate();
// Optimistic completion
if (!this._optimisticCompletions) this._optimisticCompletions = {};
this._optimisticCompletions[bonusKey] = { timestamp: Date.now(), count: 1 };
try {
await this.hass.callService("taskmate", "complete_bonus_subtask", {
chore_id: chore.id,
bonus_subtask_id: subtask.id,
child_id: child.id,
});
this._playSound(chore.completion_sound || this.config.default_sound || "chime");
} catch (err) {
delete this._optimisticCompletions[bonusKey];
}
this._loading = { ...this._loading, [bonusKey]: false };
this.requestUpdate();
// Clear optimistic after 30s
setTimeout(() => {
if (this._optimisticCompletions && this._optimisticCompletions[bonusKey]) {
delete this._optimisticCompletions[bonusKey];
this.requestUpdate();
}
}, 30000);
}
/**
* Undo a completed bonus sub-task (second click on its checkbox). Each
* sub-task completion carries its own completion_id, so rejecting it reverses
* only that sub-task's points — the parent chore stays done. Mirrors the
* parent-only admin gate and reject_chore path used by _handleUndo.
*/
async _handleUndoBonusSubtask(chore, subtask, child, todaysCompletions) {
const bonusKey = `${chore.id}_bonus_${subtask.id}_${child.id}`;
if (this._loading[bonusKey]) return;
// Undoing removes already-awarded points, so it is parent-only — short-circuit
// for non-parents with one friendly message instead of a doomed service call.
if (!window.__taskmate_is_parent(this.hass)) {
this._notifyUndo(this._t("child.undo_not_allowed"));
return;
}
const clearOptimistic = () => {
if (this._optimisticCompletions && this._optimisticCompletions[bonusKey]) {
const next = { ...this._optimisticCompletions };
delete next[bonusKey];
this._optimisticCompletions = next;
}
};
// Find the persisted completion record for this sub-task.
const completion = (todaysCompletions || []).find(
c => c.chore_id === chore.id && c.child_id === child.id && c.bonus_subtask_id === subtask.id
);
const completionId = completion?.completion_id || completion?.id;
// Only an optimistic tick and nothing on the server yet — just roll back the
// optimistic state; there is no completion to reject.
if (!completionId) {
clearOptimistic();
this.requestUpdate();
return;
}
this._loading = { ...this._loading, [bonusKey]: true };
this.requestUpdate();
try {
await this.hass.callService("taskmate", "reject_chore", {
completion_id: completionId,
});
this._playSound(this.config.undo_sound || "undo");
clearOptimistic();
} catch (error) {
console.error("Failed to undo bonus sub-task completion:", error);
const message = this._isUnauthorized(error)
? this._t("child.undo_not_allowed")
: this._t("child.error_undo", { message: error?.message || "" });
this._notifyUndo(message);
} finally {
this._loading = { ...this._loading, [bonusKey]: false };
this.requestUpdate();
}
}
_renderCelebration() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || this.hass?.states?.[this.config.entity]?.attributes || {};
const pointsIcon = attrs.points_icon || "mdi:star";
const celebratingChore = (attrs.chores || []).find(
c => c.id === this._celebrating
);
const completions = attrs.todays_completions || [];
const latestCompletion = completions.filter(
c => c.chore_id === this._celebrating && c.child_id === this.config.child_id
).pop();
const points = (this._celebrationPoints != null)
? this._celebrationPoints
: (latestCompletion && latestCompletion.points != null)
? latestCompletion.points
: (celebratingChore?.points || 0);
return html`
<div class="celebration-overlay" @click="${this._closeCelebration}">
<div class="celebration-content" @click="${(e) => e.stopPropagation()}">
<div class="celebration-stars">&#127775;&#127775;&#127775;</div>
<div class="celebration-title">${this._t('child.celebration_title')}</div>
<div class="celebration-message">${this._t('child.celebration_message')}</div>
<div class="celebration-points">
<ha-icon icon="${pointsIcon}"></ha-icon>
+${points}
</div>
</div>
</div>
`;
}
_renderConfetti() {
const colors = ["#ff6b9d", "#9b59b6", "#3498db", "#2ecc71", "#f1c40f", "#e67e22"];
return html`
<div class="confetti-container">
${this._confetti.map((piece, index) => html`
<div
class="confetti"
style="
left: ${piece.x}%;
animation-delay: ${piece.delay}s;
background: ${colors[index % colors.length]};
border-radius: ${piece.round ? '50%' : '0'};
width: ${piece.size}px;
height: ${piece.size}px;
"
></div>
`)}
</div>
`;
}
async _handleComplete(chore, child, photoUrl = null) {
const key = `${chore.id}_${child.id}`;
const dailyLimit = chore.daily_limit || 1;
// Check if already loading for this chore (prevent double-clicks during loading)
if (this._loading[chore.id]) {
return;
}
// Photo-evidence chores can't be completed with a plain tap — open the
// capture overlay first. The overlay uploads the photo and then calls back
// into this method with a photoUrl, which takes the service path below.
if (chore.require_photo && !photoUrl) {
this._openPhotoCapture(chore, child);
return;
}
// Get current completion count including optimistic completions
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || this.hass?.states?.[this.config.entity]?.attributes || {};
const allCompletions = attrs.todays_completions || [];
const todaysCompletions = this._filterCompletionsForToday(allCompletions);
const actualCompletionsToday = todaysCompletions.filter(
(comp) => comp.chore_id === chore.id && comp.child_id === child.id
).length;
// Count existing optimistic completions for this chore/child
const existingData = this._optimisticCompletions[key];
const existingOptimisticCount = existingData?.count || 0;
// Calculate total completions (actual + optimistic)
// This is a simplified count - we're being defensive and not trying to dedupe
const totalCompletions = actualCompletionsToday + existingOptimisticCount;
// Guard: If daily limit already reached, don't allow another completion
if (totalCompletions >= dailyLimit) {
this.requestUpdate(); // Force re-render to show completed state
return;
}
// IMMEDIATELY set optimistic completion BEFORE making the service call
// This prevents double-clicks even if the button hasn't re-rendered yet
const now = Date.now();
const existingTimestamps = existingData?.timestamps || [];
this._optimisticCompletions = {
...this._optimisticCompletions,
[key]: {
timestamp: now,
timestamps: [...existingTimestamps, now],
count: existingOptimisticCount + 1,
},
};
this._loading = { ...this._loading, [chore.id]: true };
this.requestUpdate();
let cleanupTimeout;
try {
// Prefer pressing the per-child/per-chore button entity so HA
// state-trigger automations on button.taskmate_<child>_complete_<chore>
// fire. Falls back to the service call if the entity isn't registered
// yet (e.g. right after HA restart).
// A photo-evidence completion MUST go through the service so it can carry
// photo_url — a button-entity press cannot. Other chores prefer the button
// entity so HA state-trigger automations fire.
const buttonEntityId = photoUrl ? null : (window.__taskmate_find_button
&& window.__taskmate_find_button(this.hass, child.id, "complete", chore.id));
if (buttonEntityId) {
await this.hass.callService("button", "press", { entity_id: buttonEntityId });
} else {
await this.hass.callService("taskmate", "complete_chore", {
chore_id: chore.id,
child_id: child.id,
...(photoUrl ? { photo_url: photoUrl } : {}),
});
}
// Trigger celebration!
this._celebrating = chore.id;
this._spawnConfetti();
// Play completion sound!
// Use the chore's completion_sound, fall back to config default, then to 'coin'
const soundToPlay = chore.completion_sound || this.config.default_sound || 'coin';
this._playSound(soundToPlay);
this.requestUpdate();
// Auto-close celebration after 2.5 seconds
setTimeout(() => {
this._closeCelebration();
}, 2500);
// Clean up optimistic completion after 30 seconds (by then HA state should be updated)
cleanupTimeout = setTimeout(() => {
const newOptimistic = { ...this._optimisticCompletions };
delete newOptimistic[key];
this._optimisticCompletions = newOptimistic;
this.requestUpdate();
}, 30000);
} catch (error) {
console.error("Failed to complete chore:", error);
// Cancel the 30-second cleanup timer since we're handling cleanup now
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
}
// Remove the optimistic completion since the service call failed
const newOptimistic = { ...this._optimisticCompletions };
const currentData = newOptimistic[key];
if (currentData) {
// Remove the timestamp we just added
const newTimestamps = currentData.timestamps.filter(ts => ts !== now);
if (newTimestamps.length === 0) {
delete newOptimistic[key];
} else {
newOptimistic[key] = {
...currentData,
timestamps: newTimestamps,
count: newTimestamps.length,
};
}
this._optimisticCompletions = newOptimistic;
}
// Show error notification
if (this.hass.callService) {
this.hass.callService("persistent_notification", "create", {
title: this._t('child.error_title'),
message: this._t('child.error_complete', { message: error.message }),
notification_id: `taskmate_error_${chore.id}`,
});
}
} finally {
this._loading = { ...this._loading, [chore.id]: false };
this.requestUpdate();
}
}
// ── Photo-evidence capture ────────────────────────────────────────────────
_openPhotoCapture(chore, child) {
this._photoCapture = {
chore, child, step: "pick", previewUrl: "", blob: null,
sizeKb: 0, error: "",
};
this.requestUpdate();
// Open the picker straight away (still inside the user's tap gesture).
this.updateComplete.then(() => this._triggerPhotoPicker());
}
_triggerPhotoPicker() {
const input = this.renderRoot && this.renderRoot.querySelector("#tm-photo-input");
if (input) {
input.value = "";
input.click();
}
}
_onPhotoSelected(e) {
const file = e.target.files && e.target.files[0];
e.target.value = "";
if (!file || !this._photoCapture) return;
if (!file.type || !file.type.startsWith("image/")) {
this._photoCapture = { ...this._photoCapture, step: "pick",
error: this._t("child.photo_not_image") };
this.requestUpdate();
return;
}
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const max = 1280;
let { width, height } = img;
if (width > max || height > max) {
const scale = Math.min(max / width, max / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (!blob || !this._photoCapture) {
this._photoCapture = { ...this._photoCapture, step: "pick",
error: this._t("child.photo_process_failed") };
this.requestUpdate();
return;
}
if (this._photoCapture.previewUrl) URL.revokeObjectURL(this._photoCapture.previewUrl);
this._photoCapture = {
...this._photoCapture, step: "preview", blob,
previewUrl: URL.createObjectURL(blob),
sizeKb: Math.round(blob.size / 1024), error: "",
};
this.requestUpdate();
}, "image/jpeg", 0.8);
};
img.onerror = () => {
URL.revokeObjectURL(url);
this._photoCapture = { ...this._photoCapture, step: "pick",
error: this._t("child.photo_process_failed") };
this.requestUpdate();
};
img.src = url;
}
async _confirmPhoto() {
const cap = this._photoCapture;
if (!cap || !cap.blob) return;
this._photoCapture = { ...cap, step: "uploading", error: "" };
this.requestUpdate();
// POST the blob with a Bearer token, refreshing the (short-lived) access
// token if expired. A 401 means the cached token went stale mid-session, so
// force a refresh and retry once before giving up.
const postPhoto = async () => {
const token = this.hass && this.hass.auth && this.hass.auth.data
&& this.hass.auth.data.access_token;
const fd = new FormData();
fd.append("file", cap.blob, "evidence.jpg");
return fetch("/api/taskmate/photo", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
};
try {
if (this.hass && this.hass.auth && this.hass.auth.expired
&& this.hass.auth.refreshAccessToken) {
try { await this.hass.auth.refreshAccessToken(); } catch (e) { /* surfaced below */ }
}
let resp = await postPhoto();
if (resp.status === 401 && this.hass && this.hass.auth
&& this.hass.auth.refreshAccessToken) {
await this.hass.auth.refreshAccessToken();
resp = await postPhoto();
}
if (!resp.ok) throw new Error(`upload failed (${resp.status})`);
const { photo_url: photoUrl } = await resp.json();
if (!photoUrl) throw new Error("no photo_url in response");
const { chore, child } = cap;
this._closePhotoCapture();
await this._handleComplete(chore, child, photoUrl);
} catch (err) {
// Keep the overlay open on the preview so the child can retry.
if (this._photoCapture) {
this._photoCapture = { ...this._photoCapture, step: "preview",
error: this._t("child.photo_upload_failed") };
this.requestUpdate();
}
}
}
_retakePhoto() {
if (!this._photoCapture) return;
if (this._photoCapture.previewUrl) URL.revokeObjectURL(this._photoCapture.previewUrl);
this._photoCapture = { ...this._photoCapture, step: "pick",
previewUrl: "", blob: null, sizeKb: 0, error: "" };
this.requestUpdate();
this._triggerPhotoPicker();
}
_closePhotoCapture() {
if (this._photoCapture && this._photoCapture.previewUrl) {
URL.revokeObjectURL(this._photoCapture.previewUrl);
}
this._photoCapture = null;
this.requestUpdate();
}
_renderPhotoCapture() {
const cap = this._photoCapture;
if (!cap) return "";
const chore = cap.chore || {};
const stop = (e) => e.stopPropagation();
return html`
<div class="photo-overlay" @click="${() => this._closePhotoCapture()}">
<div class="photo-sheet" @click="${stop}">
<div class="photo-title">📷 ${this._t("child.photo_required_title")}</div>
<div class="photo-for">${chore.name || ""}</div>
${cap.step === "pick" ? html`
<div class="photo-dropzone" @click="${() => this._triggerPhotoPicker()}">
<div class="photo-dropzone-icon">📸</div>
<div class="photo-dropzone-label">${this._t("child.photo_take")}</div>
<div class="photo-dropzone-hint">${this._t("child.photo_hint")}</div>
</div>
${cap.error ? html`<div class="photo-error">${cap.error}</div>` : ""}
<button class="photo-cancel" @click="${() => this._closePhotoCapture()}">
${this._t("child.photo_cancel")}
</button>
` : ""}
${cap.step === "preview" ? html`
<div class="photo-preview">
<img src="${cap.previewUrl}" alt="">
<span class="photo-size-tag">~${cap.sizeKb} KB</span>
</div>
<div class="photo-btn-row">
<button class="photo-btn ghost" @click="${() => this._retakePhoto()}">
${this._t("child.photo_retake")}
</button>
<button class="photo-btn primary" @click="${() => this._confirmPhoto()}">
${this._t("child.photo_confirm")}
</button>
</div>
${cap.error ? html`<div class="photo-error">${cap.error}</div>` : ""}
<button class="photo-cancel" @click="${() => this._closePhotoCapture()}">
${this._t("child.photo_cancel")}
</button>
` : ""}
${cap.step === "uploading" ? html`
<div class="photo-uploading">
<div class="photo-spinner"></div>
<div class="photo-uploading-label">${this._t("child.photo_uploading")}</div>
</div>
` : ""}
</div>
</div>
`;
}
/** Show a brief, friendly popup (HA snackbar) instead of a raw error. */
_notifyUndo(message) {
this.dispatchEvent(new CustomEvent("hass-notification", {
detail: { message },
bubbles: true,
composed: true,
}));
}
/** True if an error from reject_chore is an authorization/admin rejection. */
_isUnauthorized(error) {
const text = `${error?.message || ""} ${error?.code || ""}`.toLowerCase();
return text.includes("unauthorized") || text.includes("not authorized");
}
async _handleUndo(chore, child, childCompletionsToday) {
// Check if already loading for this chore (prevent double-clicks during loading)
if (this._loading[chore.id]) {
return;
}
// Undoing a completion removes already-awarded points, so it is parent-only
// (this mirrors the parent gate on the reject_chore service). For a non-parent
// user the call always fails with a raw "Unauthorized" — HA shows its own
// snackbar and we used to add an error notification on top. Short-circuit
// with one clear, friendly message and never fire the doomed call.
if (!window.__taskmate_is_parent(this.hass)) {
this._notifyUndo(this._t("child.undo_not_allowed"));
return;
}
// Find the most recent completion for this chore/child to undo
// Sort by completed_at descending to get the most recent
const sortedCompletions = [...childCompletionsToday].sort((a, b) => {
const dateA = new Date(a.completed_at || 0);
const dateB = new Date(b.completed_at || 0);
return dateB - dateA;
});
const completionToUndo = sortedCompletions[0];
// Check for completion_id (from sensor) or id (fallback)
const completionId = completionToUndo?.completion_id || completionToUndo?.id;
if (!completionToUndo || !completionId) {
return;
}
this._loading = { ...this._loading, [chore.id]: true };
this.requestUpdate();
try {
// Call the reject_chore service to remove the completion
await this.hass.callService("taskmate", "reject_chore", {
completion_id: completionId,
});
// Play undo sound (sad/descending tone)
const undoSoundToPlay = this.config.undo_sound || 'undo';
this._playSound(undoSoundToPlay);
// Clear any optimistic completion data for this chore/child (including bonus sub-tasks)
const key = `${chore.id}_${child.id}`;
const newOptimistic = { ...this._optimisticCompletions };
delete newOptimistic[key];
// Also clear bonus sub-task optimistic completions
const bonusPrefix = `${chore.id}_bonus_`;
const bonusSuffix = `_${child.id}`;
for (const k of Object.keys(newOptimistic)) {
if (k.startsWith(bonusPrefix) && k.endsWith(bonusSuffix)) delete newOptimistic[k];
}
this._optimisticCompletions = newOptimistic;
} catch (error) {
console.error("Failed to undo chore completion:", error);
// Fallback for the rare case an undo still fails (e.g. a parent hit a
// genuine error). Show a single transient popup, not a sticky bell
// notification. An auth rejection gets the same friendly wording as the
// pre-check above.
const message = this._isUnauthorized(error)
? this._t("child.undo_not_allowed")
: this._t("child.error_undo", { message: error?.message || "" });
this._notifyUndo(message);
} finally {
this._loading = { ...this._loading, [chore.id]: false };
this.requestUpdate();
}
}
_spawnConfetti() {
const confetti = [];
for (let i = 0; i < 50; i++) {
confetti.push({
x: Math.random() * 100,
delay: Math.random() * 0.5,
size: Math.random() * 8 + 6,
round: Math.random() > 0.5,
});
}
this._confetti = confetti;
// Clear confetti after animation
setTimeout(() => {
this._confetti = [];
this.requestUpdate();
}, 3500);
}
_closeCelebration() {
this._celebrating = null;
this.requestUpdate();
}
}
// Card Editor
class TaskMateChildCardEditor extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
};
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
static get styles() {
return css`
:host { display: block; padding: 4px 0; }
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-left: auto;
}
.colour-helper {
color: var(--secondary-text-color);
font-size: 0.82rem;
line-height: 1.3;
}
`;
}
setConfig(config) {
this.config = config;
}
_timeCategoryEditorOptions(overviewEntity) {
const builtinLabels = {
morning: this._t('child.editor.time_category_morning'),
afternoon: this._t('child.editor.time_category_afternoon'),
evening: this._t('child.editor.time_category_evening'),
night: this._t('child.editor.time_category_night'),
};
const periods = overviewEntity?.attributes?.time_periods;
const periodOptions = Array.isArray(periods) && periods.length
? periods.filter(p => p && p.id).map(p => ({
value: p.id,
label: p.label || builtinLabels[p.id] || p.id,
}))
: Object.entries(builtinLabels).map(([value, label]) => ({ value, label }));
return [
...periodOptions,
{ value: 'anytime', label: this._t('child.editor.time_category_anytime') },
{ value: 'all', label: this._t('child.editor.time_category_all') },
];
}
_buildSchema() {
const overviewEntity = this.config?.entity
? this.hass?.states?.[this.config.entity]
: null;
const children = overviewEntity?.attributes?.children || [];
return [
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
{
name: 'child_id',
selector: {
select: {
options: [
{ value: '', label: this._t('child.editor.select_child') },
...children.map((c) => ({ value: c.id, label: c.name })),
],
mode: 'dropdown',
},
},
},
{
name: 'time_category',
selector: {
select: {
options: this._timeCategoryEditorOptions(overviewEntity),
mode: 'dropdown',
},
},
},
{
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',
},
},
},
{
name: 'due_days_mode',
selector: {
select: {
options: [
{ value: 'hide', label: this._t('child.editor.due_days_hide') },
{ value: 'dim', label: this._t('child.editor.due_days_dim') },
{ value: 'show', label: this._t('child.editor.due_days_show') },
],
mode: 'dropdown',
},
},
},
{
name: 'recurrence_done_mode',
selector: {
select: {
options: [
{ value: 'dim', label: this._t('child.editor.recurrence_dim') },
{ value: 'hide', label: this._t('child.editor.recurrence_hide') },
{ value: 'show', label: this._t('child.editor.recurrence_show') },
],
mode: 'dropdown',
},
},
},
{
name: 'elapsed_time_mode',
selector: {
select: {
options: [
{ value: 'dim', label: this._t('child.editor.elapsed_dim') },
{ value: 'hide', label: this._t('child.editor.elapsed_hide') },
{ value: 'show', label: this._t('child.editor.elapsed_show') },
],
mode: 'dropdown',
},
},
},
{ name: 'show_countdown', selector: { boolean: {} } },
{ name: 'show_description', selector: { boolean: {} } },
{ name: 'debug', selector: { boolean: {} } },
];
}
_computeLabel = (entry) => {
const labels = {
entity: this._t('common.editor.overview_entity'),
child_id: this._t('child.editor.child'),
time_category: this._t('child.editor.time_category'),
card_design: this._t('common.design.field_label'),
due_days_mode: this._t('child.editor.chores_not_due_today'),
recurrence_done_mode: this._t('child.editor.recurring_when_completed'),
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'),
debug: this._t('child.editor.show_debug'),
};
return labels[entry.name] ?? entry.name;
};
_computeHelper = (entry) => {
const helpers = {
entity: this._t('common.editor.overview_entity_helper'),
child_id: this._t('child.editor.child_helper'),
time_category: this._t('child.editor.time_category_helper'),
due_days_mode: this._t('child.editor.due_days_helper'),
recurrence_done_mode: this._t('child.editor.recurrence_helper'),
elapsed_time_mode: this._t('child.editor.elapsed_helper'),
};
return helpers[entry.name] ?? '';
};
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || '',
child_id: this.config.child_id || '',
time_category: this.config.time_category || 'morning',
card_design: this.config.card_design || 'global',
due_days_mode: this.config.due_days_mode || 'hide',
recurrence_done_mode: this.config.recurrence_done_mode || 'dim',
elapsed_time_mode: this.config.elapsed_time_mode || 'dim',
show_countdown: this.config.show_countdown !== false,
show_description: this.config.show_description === true,
debug: this.config.debug === true,
};
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', '#9b59b6')}
`;
}
_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),
});
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
const defaults = {
show_countdown: true, show_description: false, debug: false,
time_category: 'morning', due_days_mode: 'hide',
recurrence_done_mode: 'dim', elapsed_time_mode: 'dim',
};
if (value === '' || value === null || value === undefined) {
delete newConfig[key];
} else if (key === 'card_design' && value === 'global') {
delete newConfig[key];
} else if (defaults[key] !== undefined && value === defaults[key]) {
delete newConfig[key];
} else {
newConfig[key] = value;
}
}
this._dispatchConfig(newConfig);
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === undefined || value === '') delete newConfig[key];
this._dispatchConfig(newConfig);
}
_dispatchConfig(newConfig) {
this.dispatchEvent(new CustomEvent('config-changed', {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}
}
// Register the cards
customElements.define("taskmate-child-card", TaskMateChildCard);
customElements.define("taskmate-child-card-editor", TaskMateChildCardEditor);
// Register with Home Assistant
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-child-card",
name: "TaskMate Child Card",
description: "A fun, kid-friendly card for children to complete their chores!",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-child-card", "overview"),
});
// Version is injected by the HA resource URL (?v=x.x.x) and read from the DOM
const _tmVersion = new URLSearchParams(
Array.from(document.querySelectorAll('script[src*="/taskmate-child-card.js"]'))
.map(s => s.src.split("?")[1]).find(Boolean) || ""
).get("v") || "?";
console.info(
"%c TASKMATE CHILD CARD %c v" + _tmVersion + " ",
"background:#9b59b6;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
);