/** * TaskMate Parent Dashboard Card * Unified parent view: all children's today progress, pending approvals * with inline approve/reject, pending reward claims, and quick point adjustments. * * Version: 1.0.0 */ 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; // Security defense-in-depth: only ever emit our own auth-gated photo endpoint // into an href/src. The backend already rejects foreign/crafted photo_url // values, but guard here too so a stored bad value can never become a // javascript:/external link in a parent's browser. const tmSafePhotoUrl = (u) => typeof u === "string" && u.startsWith("/api/taskmate/photo/") ? u : ""; const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); class TaskMateParentDashboardCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _loading: { type: Object }, _activeSection: { type: String }, _expanded: { 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; } _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } // Build "Chore · Child · date" for the lightbox caption (missing parts drop). _photoCaption(chore, child, iso) { let when = ""; if (iso) { const d = new Date(iso); if (!isNaN(d)) when = d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } return [chore, child, when].filter(Boolean).join(" · "); } // Open the shared in-page lightbox; fall through to the href if it's absent. _openPhoto(e, url, caption) { if (window.__taskmate_lightbox) { e.preventDefault(); window.__taskmate_lightbox(url, caption, this._t("common.close")); } } constructor() { super(); this._loading = {}; this._activeSection = "overview"; this._expanded = {}; } static get styles() { const base = css` :host { display: block; } ha-card { overflow: hidden; } /* ── Header ── */ .card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; background: var(--taskmate-header-bg, #c0392b); color: white; gap: 12px; } .header-content { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; } .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; } .header-title { font-size: 1.1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pending-badge { background: #e74c3c; color: white; border-radius: 12px; padding: 3px 10px; font-size: 0.82rem; font-weight: 700; display: flex; align-items: center; gap: 4px; flex-shrink: 0; animation: badge-pulse 2s ease-in-out infinite; } .pending-badge ha-icon { --mdc-icon-size: 14px; } @keyframes badge-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.4); } 50% { box-shadow: 0 0 0 5px rgba(231,76,60,0); } } /* ── Tab nav ── */ .tab-nav { display: flex; border-bottom: 1px solid var(--divider-color, #e0e0e0); background: var(--secondary-background-color, #f5f5f5); /* Flex items refuse to shrink below their content, so on a narrow card the last tab used to be clipped off the edge with no way to reach it. Wrapping keeps every tab visible without asking the parent to discover a horizontal swipe on a card that doesn't look scrollable. */ flex-wrap: wrap; } .tab-btn { /* Grow to fill a wide card, but keep the label intact when narrow. */ flex: 1 0 auto; min-width: 0; white-space: nowrap; padding: 10px 8px; background: none; border: none; font-size: 0.78rem; font-weight: 600; color: var(--secondary-text-color); cursor: pointer; border-bottom: 2px solid transparent; transition: color 0.15s, border-color 0.15s; display: flex; align-items: center; justify-content: center; gap: 5px; position: relative; } .tab-btn ha-icon { --mdc-icon-size: 16px; } .tab-btn.active { color: var(--primary-color, #3498db); border-bottom-color: var(--primary-color, #3498db); } .tab-badge { background: #e74c3c; color: white; border-radius: 8px; padding: 1px 5px; font-size: 0.65rem; font-weight: 700; line-height: 1.4; } /* ── Content ── */ .tab-content { padding: 14px; display: flex; flex-direction: column; gap: 10px; } /* ── Child overview tiles ── */ .child-tile { display: flex; align-items: center; gap: 12px; padding: 12px 14px; background: var(--card-background-color, #fff); border: 1px solid var(--divider-color, #e0e0e0); border-radius: 12px; } .child-avatar { width: 42px; height: 42px; min-width: 42px; border-radius: 50%; background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .child-avatar ha-icon { --mdc-icon-size: 26px; color: white; } .child-tile-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } .child-tile-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .child-tile-name { font-size: 0.95rem; font-weight: 600; color: var(--primary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .points-pill { display: flex; align-items: center; gap: 3px; background: rgba(241,196,15,0.15); color: #e67e22; border-radius: 10px; padding: 2px 8px; font-size: 0.8rem; font-weight: 700; flex-shrink: 0; } .points-pill ha-icon { --mdc-icon-size: 13px; color: #f1c40f; } .progress-row { display: flex; align-items: center; gap: 8px; } .progress-bar { flex: 1; height: 7px; background: var(--divider-color, #e0e0e0); border-radius: 4px; overflow: hidden; } .progress-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; } .progress-fill.complete { background: linear-gradient(90deg, #27ae60, #2ecc71); } .progress-fill.partial { background: linear-gradient(90deg, #3498db, #2980b9); } .progress-fill.none { width: 0 !important; } .progress-label { font-size: 0.75rem; font-weight: 600; color: var(--secondary-text-color); white-space: nowrap; min-width: 32px; text-align: right; } /* ── Approval items ── */ .approval-item { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--card-background-color, #fff); border: 1px solid var(--divider-color, #e0e0e0); border-radius: 12px; transition: opacity 0.2s; } .approval-item.loading { opacity: 0.5; pointer-events: none; } .approval-child-avatar { width: 38px; height: 38px; min-width: 38px; border-radius: 50%; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); display: flex; align-items: center; justify-content: center; } .approval-child-avatar ha-icon { --mdc-icon-size: 22px; color: white; } .approval-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .approval-photo { display: inline-block; margin-top: 4px; line-height: 0; } .approval-photo img { width: 48px; height: 48px; object-fit: cover; border-radius: 8px; border: 1px solid var(--divider-color, #e0e0e0); } .approval-chore { font-size: 0.9rem; font-weight: 600; color: var(--primary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .approval-meta { font-size: 0.75rem; color: var(--secondary-text-color); display: flex; align-items: center; gap: 6px; } .approval-points { display: flex; align-items: center; gap: 2px; font-weight: 600; color: #e67e22; } .approval-points ha-icon { --mdc-icon-size: 12px; color: #f1c40f; } .approval-actions { display: flex; gap: 6px; flex-shrink: 0; } .btn-approve, .btn-reject { width: 34px; height: 34px; border-radius: 50%; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.1s, box-shadow 0.1s; flex-shrink: 0; } .btn-approve { background: linear-gradient(135deg, #27ae60, #2ecc71); color: white; box-shadow: 0 2px 8px rgba(46,204,113,0.3); } .btn-reject { background: linear-gradient(135deg, #c0392b, #e74c3c); color: white; box-shadow: 0 2px 8px rgba(231,76,60,0.3); } .btn-approve:hover { transform: scale(1.1); } .btn-reject:hover { transform: scale(1.1); } .btn-approve ha-icon, .btn-reject ha-icon { --mdc-icon-size: 18px; } /* ── Reward claim items ── */ .claim-item { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--card-background-color, #fff); border: 1px solid rgba(155,89,182,0.3); border-radius: 12px; background: rgba(155,89,182,0.04); transition: opacity 0.2s; } .claim-item.loading { opacity: 0.5; pointer-events: none; } .claim-icon-wrap { width: 38px; height: 38px; min-width: 38px; border-radius: 50%; background: linear-gradient(135deg, #9b59b6, #8e44ad); display: flex; align-items: center; justify-content: center; } .claim-icon-wrap ha-icon { --mdc-icon-size: 22px; color: white; } .claim-info { flex: 1; min-width: 0; } .claim-reward-name { font-size: 0.9rem; font-weight: 600; color: var(--primary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .claim-meta { font-size: 0.75rem; color: var(--secondary-text-color); margin-top: 2px; } /* ── Quick points ── */ .quick-points-row { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--card-background-color, #fff); border: 1px solid var(--divider-color, #e0e0e0); border-radius: 12px; } .qp-avatar { width: 38px; height: 38px; min-width: 38px; border-radius: 50%; background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); display: flex; align-items: center; justify-content: center; } .qp-avatar ha-icon { --mdc-icon-size: 22px; color: white; } .qp-name { flex: 1; font-size: 0.9rem; font-weight: 600; color: var(--primary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .qp-points { font-size: 1rem; font-weight: 700; color: #9b59b6; white-space: nowrap; display: flex; align-items: center; gap: 3px; } .qp-points ha-icon { --mdc-icon-size: 14px; color: #f1c40f; } .qp-actions { display: flex; gap: 6px; } .btn-add, .btn-remove { width: 32px; height: 32px; border-radius: 50%; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.1s; flex-shrink: 0; } .btn-add { background: linear-gradient(135deg, #27ae60, #2ecc71); color: white; box-shadow: 0 2px 6px rgba(46,204,113,0.3); } .btn-remove { background: linear-gradient(135deg, #c0392b, #e74c3c); color: white; box-shadow: 0 2px 6px rgba(231,76,60,0.3); } .btn-add:hover, .btn-remove:hover { transform: scale(1.1); } .btn-add ha-icon, .btn-remove ha-icon { --mdc-icon-size: 16px; } /* ── Complete on behalf (admin) ── */ .tm-outstanding { margin-top: 8px; border-top: 1px dashed var(--divider-color, #e0e0e0); padding-top: 8px; } .tm-outstanding-hdr { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.6; margin-bottom: 6px; } .tm-outstanding-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; } .tm-outstanding-row + .tm-outstanding-row { border-top: 1px solid var(--divider-color, #eee); } .tm-outstanding-name { flex: 1; font-size: 0.9rem; } .tm-outstanding-pts { opacity: 0.6; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 2px; } .btn-complete-behalf { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; border: none; cursor: pointer; background: #2e9e5b; color: #fff; font: 600 0.75rem/1 inherit; padding: 6px 10px; border-radius: 8px; } .btn-complete-behalf:hover { background: #27894e; } .btn-complete-behalf:active { transform: scale(0.96); } .btn-complete-behalf[disabled] { opacity: 0.5; pointer-events: none; } .child-tile.tm-expandable .child-tile-main { cursor: pointer; } .tm-all-done { font-size: 0.85rem; opacity: 0.6; font-style: italic; padding: 4px 0; } /* ── Empty state ── */ .empty-section { display: flex; flex-direction: column; align-items: center; padding: 24px 16px; text-align: center; gap: 8px; color: var(--secondary-text-color); } .empty-section ha-icon { --mdc-icon-size: 40px; opacity: 0.35; } .empty-section span { font-size: 0.9rem; } .timed-sessions-section { padding: 8px 0; margin-bottom: 8px; border-bottom: 1px solid var(--divider-color, #e0e0e0); } .timed-session-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 0.9rem; } .timed-session-row ha-icon { --mdc-icon-size: 18px; } .paused-tag { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; color: #e67e22; background: rgba(230, 126, 34, 0.12); padding: 2px 6px; border-radius: 4px; } .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: 12px; opacity: 0.5; } @media (max-width: 480px) { .card-header { padding: 12px 14px; } .tab-btn { font-size: 0.72rem; padding: 8px 6px; } .tab-content { padding: 10px; gap: 8px; } .approval-item, .claim-item, .quick-points-row, .child-tile { padding: 10px 12px; } } /* ── Designed styles (playroom / console / cleanpro) ── Shared .tmd kit + tokens come from taskmate-design.js styles(). Only card-specific layout classes are declared here. */ /* Tab bar */ .pd-tabs { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 13px; } .pd-tab { font: inherit; font-family: var(--tmd-font-display); font-weight: 800; font-size: 13px; border: 0; cursor: pointer; padding: 8px 13px; border-radius: 999px; background: var(--tmd-surface-2); color: var(--tmd-text); border: 1px solid var(--tmd-border); display: inline-flex; align-items: center; gap: 6px; } .pd-tab.active { background: var(--tmd-accent); color: #fff; border-color: transparent; } :host([data-tm-design="console"]) .pd-tab.active { color: #06101c; background: linear-gradient(135deg, var(--tmd-accent), color-mix(in srgb,var(--tmd-accent) 60%,var(--tmd-accent2))); box-shadow: 0 0 14px color-mix(in srgb,var(--tmd-accent) 45%,transparent); } .pd-tab .tab-cnt { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--tmd-bad); color: #fff; font-size: 11px; font-weight: 800; display: grid; place-items: center; } .pd-tab.active .tab-cnt { background: rgba(255,255,255,.3); } /* Console tab bar segmented */ .pd-tabs-cn { display: flex; gap: 0; background: #0b1424; border: 1px solid var(--tmd-border); border-radius: 8px; padding: 3px; margin-bottom: 13px; } .pd-tab-cn { flex: 1; text-align: center; padding: 7px 4px; border-radius: 6px; border: 0; cursor: pointer; background: transparent; color: var(--tmd-dim); font-family: var(--tmd-font-mono); font-size: 11px; font-weight: 700; letter-spacing: .04em; } .pd-tab-cn.active { background: var(--tmd-surface-2); color: var(--tmd-accent); font-weight: 800; } /* Clean Pro tab bar underline */ .pd-tabs-cp { display: flex; gap: 0; border-bottom: 1px solid var(--tmd-border); margin: -2px 0 14px; } .pd-tab-cp { padding: 8px 12px; border: 0; background: transparent; cursor: pointer; font-family: var(--tmd-font-body); font-weight: 700; font-size: 13.5px; color: var(--tmd-dim); border-bottom: 2px solid transparent; margin-bottom: -1px; display: inline-flex; align-items: center; gap: 6px; } .pd-tab-cp.active { color: var(--tmd-accent); border-bottom-color: var(--tmd-accent); } .pd-tab-cp .tab-cnt { padding: 1px 7px; border-radius: 999px; background: var(--tmd-bad); color: #fff; font-size: 11px; font-weight: 800; } /* Overview rows */ .pd-ov-pl { background: var(--tmd-surface-2); border-radius: 18px; padding: 12px; } .pd-ov-pl + .pd-ov-pl { margin-top: 10px; } .pd-ov-cn { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: 10px; padding: 11px; } .pd-ov-cn + .pd-ov-cn { margin-top: 9px; } .pd-ov-cp { padding: 9px 0; } .pd-name { font-weight: 800; } .pd-name-cp { font-weight: 600; } .pd-sub { font-size: 12.5px; } .pd-pts { font-size: 20px; color: var(--tmd-accent); } .pd-pts-cn { font-family: var(--tmd-font-mono); font-weight: 800; font-size: 18px; color: var(--tmd-accent); } .pd-mini { font-family: var(--tmd-font-mono); font-size: 10px; } /* Generic designed item rows (approvals / claims / points) */ .pd-item { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: var(--tmd-radius-sm); padding: 11px; } .pd-item + .pd-item { margin-top: 9px; } .pd-item-title { font-weight: 700; } .pd-item-sub { font-size: 12px; } .pd-acts { display: flex; gap: 6px; flex: none; } .pd-photo { width: 36px; height: 36px; border-radius: 8px; overflow: hidden; background: var(--tmd-surface); border: 1px solid var(--tmd-border); display: grid; place-items: center; font-size: 15px; flex: none; } .pd-photo img { width: 100%; height: 100%; object-fit: cover; } .pd-photo-link { line-height: 0; text-decoration: none; } .pd-gold-soft { color: var(--tmd-gold); } `; 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"); this.config = { title: "Parent Dashboard", quick_points_amount: 5, show_claims: true, header_color: '#c0392b', ...config, }; } getCardSize() { return 6; } static getConfigElement() { return document.createElement("taskmate-parent-dashboard-card-editor"); } static getStubConfig() { return { entity: "sensor.taskmate_overview", title: "Parent Dashboard" }; } 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 })}
`; if (entity.state === "unavailable" || entity.state === "unknown") return html`
${this._t('common.unavailable')}
`; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; const children = attrs.children || []; const chores = attrs.chores || []; const completions = attrs.todays_completions || []; // Pending approvals come from the full pending list (not filtered to // today) so a completion left pending from a previous day still shows an // approve button after a recurring chore resets. `completions` stays // today-only for the overview tab's activity display. const pendingCompletions = attrs.chore_completions || completions.filter(c => !c.approved); const pendingRewardClaims = attrs.pending_reward_claims || []; const pointsIcon = attrs.points_icon || "mdi:star"; const pointsName = attrs.points_name || this._t('common.points'); const totalPending = pendingCompletions.length + pendingRewardClaims.length; const rotationChores = chores.filter(c => ['alternating', 'random', 'balanced'].includes(c.assignment_mode || 'everyone')); const tabs = [ { id: "overview", label: this._t('dashboard.tab_overview'), icon: "mdi:view-dashboard" }, { id: "approvals", label: this._t('dashboard.tab_approvals'), icon: "mdi:check-circle", count: pendingCompletions.length }, { id: "points", label: this._t('dashboard.tab_points'), icon: "mdi:star-plus" }, ]; if (rotationChores.length > 0) { tabs.splice(1, 0, { id: "rotation", label: this._t('dashboard.tab_rotation', {}, 'Rotation'), icon: "mdi:rotate-3d-variant" }); } if (this.config.show_claims) { const insertAt = tabs.findIndex(t => t.id === "points"); tabs.splice(insertAt, 0, { id: "claims", label: this._t('dashboard.tab_claims'), icon: "mdi:gift", count: pendingRewardClaims.length }); } return html`
${this.config.title || this._t('dashboard.default_title')}
${totalPending > 0 ? html`
${totalPending}
` : ''}
${tabs.map(tab => html` `)}
${this._activeSection === "overview" ? this._renderOverview(children, chores, completions, pointsIcon, pointsName) : ''} ${this._activeSection === "rotation" ? this._renderRotation(rotationChores, children, attrs) : ''} ${this._activeSection === "approvals" ? this._renderApprovals(pendingCompletions, children, chores, pointsIcon) : ''} ${this._activeSection === "claims" ? this._renderClaims(pendingRewardClaims, pointsIcon) : ''} ${this._activeSection === "points" ? this._renderPoints(children, pointsIcon, pointsName) : ''}
`; } /* ══════════════════════════════════════════════════════════════════════ DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js Ported from docs/design/redesigns/frag/12-parent-dashboard.html. Preserves the tab state machine (_activeSection) and reuses every existing handler. ══════════════════════════════════════════════════════════════════════ */ _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(name, avatar, tone, size) { const a = avatar || ""; const inner = a.startsWith("mdi:") ? html`` : a ? html`${name || ''}` : (name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } // Per-child today progress (approved / total due today), mirroring the // classic _renderOverview filter logic, for the designed overview tab. _designChildProgress(child, chores, completions, attrs) { const todayDow = attrs.today_day_of_week || new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase(); const availability = attrs.chore_availability || {}; const childChores = chores.filter(c => { if (c.enabled === false) return false; if ((c.disabled_for || []).includes(child.id)) return false; const at = c.assigned_to || []; const assigned = at.length === 0 || at.includes(child.id); if (!assigned) return false; const perChild = availability[c.id]; if (c.schedule_mode === 'one_shot') { if (perChild && perChild[child.id] === false) return false; } if (c.schedule_mode === 'specific_days') { const dueDays = Array.isArray(c.due_days) ? c.due_days : []; if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false; } if (c.schedule_mode === 'recurring') { if (perChild && perChild[child.id] === false) return false; } return true; }); const childChoreIds = new Set(childChores.map(c => c.id)); const approved = completions.filter(c => c.child_id === child.id && c.approved && childChoreIds.has(c.chore_id)).length; const total = childChores.length; const pct = total > 0 ? Math.min(100, Math.round((approved / total) * 100)) : 0; return { approved, total, pct }; } _renderDesigned(design) { const hd = _safeColor(this.config.header_color, '#c0392b'); const entity = this.hass.states[this.config.entity]; if (!entity) { return html` ${this._dHeader(hd, 0)}
${this._t('common.entity_not_found', { entity: this.config.entity })}
`; } if (entity.state === "unavailable" || entity.state === "unknown") { return html` ${this._dHeader(hd, 0)}
${this._t('common.unavailable')}
`; } const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; const children = attrs.children || []; const chores = attrs.chores || []; const completions = attrs.todays_completions || []; const pendingCompletions = attrs.chore_completions || completions.filter(c => !c.approved); const pendingRewardClaims = attrs.pending_reward_claims || []; const pointsIcon = attrs.points_icon || "mdi:star"; const pointsName = attrs.points_name || this._t('common.points'); const totalPending = pendingCompletions.length + pendingRewardClaims.length; const rotationChores = chores.filter(c => ['alternating', 'random', 'balanced'].includes(c.assignment_mode || 'everyone')); const tabs = [ { id: "overview", label: this._t('dashboard.tab_overview') }, { id: "approvals", label: this._t('dashboard.tab_approvals'), count: pendingCompletions.length }, { id: "points", label: this._t('dashboard.tab_points') }, ]; if (rotationChores.length > 0) { tabs.splice(1, 0, { id: "rotation", label: this._t('dashboard.tab_rotation', {}, 'Rotation') }); } if (this.config.show_claims) { const insertAt = tabs.findIndex(t => t.id === "points"); tabs.splice(insertAt, 0, { id: "claims", label: this._t('dashboard.tab_claims'), count: pendingRewardClaims.length }); } // Guard: if the active section is no longer present (e.g. rotation/claims // toggled off), fall back to overview. if (!tabs.some(t => t.id === this._activeSection)) this._activeSection = "overview"; const tabBar = design === "console" ? this._dTabsConsole(tabs) : design === "cleanpro" ? this._dTabsCleanpro(tabs) : this._dTabsPlayroom(tabs); let body; const sec = this._activeSection; if (sec === "overview") body = this._dOverview(design, children, chores, completions, attrs, pointsIcon); else if (sec === "rotation") body = this._dRotation(design, rotationChores, children, attrs); else if (sec === "approvals") body = this._dApprovals(design, pendingCompletions, children, chores, pointsIcon); else if (sec === "claims") body = this._dClaims(design, pendingRewardClaims, pointsIcon); else if (sec === "points") body = this._dPoints(design, children, pointsIcon, pointsName); return html` ${this._dHeader(hd, totalPending)}
${tabBar} ${body}
`; } _dHeader(hd, count) { const title = this.config.title || this._t('dashboard.default_title'); return html`
🏠 ${title} ${count > 0 ? html`${count}` : ""}
`; } _switchTab(id) { this._activeSection = id; this.requestUpdate(); } _dTabsPlayroom(tabs) { return html`
${tabs.map(t => html` `)}
`; } _dTabsConsole(tabs) { return html`
${tabs.map(t => html` `)}
`; } _dTabsCleanpro(tabs) { return html`
${tabs.map(t => html` `)}
`; } _dEmpty(msg) { return html`
${msg}
`; } _dOverview(design, children, chores, completions, attrs, pointsIcon) { if (!children.length) return this._dEmpty(this._t('dashboard.empty_no_children')); return html`${children.map((child, i) => { const tone = this._designTone(i); const { approved, total, pct } = this._designChildProgress(child, chores, completions, attrs); if (design === "console") { return html`
${this._av(child.name, child.avatar, tone, 34)}
${child.name} ${approved}/${total}
${child.points}
`; } if (design === "cleanpro") { const isParent = window.__taskmate_is_parent(this.hass); return html`
${this._av(child.name, child.avatar, tone, 36)}
${child.name} ${approved}/${total} · ${child.points} ${this._t('common.points')}
${isParent ? html`` : ""}
${i < children.length - 1 ? html`
` : ""}`; } // playroom return html`
${this._av(child.name, child.avatar, tone, 42)}
${child.name}
${this._t('dashboard.chores_done', { done: approved, total }, `${approved} / ${total} chores done`)}
${child.points}⭐
`; })}`; } _dApprovals(design, pending, children, chores, pointsIcon) { if (!pending.length) return this._dEmpty(this._t('dashboard.empty_approvals')); const childMap = {}; children.forEach(c => { childMap[c.id] = c; }); const choreMap = {}; chores.forEach(c => { choreMap[c.id] = c; }); return html`${pending.map((comp, i) => { const tone = this._designTone(i); const child = childMap[comp.child_id]; const isLoading = !!this._loading[comp.completion_id]; const time = comp.completed_at ? new Date(comp.completed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""; const pts = comp.points || choreMap[comp.chore_id]?.points || 0; const photoUrl = tmSafePhotoUrl(comp.photo_url); const photoCap = this._photoCaption(comp.chore_name, child && child.name, comp.completed_at); const photo = photoUrl ? html`
` : ""; const acts = html` `; return html`
${this._av(child?.name || '?', child?.avatar, tone, 36)}
${comp.chore_name || choreMap[comp.chore_id]?.name || this._t('common.unknown')}
${child?.name || this._t('common.unknown')}${time ? ` · ${time}` : ""} · +${pts}
${photo}
${acts}
`; })}`; } _dClaims(design, claims, pointsIcon) { if (!claims.length) return this._dEmpty(this._t('dashboard.empty_claims')); return html`${claims.map((claim, i) => { const tone = this._designTone(i); const isLoading = !!this._loading[claim.claim_id]; const time = claim.claimed_at ? new Date(claim.claimed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""; return html`
${claim.reward_name}
${claim.child_name}${time ? ` · ${time}` : ""} · ${claim.cost}
`; })}`; } _dRotation(design, rotationChores, children, attrs) { if (!rotationChores.length) { return this._dEmpty(this._t('dashboard.empty_rotation', {}, 'No rotating chores configured')); } const childById = Object.fromEntries((children || []).map(c => [c.id, c])); const groups = attrs.task_groups || []; const groupByChoreId = {}; for (const g of groups) for (const cid of (g.chore_ids || [])) groupByChoreId[cid] = g; const unknown = this._t('dashboard.rotation_unassigned', {}, 'Nobody'); return html`${rotationChores.map((chore, i) => { const tone = this._designTone(i); const currentChild = childById[chore.assignment_current_child_id || '']; const pool = (chore.assigned_to && chore.assigned_to.length) ? chore.assigned_to : (children || []).map(c => c.id); const group = groupByChoreId[chore.id]; const isSticky = group && group.policy === 'sticky'; const isStickyFollower = isSticky && group.chore_ids[0] !== chore.id; const skipDisabled = pool.length <= 1 || isStickyFollower; const key = `skip_${chore.id}`; const loading = !!this._loading[key]; return html`
${this._av(currentChild?.name || '?', currentChild?.avatar || 'mdi:rotate-3d-variant', tone, 36)}
${chore.name}
${currentChild?.name || unknown} · ${chore.assignment_mode}${group ? ` · ${group.policy === 'sticky' ? '🔗' : '🔀'} ${group.name}` : ""}
`; })}`; } _dPoints(design, children, pointsIcon, pointsName) { if (!children.length) return this._dEmpty(this._t('dashboard.empty_no_children')); const amount = this.config.quick_points_amount || 5; return html`${children.map((child, i) => { const tone = this._designTone(i); return html`
${this._av(child.name, child.avatar, tone, 36)}
${child.name}
${child.points} ${pointsName}
`; })}`; } _renderOverview(children, chores, completions, pointsIcon, pointsName) { if (!children.length) return html`
${this._t('dashboard.empty_no_children')}
`; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || {}; const todayDow = attrs.today_day_of_week || new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase(); const availability = attrs.chore_availability || {}; const timedSessions = attrs.active_timed_sessions || []; const childById = Object.fromEntries(children.map(c => [c.id, c])); const choreById = Object.fromEntries(chores.map(c => [c.id, c])); return html` ${timedSessions.length > 0 ? html`
${timedSessions.map(s => { const sChild = childById[s.child_id]; const sChore = choreById[s.chore_id]; if (!sChild || !sChore) return ''; let elapsed = s.total_seconds_today || 0; if (s.state === 'running' && s.current_segment_start) { elapsed += Math.max(0, Math.floor((Date.now() - new Date(s.current_segment_start).getTime()) / 1000)); } const mins = Math.floor(elapsed / 60); const secs = elapsed % 60; return html`
${sChild.name} — ${sChore.name} (${String(mins).padStart(2,'0')}:${String(secs).padStart(2,'0')}) ${s.state === 'paused' ? html`${this._t('dashboard.paused_tag')}` : ''}
`; })}
` : ''} ${children.map(child => { const childChores = chores.filter(c => { // Skip disabled chores (one-shot completed or expired) if (c.enabled === false) return false; if ((c.disabled_for || []).includes(child.id)) return false; const at = c.assigned_to || []; const assigned = at.length === 0 || at.includes(child.id); if (!assigned) return false; const perChild = availability[c.id]; // One-shot: use is_available check (same as recurring) if (c.schedule_mode === 'one_shot') { if (perChild && perChild[child.id] === false) return false; } // Mode A: due days check if (c.schedule_mode === 'specific_days') { const dueDays = Array.isArray(c.due_days) ? c.due_days : []; if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false; } // Mode B: recurrence availability check if (c.schedule_mode === 'recurring') { if (perChild && perChild[child.id] === false) return false; } return true; }); const childChoreIds = new Set(childChores.map(c => c.id)); const approved = completions.filter(c => c.child_id === child.id && c.approved && childChoreIds.has(c.chore_id)).length; const total = childChores.length; const pct = total > 0 ? Math.min(100, (approved / total) * 100) : 0; const isComplete = total > 0 && approved >= total; const cls = isComplete ? "complete" : pct > 0 ? "partial" : "none"; const isParent = window.__taskmate_is_parent(this.hass); const outstanding = childChores.filter(c => { const doneToday = completions.filter( x => x.child_id === child.id && x.chore_id === c.id && !x.bonus_subtask_id ).length; return doneToday < (c.daily_limit || 1); }); const isOpen = !!this._expanded[child.id]; return html`
${child.name} ${child.points}
${approved}/${total}
${isParent && isOpen ? html`
${this._t('common.complete_on_behalf_heading')}
${outstanding.length === 0 ? html`
${this._t('common.complete_on_behalf_all_done')}
` : outstanding.map(c => { const key = `behalf_${child.id}_${c.id}`; const loading = !!this._loading[key]; return html`
${c.name} ${c.points}
`; })}
` : ''}
`; })} `; } _renderRotation(rotationChores, children, attrs) { if (!rotationChores.length) { return html`
${this._t('dashboard.empty_rotation', {}, 'No rotating chores configured')}
`; } const childById = Object.fromEntries((children || []).map(c => [c.id, c])); const groups = attrs.task_groups || []; const groupByChoreId = {}; for (const g of groups) { for (const cid of (g.chore_ids || [])) { groupByChoreId[cid] = g; } } const unknown = this._t('dashboard.rotation_unassigned', {}, 'Nobody'); return html` ${rotationChores.map(chore => { const currentChildId = chore.assignment_current_child_id || ''; const currentChild = childById[currentChildId]; const pool = (chore.assigned_to && chore.assigned_to.length) ? chore.assigned_to : (children || []).map(c => c.id); const poolSize = pool.length; const isSticky = groupByChoreId[chore.id] && groupByChoreId[chore.id].policy === 'sticky'; const isStickyFollower = isSticky && groupByChoreId[chore.id].chore_ids[0] !== chore.id; const skipDisabled = poolSize <= 1 || isStickyFollower; const group = groupByChoreId[chore.id]; const key = `skip_${chore.id}`; const loading = !!this._loading[key]; return html`
${chore.name} ${currentChild?.name || unknown} · ${chore.assignment_mode} ${group ? html`· ${group.policy === 'sticky' ? '🔗' : '🔀'} ${group.name}` : ''}
`; })} `; } _renderApprovals(pending, children, chores, pointsIcon) { if (!pending.length) return html`
${this._t('dashboard.empty_approvals')}
`; const childMap = {}; children.forEach(c => { childMap[c.id] = c; }); const choreMap = {}; chores.forEach(c => { choreMap[c.id] = c; }); return html` ${pending.map(comp => { const child = childMap[comp.child_id]; const chore = choreMap[comp.chore_id]; const isLoading = this._loading[comp.completion_id]; const time = new Date(comp.completed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); return html`
${comp.chore_name || chore?.name || this._t('common.unknown')}
${child?.name || this._t('common.unknown')} ${time} +${comp.points || chore?.points || 0}
${tmSafePhotoUrl(comp.photo_url) ? html` ` : ''}
`; })} `; } _renderClaims(claims, pointsIcon) { if (!claims.length) return html`
${this._t('dashboard.empty_claims')}
`; return html` ${claims.map(claim => { const isLoading = this._loading[claim.claim_id]; const time = new Date(claim.claimed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); return html`
${claim.reward_name}
${claim.child_name} • ${time} • ${claim.cost}
`; })} `; } _renderPoints(children, pointsIcon, pointsName) { const amount = this.config.quick_points_amount || 5; return html` ${children.map(child => html`
${child.name} ${child.points}
`)} `; } _notifyServiceError(service, error, notificationId) { console.error(`Failed to call ${service}:`, error); if (this.hass && this.hass.callService) { this.hass.callService("persistent_notification", "create", { title: this._t('approvals.error_title'), message: this._t('approvals.error_failed_service', { service: service.replace(/_/g, " "), message: error && error.message ? error.message : String(error), }), notification_id: notificationId, }); } } async _handleApprove(completionId) { if (this._loading[completionId]) return; this._loading = { ...this._loading, [completionId]: true }; this.requestUpdate(); try { await this.hass.callService("taskmate", "approve_chore", { completion_id: completionId }); } catch (e) { this._notifyServiceError("approve_chore", e, `taskmate_dashboard_approve_${completionId}`); } finally { this._loading = { ...this._loading, [completionId]: false }; this.requestUpdate(); } } async _handleReject(completionId) { if (this._loading[completionId]) return; this._loading = { ...this._loading, [completionId]: true }; this.requestUpdate(); try { await this.hass.callService("taskmate", "reject_chore", { completion_id: completionId }); } catch (e) { this._notifyServiceError("reject_chore", e, `taskmate_dashboard_reject_${completionId}`); } finally { this._loading = { ...this._loading, [completionId]: false }; this.requestUpdate(); } } async _handleApproveReward(claimId) { if (this._loading[claimId]) return; this._loading = { ...this._loading, [claimId]: true }; this.requestUpdate(); try { await this.hass.callService("taskmate", "approve_reward", { claim_id: claimId }); } catch (e) { this._notifyServiceError("approve_reward", e, `taskmate_dashboard_approve_reward_${claimId}`); } finally { this._loading = { ...this._loading, [claimId]: false }; this.requestUpdate(); } } async _handleRejectReward(claimId) { if (this._loading[claimId]) return; this._loading = { ...this._loading, [claimId]: true }; this.requestUpdate(); try { await this.hass.callService("taskmate", "reject_reward", { claim_id: claimId }); } catch (e) { this._notifyServiceError("reject_reward", e, `taskmate_dashboard_reject_reward_${claimId}`); } finally { this._loading = { ...this._loading, [claimId]: false }; this.requestUpdate(); } } async _handleSkip(choreId) { const key = `skip_${choreId}`; this._loading = { ...this._loading, [key]: true }; this.requestUpdate(); try { await this.hass.callService("taskmate", "skip_chore", { chore_id: choreId }); } catch (e) { this._notifyServiceError("skip_chore", e, `taskmate_dashboard_skip_${choreId}`); } finally { this._loading = { ...this._loading, [key]: false }; this.requestUpdate(); } } async _handlePoints(childId, delta) { const key = `${childId}_${delta}`; this._loading = { ...this._loading, [key]: true }; this.requestUpdate(); const service = delta > 0 ? "add_points" : "remove_points"; try { await this.hass.callService("taskmate", service, { child_id: childId, points: Math.abs(delta), }); } catch (e) { this._notifyServiceError(service, e, `taskmate_dashboard_points_${key}`); } finally { this._loading = { ...this._loading, [key]: false }; this.requestUpdate(); } } async _handleCompleteOnBehalf(choreId, childId) { const key = `behalf_${childId}_${choreId}`; if (this._loading[key]) return; this._loading = { ...this._loading, [key]: true }; this.requestUpdate(); try { await this.hass.callService("taskmate", "complete_chore", { chore_id: choreId, child_id: childId, as_parent: true, }); } catch (e) { this._notifyServiceError("complete_chore", e, `taskmate_dashboard_behalf_${key}`); } finally { this._loading = { ...this._loading, [key]: false }; this.requestUpdate(); } } } class TaskMateParentDashboardCardEditor extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object } }; } static get styles() { return css` :host { display: block; } 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; } _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } _buildSchema() { return [ { name: 'entity', selector: { entity: { domain: 'sensor' } } }, { name: 'title', selector: { text: {} } }, { 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: 'quick_points_amount', selector: { number: { min: 1, max: 100, mode: 'box' } } }, { name: 'show_claims', selector: { boolean: {} } }, ]; } _computeLabel = (entry) => { const labels = { entity: this._t('dashboard.editor.entity_label'), title: this._t('dashboard.editor.title_label'), card_design: this._t('common.design.field_label'), quick_points_amount: this._t('dashboard.editor.quick_points_label'), show_claims: this._t('dashboard.editor.show_claims'), }; return labels[entry.name] ?? entry.name; }; _computeHelper = (entry) => { const helpers = { entity: this._t('dashboard.editor.entity_helper'), quick_points_amount: this._t('dashboard.editor.quick_points_helper'), }; return helpers[entry.name] ?? ''; }; render() { if (!this.hass || !this.config) return html``; const data = { entity: this.config.entity || '', title: this.config.title || '', card_design: this.config.card_design || 'global', quick_points_amount: this.config.quick_points_amount || 5, show_claims: this.config.show_claims !== false, }; return html` ${this._renderColourPicker('header_color', '#c0392b')} `; } _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._update(key, v), onPreset: (v) => this._update(key, v), onReset: () => this._update(key, defaultValue), }); } _formChanged(e) { const newValues = e.detail.value || {}; const newConfig = { ...this.config }; for (const [key, value] of Object.entries(newValues)) { if (value === '' || value === null || value === undefined) { delete newConfig[key]; } else if (key === 'card_design' && value === 'global') { delete newConfig[key]; } else if (key === 'show_claims' && value === true) { delete newConfig[key]; } else if (key === 'quick_points_amount' && value === 5) { delete newConfig[key]; } else { newConfig[key] = value; } } this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: newConfig }, bubbles: true, composed: true, })); } _update(key, value) { const cfg = { ...this.config, [key]: value }; this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: cfg }, bubbles: true, composed: true })); } } customElements.define("taskmate-parent-dashboard-card", TaskMateParentDashboardCard); customElements.define("taskmate-parent-dashboard-card-editor", TaskMateParentDashboardCardEditor); window.customCards = window.customCards || []; window.customCards.push({ type: "taskmate-parent-dashboard-card", name: "TaskMate Parent Dashboard", description: "Unified parent view with approvals, child progress, and quick point controls", preview: true, getEntitySuggestion: (hass, entityId) => window.__taskmate_suggest(hass, entityId, "taskmate-parent-dashboard-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-parent-dashboard-card.js"]')) .map(s => s.src.split("?")[1]).find(Boolean) || "" ).get("v") || "?"; console.info( "%c TASKMATE PARENT DASHBOARD CARD %c v" + _tmVersion + " ", "background:#c0392b;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;" );