/** * 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); // A reactive chore's countdown (#674) has to keep ticking too, otherwise it // sits on a stale minute until the next coordinator push. const now = Date.now(); const hasDeadline = (attrs.chores || []).some(c => { if (!c.deadline_at) return false; const at = new Date(c.deadline_at).getTime(); return !Number.isNaN(at) && at > now; }); if ((hasRunning || hasDeadline) && !this._timerInterval) { this._timerInterval = setInterval(() => { this._timerTick++; this.requestUpdate(); }, 1000); } else if (!hasRunning && !hasDeadline && 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`
${options.map(o => html` `)}
`; } 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; } /* Designed-header avatar wrapper: mirror of the classic avatar-container so the picker works under every design, not just classic. The picker itself (.avatar-picker) is absolute, so the header is the positioned anchor. Base styles here win over the shared kit's .tmd-hd because the card includes them after the kit in static styles(). */ .tmd-hd { position: relative; } .tmd-av-wrap { position: relative; display: inline-flex; align-items: center; flex: none; } .tmd-av-wrap.avatar-clickable { cursor: pointer; } .tmd-av-edit { position: absolute; bottom: -2px; right: -2px; width: 15px; height: 15px; border-radius: 50%; background: var(--tmd-accent, #7c3aed); color: #fff; display: grid; place-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.3); } .tmd-av-edit ha-icon { --mdc-icon-size: 10px; color: #fff; } .avatar-edit-dot ha-icon { --mdc-icon-size: 12px; color: white; } .avatar-picker { position: absolute; z-index: 20; margin-top: 56px; 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); } /* Pre-reader mode (#683) — picture-only, huge targets */ .pre-reader-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 14px; padding: 4px 0 8px; } .pre-tile { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 140px; padding: 16px 10px; border: 3px solid var(--divider-color, #e0e0e0); border-radius: 26px; background: var(--card-background-color, #fff); font-family: inherit; cursor: pointer; transition: transform 0.12s ease, border-color 0.12s ease; } .pre-tile:active { transform: scale(0.96); } .pre-tile:focus-visible { outline: 4px solid var(--primary-color); outline-offset: 3px; } .pre-tile-icon ha-icon { --mdc-icon-size: 64px; color: var(--primary-color); } .pre-tile-stars { display: flex; gap: 2px; } .pre-tile-stars ha-icon { --mdc-icon-size: 18px; color: #f1c40f; } .pre-tile-label { font-size: 0.85rem; font-weight: 700; text-align: center; color: var(--secondary-text-color); line-height: 1.2; } .pre-tile.done { border-color: var(--success-color, #2ecc71); background: rgba(46, 204, 113, 0.10); } .pre-tile.done .pre-tile-icon ha-icon { opacity: 0.35; } .pre-tile.done .pre-tile-stars { opacity: 0.35; } .pre-tile-tick { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; } .pre-tile-tick ha-icon { --mdc-icon-size: 72px; color: var(--success-color, #2ecc71); } .pre-tile.locked { opacity: 0.4; cursor: default; } .pre-tile.loading { opacity: 0.6; } /* Chore roulette (#677) */ .roulette { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 12px; } /* The --tmd-* tokens only resolve under a designed style; the fallbacks are the original classic values, so classic is unchanged. */ .roulette-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; background: var(--tmd-accent, linear-gradient(135deg, #8e44ad, #c0392b)); color: #fff; border: 0; border-radius: var(--tmd-radius-sm, 14px); padding: 12px 16px; font-family: var(--tmd-font-display, inherit); font-size: 0.95rem; font-weight: 800; cursor: pointer; width: 100%; } .roulette-btn:disabled { opacity: 0.55; cursor: default; } .roulette-btn.is-spinning ha-icon { animation: tm-roulette-spin 0.6s linear infinite; } @keyframes tm-roulette-spin { to { transform: rotate(360deg); } } .roulette-result { display: flex; align-items: center; gap: 8px; background: color-mix(in srgb, var(--tmd-accent, #8e44ad) 12%, transparent); border: 1px solid color-mix(in srgb, var(--tmd-accent, #8e44ad) 35%, transparent); border-radius: var(--tmd-radius-sm, 12px); padding: 8px 12px; } .roulette-mult { background: var(--tmd-accent, #8e44ad); color: #fff; border-radius: var(--tmd-radius-sm, 8px); padding: 2px 8px; font-weight: 900; font-size: 0.85rem; } .roulette-name { flex: 1; font-weight: 700; font-size: 0.95rem; } .roulette-worth { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; color: var(--tmd-accent, #8e44ad); } .roulette-worth ha-icon { --mdc-icon-size: 16px; } .roulette-spent { text-align: center; font-size: 0.8rem; color: var(--tmd-dim, var(--secondary-text-color)); opacity: 0.8; } /* Reactive-chore countdown (#674). Amber by default, red under 5 min. On its own line, not inline in .chore-name — that name ellipsises, and a long one clipped the badge down to just the bonus. */ .deadline-badge { display: inline-flex; align-items: center; gap: 4px; margin-top: 4px; align-self: flex-start; background: rgba(255, 152, 0, 0.18); color: #ef6c00; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.02em; text-transform: uppercase; border-radius: 8px; padding: 2px 7px; white-space: nowrap; width: fit-content; } .deadline-badge.deadline-urgent { background: var(--fun-red); color: #fff; } .deadline-bonus { opacity: 0.85; font-weight: 900; } .mandatory-badge { display: inline-flex; align-items: center; 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 381–600px */ @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 601–1023px */ @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`
${this._t('common.entity_not_found', { entity: this.config.entity })}
`; } // 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`
${this._t('child.child_not_found', { child_id: this.config.child_id })}
`; } // 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`
${(() => { 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`
this._toggleAvatarPicker() : null} title="${canChange ? this._t('child.avatar_change') : ''}"> ${canChange ? html`` : ''}
${canChange && this._avatarPickerOpen ? this._renderAvatarPicker(child, opts) : ''} `; })()}
${child.name}
${child.level ? html`
${this._t('child.level_label', { level: child.level })}
` : ''}
${child.points}
${pointsName}
${pendingPoints > 0 ? html`
+${pendingPoints}
${this._t('common.pending')}
` : ''}
${attrs.vacation_active ? html`
${this._t('child.on_vacation')}${attrs.vacation_name ? ` · ${attrs.vacation_name}` : ''}
` : ''} ${showBadges ? html`
this._openBadgesView()} title="${this._t('badges.label')}"> ${this._t('badges.label')} ${earnedBadges.slice(0, 5).map(b => html`
`)} ${earnedBadges.length > 5 ? html`+${earnedBadges.length - 5} →` : ''}
` : ''}
${childChores.length === 0 ? this._renderEmptyState() : html`
${this._getDynamicTitle()} ${this.config.show_countdown !== false ? (() => { const countdown = this._getMidnightCountdown(); return countdown ? html`
${countdown.label}
` : ''; })() : ''}
${this._renderRoulette(child, childChores, pointsIcon)} ${this.config.pre_reader === true ? html`
${childChores.map((chore, index) => this._renderPreReaderTile(chore, child, todaysCompletions, index))}
` : 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)} `} `)} `}
${this._renderSwappable(allChores, child, pointsIcon)} ${this._celebrating ? this._renderCelebration() : ""} ${this._confetti.length > 0 ? this._renderConfetti() : ""} ${this._renderPhotoCapture()} ${this.config.debug === true ? html`
DEBUG INFO:
Config child_id: "${this.config.child_id}"
Found child.id: "${this._debugInfo?.foundChildId}"
Found child.name: "${this._debugInfo?.foundChildName}"
Total chores: ${this._debugInfo?.totalChores}
Filtered chores: ${this._debugInfo?.filteredCount}
Sample chores assigned_to:
${(this._debugInfo?.sampleChores || []).map(c => html`
- ${c.name}: ${JSON.stringify(c.assigned_to)} (isArray: ${c.isArray})
`)}
` : ""}
`; } /* ══════════════════════════════════════════════════════════════════════ 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 Badges" → sensor.taskmate__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`` : a ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } _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`
${pill || ""} 🧒 ${title}${sub ? html`${sub}` : ""}
`; if (!entity) { return html` ${header(this._t("common.entity_not_found", { entity: this.config.entity }))}
${this._t("common.entity_not_found", { entity: this.config.entity })}
`; } 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` ${header(this._t("child.child_not_found", { child_id: this.config.child_id }))}
${this._t("child.child_not_found", { child_id: this.config.child_id })}
`; } 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`
${this._getDynamicTitle()} ${countdown ? html` ${countdown.label}` : ""}
`; // Pre-reader mode replaces the chore list with picture tiles. It has to be // handled here as well as in the classic path, or `pre_reader: true` is // silently ignored under every designed style — including accessible, // which is the one a child who needs picture tiles is most likely on. // The tiles keep the designed shell (header, tokens) around them. const body = this.config.pre_reader === true ? html`
${childChores.map((chore, index) => this._renderPreReaderTile(chore, child, todaysCompletions, index))}
` : design === "playroom" ? this._designPlayroom(child, rows, remaining, tone) : design === "console" ? this._designConsole(child, rows, remaining, tone) : this._designCleanpro(child, rows, remaining, tone); return html` ${this._designHeaderFull(child, design, remaining, rows.length, tone, pendingPoints, pointsIcon)}
${attrs.vacation_active ? html`
${this._t("child.on_vacation")}${attrs.vacation_name ? ` · ${attrs.vacation_name}` : ""}
` : ""} ${showBadges ? html`
this._openBadgesView()} title="${this._t("badges.label")}"> ${this._t("badges.label")} ${earnedBadges.slice(0, 5).map(b => html`
`)} ${earnedBadges.length > 5 ? html`+${earnedBadges.length - 5} →` : ""}
` : ""} ${sectionLine} ${this._renderRoulette(child, childChores, pointsIcon)} ${body} ${this._renderSwappable(allChores, child, pointsIcon)}
${this._celebrating ? this._renderCelebration() : ""} ${this._confetti.length > 0 ? this._renderConfetti() : ""} ${this._renderPhotoCapture()}
`; } /** 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")}`; // Avatar picker: classic makes the avatar tappable; the designed header // must too, or the picker only ever works on classic. Same eligibility // rule (allow_avatar_change + more than one unlocked option). const opts = child.avatar_options || []; const canChange = this.config.allow_avatar_change !== false && opts.filter(o => o.unlocked).length > 1; return html`
this._toggleAvatarPicker() : null} title="${canChange ? this._t("child.avatar_change") : ""}"> ${this._av(child, tone, 34)} ${canChange ? html`` : ""}
${title}${sub} ${remaining === 0 && total > 0 ? html`🎉` : ""} ${pendingPoints > 0 ? html` +${pendingPoints}` : ""} ${canChange && this._avatarPickerOpen ? this._renderAvatarPicker(child, opts) : ""}
`; } /** 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`
${r.mandatory ? html`⚠ ${this._t("child.mandatory")}` : ""} ${r.photo ? html`📷 ${this._t("child.photo_needed")}` : ""}
` : ""} ${showDesc ? html`
${r.chore.description}
` : ""}`; } _designHeader(child, tt, sub, tone, pillText) { return html`
${this._av(child, tone, 34)} ${tt}${sub ? html`${sub}` : ""} ${pillText ? html`${pillText}` : ""}
`; } _designDoneBtn(r, label, cls) { return html``; } // 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``; } /** 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`
${this._t("child.all_done")}
`; return html`
${rows.map(r => r.timed ? this._designTimed(r) : html`
${r.done ? "✓" : r.index + 1}
${r.emoji}
${r.chore.name}
${r.done ? "" : html`
+${r.points} ⭐
`} ${this._designChoreMeta(r)}
${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")}
${this._designBonus(r)}`)}
`; } _designConsole(child, rows, remaining, tone) { if (rows.length === 0) return html`
${this._t("child.all_done")}
`; return html`
${rows.map(r => r.timed ? this._designTimed(r) : html`
${r.done ? "✓" : String(r.index + 1).padStart(2, "0")}
${r.emoji}
${r.chore.name}
${r.done ? html`
CLEARED
` : html`
+${r.points} XP
`} ${this._designChoreMeta(r)}
${r.done ? this._designUndoChip(r, html`↩`, "q-undo") : this._designDoneBtn(r, r.photo ? `📷 ${this._t("child.done") || "CLAIM"}` : (this._t("child.done") || "CLAIM"))}
${this._designBonus(r)}`)}
`; } _designCleanpro(child, rows, remaining, tone) { if (rows.length === 0) return html`
${this._t("child.all_done")}
`; return html`
${rows.map(r => r.timed ? this._designTimed(r) : html`
${r.done ? "✓" : r.index + 1}
${r.emoji}
${r.chore.name}
${this._designChoreMeta(r)}
${r.done ? this._designUndoChip(r, this._t("child.done") || "Completed", "c-done") : html`+${r.points} ${this._designDoneBtn(r, r.photo ? `📷 ${this._t("child.done") || "Done"}` : (this._t("child.done") || "Done"), "sm")}`}
${this._designBonus(r)}`)}
`; } _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`
${this._t('child.swap_section_title') || 'Take over a chore'}
${swappable.map(c => { const key = `swap_${c.id}`; const loading = this._loading[key]; const requested = this._swapRequested.has(c.id); return html`
${c.name}
+${c.effective_points ?? c.points}
`; })}
`; } 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`
${this._t('child.all_done')}
${this._t('child.no_chores_right_now')}
`; } /** * Countdown badge for a reactive chore (#674) — "empty the washing machine * within 30 minutes". Reads the deadline fresh on every render, so it ticks * down with the coordinator's updates. Hidden once the chore is done. */ _renderDeadlineBadge(chore, isCompletedForToday) { if (!chore.deadline_at || isCompletedForToday) return ''; const deadline = new Date(chore.deadline_at); if (Number.isNaN(deadline.getTime())) return ''; const msLeft = deadline.getTime() - Date.now(); if (msLeft <= 0) return ''; const minsLeft = Math.ceil(msLeft / 60000); const label = minsLeft >= 60 ? this._t('child.deadline_hours', { hours: Math.floor(minsLeft / 60), mins: minsLeft % 60 }) : this._t('child.deadline_minutes', { mins: minsLeft }); // Under five minutes is the point at which it's worth shouting about. const urgent = minsLeft <= 5; const bonus = chore.speed_bonus_points || 0; return html` ⏱ ${label}${bonus ? html` +${bonus}` : ''} `; } /** * Chore roulette (#677) — an opt-in nudge for the child who has stalled. * Only renders when the parent enabled it in settings AND the card opts in, * so an existing dashboard never sprouts a new button unasked. */ _renderRoulette(child, childChores, pointsIcon) { if (this.config.show_roulette !== true) return ''; const roulette = child.roulette; if (!roulette) return ''; // switched off in settings const picked = roulette.chore_id ? childChores.find(c => String(c.id) === String(roulette.chore_id)) : null; const spinsLeft = roulette.spins_left ?? 0; const multiplier = roulette.multiplier || 2; const spinning = this._spinning === child.id; return html`
${picked ? html`
×${multiplier} ${picked.name} ${Math.round((picked.effective_points ?? picked.points ?? 0) * multiplier)}
` : ''} ${spinsLeft > 0 ? html` ` : html`
${this._t('child.roulette_no_spins')}
`}
`; } async _spinRoulette(child) { if (this._spinning) return; this._spinning = child.id; this.requestUpdate(); try { await this.hass.callService('taskmate', 'spin_roulette', { child_id: child.id }); } catch (err) { // "No spins left" / "Nothing left to spin for" arrive here as // ServiceValidationError — show them rather than failing silently. this.dispatchEvent(new CustomEvent('hass-notification', { detail: { message: String(err?.message || err) }, bubbles: true, composed: true, })); } finally { this._spinning = null; this.requestUpdate(); } } /** * Pre-reader mode (#683): a picture-only tile for children who can't read * yet. No chore name, no numbers — a big icon, a row of stars for the * points, and a huge tick when it's done. */ _renderPreReaderTile(chore, child, todaysCompletions = [], choreIndex = 0) { const dailyLimit = chore.daily_limit || 1; // Counted the same way the standard row does: pending completions hold a // place too, and a parent completing on behalf lands under "__parent__". const childCompletionsToday = todaysCompletions.filter( (comp) => comp.chore_id === chore.id && (comp.child_id === child.id || comp.child_id === "__parent__") && !comp.bonus_subtask_id ); const isDone = childCompletionsToday.length >= dailyLimit; const isLoading = this._loading[chore.id]; const available = chore._isAvailableForChild !== false && !chore._isLockedPreview; // Stars, not digits: a 4-year-old can count pictures, not read "+3". const points = chore.effective_points ?? chore.points ?? 0; const stars = Math.max(1, Math.min(5, Math.round(points / 2) || 1)); const icon = chore.icon || this._getTimeCategoryIcon(chore.time_category) || 'mdi:checkbox-marked-circle-outline'; return html` `; } _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`
${choreNumber}
${chore.name}${chore.mandatory ? html`⚠ ${this._t('child.mandatory')}` : ''}
${this._renderDeadlineBadge(chore, isCompletedForToday)} ${chore.difficulty ? html`${this._t('child.difficulty_' + chore.difficulty) || chore.difficulty}` : ''} ${this.config.show_description && chore.description ? html`
${chore.description}
` : ''} ${chore._isRecurring && !chore._isAvailableForChild ? html`
${chore.recurrence ? chore.recurrence.replace(/_/g, ' ') : this._t('child.recurring')}
` : ''}
+${chore.effective_points ?? chore.points} ${dailyLimit > 1 ? html`(${completionsToday}/${dailyLimit})` : ''}
${chore.require_photo && !isCompletedForToday ? html`
📷 ${this._t('child.photo_needed')}
` : ''}
${isLoading ? html`` : isLockedPreview ? html`` : html``}
`; } _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`
${choreNumber}
${chore.name}
${rateLabel}
${state !== 'idle' ? html`
${state === 'running' ? html`
` : ''} ${state === 'paused' ? html`
${this._t('child.paused') || 'PAUSED'}
` : ''}
${timeStr}
${earnedPoints}
` : ''}
${state === 'idle' ? html` ` : ''} ${state === 'running' ? html` ` : ''} ${state === 'paused' ? html` ` : ''}
${maxMin > 0 ? html`
${this._t('child.timed_cap_label', {used: usedMin, max: maxMin})}
` : ''}
`; } _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`
${subtask.name}
+${subtask.points} ${this._t('child.bonus_label')}
${isLoading ? html`` : html``}
`; })}`; } 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`
🌟🌟🌟
${this._t('child.celebration_title')}
${this._t('child.celebration_message')}
+${points}
`; } _renderConfetti() { const colors = ["#ff6b9d", "#9b59b6", "#3498db", "#2ecc71", "#f1c40f", "#e67e22"]; return html`
${this._confetti.map((piece, index) => html`
`)}
`; } 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__complete_ // 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`
📷 ${this._t("child.photo_required_title")}
${chore.name || ""}
${cap.step === "pick" ? html`
📸
${this._t("child.photo_take")}
${this._t("child.photo_hint")}
${cap.error ? html`
${cap.error}
` : ""} ` : ""} ${cap.step === "preview" ? html`
~${cap.sizeKb} KB
${cap.error ? html`
${cap.error}
` : ""} ` : ""} ${cap.step === "uploading" ? html`
${this._t("child.photo_uploading")}
` : ""}
`; } /** 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: 'pre_reader', selector: { boolean: {} } }, { name: 'pre_reader_labels', 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'), pre_reader: this._t('child.editor.pre_reader'), pre_reader_labels: this._t('child.editor.pre_reader_labels'), debug: this._t('child.editor.show_debug'), }; return labels[entry.name] ?? entry.name; }; _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, pre_reader: this.config.pre_reader === true, pre_reader_labels: this.config.pre_reader_labels === true, debug: this.config.debug === true, }; return html` ${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;" );