/**
* 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`
${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 || "?").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` this._openPhoto(e, photoUrl, photoCap)}"
title="${this._t('approvals.view_photo') || 'View photo'}">`
: "";
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`
{ this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
${isParent && isOpen ? html`
e.stopPropagation()}">
${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`
`;
})}
`;
}
_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;"
);