/** * TaskMate Points Display Card * A kid-friendly display card showing how many points a child (or all children) has. * * Modes: * single — One child, large animated point total. Great for a child's own dashboard. * multi — Grid showing every child with their own score. Good for family TV display. * cumulative — Combined family total with individual breakdowns below. * * Config options: * entity — sensor.taskmate_overview (required) * mode — single | multi | cumulative (default: single) * child_id — required for single mode * title — optional header title override * show_streak — show streak badge (default: true) * show_weekly — show this week's points alongside all-time (default: true) * show_rank — show rank medal in multi/cumulative modes (default: true) * animate — animate number on load (default: true) * header_color — hex colour for the card header (default: #9b59b6) * primary_display — current_points | career_score (default: current_points) * * 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; const DEFAULT_HEADER = "#9b59b6"; const CHILD_COLOURS = [ "#e74c3c", "#3498db", "#27ae60", "#f39c12", "#9b59b6", "#1abc9c", "#e67e22", "#2980b9", ]; const RANK_MEDAL = ["\u{1F947}", "\u{1F948}", "\u{1F949}"]; /* ─── Utility helpers ──────────────────────────────────────────────────── */ function getChildren(state) { return state.attributes.children || []; } function weeklyPoints(child, tz) { const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz }); const today = new Date(todayKey + "T12:00:00"); // noon avoids DST edge cases const monday = new Date(today); monday.setDate(today.getDate() - ((today.getDay() + 6) % 7)); const mondayKey = monday.toLocaleDateString("en-CA"); const history = child.history || []; return history .filter(e => e.approved && new Date(e.timestamp).toLocaleDateString("en-CA", { timeZone: tz }) >= mondayKey) .reduce((s, e) => s + (e.points || 0), 0); } function childAvatar(child, colour) { const av = child.avatar || "mdi:account-circle"; const isIcon = av.startsWith("mdi:"); const initials = (child.name || "?") .split(" ") .map(w => w[0]) .slice(0, 2) .join("") .toUpperCase(); return html`
${isIcon ? html`` : av ? html`${child.name}` : initials}
`; } /* ─── Main card ────────────────────────────────────────────────────────── */ const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); class TaskMatePointsDisplayCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _animated: { 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._animated = {}; } _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } static get styles() { const base = css` :host { display: block; } ha-card { overflow: hidden; font-family: inherit; } /* ── Header ── */ .card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; color: white; gap: 10px; } .header-left { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; } .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; } .card-title { font-size: 1.1rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; letter-spacing: 0.01em; } .mode-badge { background: rgba(255,255,255,0.2); border-radius: 10px; padding: 3px 10px; font-size: 0.75rem; font-weight: 600; text-transform: capitalize; flex-shrink: 0; } /* ── Shared body ── */ .card-body { padding: 20px 18px 22px; } /* ── Avatar ── */ .avatar { width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 800; color: white; flex-shrink: 0; overflow: hidden; } .avatar ha-icon { --mdc-icon-size: 32px; color: white; } .avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } /* ══════════════════════════════════════════ SINGLE MODE ══════════════════════════════════════════ */ .single-wrap { display: flex; flex-direction: column; align-items: center; gap: 18px; text-align: center; } .single-identity { display: flex; flex-direction: column; align-items: center; gap: 10px; } .single-wrap .avatar { width: 80px; height: 80px; font-size: 1.8rem; box-shadow: 0 4px 16px rgba(0,0,0,0.15); } .single-wrap .avatar ha-icon { --mdc-icon-size: 48px; } .child-name { font-size: 1.25rem; font-weight: 700; color: var(--primary-text-color); } /* Big points number */ .big-points { display: flex; flex-direction: column; align-items: center; background: var(--card-background-color, #fff); border: 2px solid var(--divider-color, #e0e0e0); border-radius: 24px; padding: 22px 40px; width: 100%; box-sizing: border-box; box-shadow: 0 2px 12px rgba(0,0,0,0.06); position: relative; overflow: hidden; } .big-points::before { content: "\u2B50"; font-size: 7rem; position: absolute; right: -12px; bottom: -18px; opacity: 0.06; line-height: 1; pointer-events: none; } .points-label { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--secondary-text-color); margin-bottom: 4px; } .points-number { font-size: 4.5rem; font-weight: 900; line-height: 1; letter-spacing: -0.02em; color: var(--primary-text-color); transition: color 0.3s; } .points-star { font-size: 2rem; animation: star-spin 3s linear infinite; display: inline-block; margin-right: 6px; vertical-align: middle; } @keyframes star-spin { 0% { transform: rotate(0deg) scale(1); } 50% { transform: rotate(180deg) scale(1.15); } 100% { transform: rotate(360deg) scale(1); } } /* Secondary metric line */ .secondary-info { font-size: 0.88rem; font-weight: 600; color: var(--secondary-text-color); margin-top: 8px; } .secondary-info .secondary-value { font-weight: 700; color: var(--primary-text-color); } /* Stats row under big number */ .stats-row { display: flex; gap: 14px; width: 100%; justify-content: center; flex-wrap: wrap; } .stat-pill { display: flex; align-items: center; gap: 6px; background: var(--secondary-background-color, #f5f5f5); border-radius: 20px; padding: 7px 14px; font-size: 0.88rem; font-weight: 600; color: var(--primary-text-color); white-space: nowrap; } .stat-pill ha-icon { --mdc-icon-size: 17px; opacity: 0.75; } .stat-pill .pill-label { font-size: 0.75rem; font-weight: 500; color: var(--secondary-text-color); } /* ══════════════════════════════════════════ MULTI MODE ══════════════════════════════════════════ */ .multi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 14px; } .child-tile { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 18px 12px; background: var(--card-background-color, #fff); border: 1px solid var(--divider-color, #e0e0e0); border-radius: 18px; text-align: center; position: relative; transition: box-shadow 0.2s; overflow: hidden; } .child-tile:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); } .child-tile .rank-badge { position: absolute; top: 8px; right: 10px; font-size: 1.1rem; line-height: 1; } .child-tile .avatar { width: 56px; height: 56px; font-size: 1.2rem; } .child-tile .child-name { font-size: 1rem; font-weight: 700; } .child-tile .tile-points { font-size: 2.4rem; font-weight: 900; line-height: 1; letter-spacing: -0.02em; } .child-tile .tile-star { font-size: 1rem; vertical-align: middle; margin-right: 2px; display: inline-block; animation: star-spin 4s linear infinite; } .child-tile .tile-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--secondary-text-color); } .child-tile .tile-weekly { font-size: 0.8rem; font-weight: 600; color: var(--secondary-text-color); } .child-tile .streak-chip { display: flex; align-items: center; gap: 3px; background: var(--secondary-background-color, #f5f5f5); border-radius: 12px; padding: 3px 8px; font-size: 0.75rem; font-weight: 700; } /* Top-child accent bar */ .child-tile.top-child::after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 4px; border-radius: 0 0 18px 18px; } /* ══════════════════════════════════════════ CUMULATIVE MODE ══════════════════════════════════════════ */ .cumulative-wrap { display: flex; flex-direction: column; gap: 18px; } .cumulative-total { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 22px; background: var(--card-background-color, #fff); border: 2px solid var(--divider-color, #e0e0e0); border-radius: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); position: relative; overflow: hidden; } .cumulative-total::before { content: "\u{1F31F}"; font-size: 6rem; position: absolute; right: -10px; bottom: -14px; opacity: 0.07; pointer-events: none; } .cumulative-total .points-label { margin-bottom: 6px; } .cumulative-total .points-number { font-size: 3.8rem; } .family-label { font-size: 0.9rem; font-weight: 600; color: var(--secondary-text-color); margin-top: 4px; } .divider-label { text-align: center; font-size: 0.78rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--secondary-text-color); display: flex; align-items: center; gap: 8px; } .divider-label::before, .divider-label::after { content: ""; flex: 1; height: 1px; background: var(--divider-color, #e0e0e0); } .cumul-row { 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: 14px; } .cumul-row .child-name { font-size: 1rem; font-weight: 700; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cumul-row .cumul-points { font-size: 1.5rem; font-weight: 900; color: var(--primary-text-color); flex-shrink: 0; } .cumul-row .cumul-star { font-size: 0.85rem; animation: star-spin 5s linear infinite; display: inline-block; margin-right: 2px; } .cumul-bar-wrap { width: 100%; background: var(--secondary-background-color, #f0f0f0); border-radius: 6px; height: 6px; overflow: hidden; margin-top: 4px; } .cumul-bar-fill { height: 100%; border-radius: 6px; transition: width 1s cubic-bezier(.4,0,.2,1); } .cumul-row-inner { flex: 1; min-width: 0; } /* ── Empty state ── */ .empty-state { text-align: center; padding: 32px 16px; color: var(--secondary-text-color); font-size: 0.9rem; } .empty-state ha-icon { --mdc-icon-size: 40px; opacity: 0.35; display: block; margin: 0 auto 10px; } /* Shared .tmd kit + design tokens are provided by taskmate-design.js styles(). */ /* Points Display — Playroom */ .pl-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 11px; } .pl-tile { background: var(--tmd-surface-2); border-radius: 18px; padding: 22px 12px 15px; text-align: center; position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; } .pl-medal { position: absolute; top: 9px; left: 11px; font-size: 16px; line-height: 1; } .pl-pts { font-family: var(--tmd-font-display); font-weight: 800; font-size: 1.85rem; line-height: 1; color: var(--tmd-accent); } .pl-pts span { font-size: 0.9rem; margin-left: 2px; } .pl-name { font-weight: 800; font-size: 0.95rem; } .pl-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; } /* Points Display — Console */ .cn-list { display: flex; flex-direction: column; gap: 9px; } .cn-row { display: flex; align-items: center; gap: 10px; background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: 10px; padding: 11px; } .cn-rank { font-size: 20px; width: 34px; color: var(--tmd-dim); } .cn-mid { flex: 1; min-width: 0; } .cn-name { font-weight: 700; margin-bottom: 6px; } .cn-right { text-align: right; } .cn-pts { font-size: 22px; } .cn-sub { font-size: 10px; } /* Points Display — Clean Pro */ .cp-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 4px; } .cp-tile { text-align: center; padding: 8px 6px; display: flex; flex-direction: column; align-items: center; gap: 6px; } .cp-pts { font-size: 1.6rem; } .cp-name { font-size: 0.82rem; font-weight: 600; } .cp-rank { background: transparent; } .cp-chips { display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; } .cp-total { justify-content: space-between; } /* Points Display — Cumulative (designed) */ .cumul-total-d { text-align: center; padding: 8px 0 14px; } .cumul-big { font-size: 2.3rem; color: var(--tmd-accent); margin-top: 2px; } .cumul-big-star { font-size: 1rem; } .cumul-list { display: flex; flex-direction: column; gap: 10px; } .cumul-row-d { display: flex; align-items: center; gap: 11px; background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: 14px; padding: 11px; } .cumul-mid { flex: 1; min-width: 0; } .cumul-head { justify-content: space-between; margin-bottom: 6px; } .cumul-name { font-weight: 700; } `; 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("TaskMate Points Display: 'entity' is required."); this.config = { mode: "single", primary_display: "current_points", show_streak: true, show_weekly: true, show_rank: true, animate: true, header_color: DEFAULT_HEADER, ...config, }; } static getConfigElement() { return document.createElement("taskmate-points-display-card-editor"); } static getStubConfig() { return { entity: "sensor.taskmate_overview", mode: "multi" }; } getCardSize() { return 3; } /* ── Data helpers ───────────────────────────────────────────────────── */ _stateObj() { return this.hass?.states?.[this.config.entity]; } _allChildren() { const s = this._stateObj(); return s ? getChildren(s) : []; } _isCareerMode() { return this.config.primary_display === "career_score"; } _primaryValue(child) { return this._isCareerMode() ? (child.career_score || 0) : (child.points || 0); } _secondaryValue(child) { return this._isCareerMode() ? (child.points || 0) : (child.career_score || 0); } _primaryLabel() { return this._isCareerMode() ? this._t("points_display.career_score_label") : this._t("points_display.total_points"); } _secondaryLabel() { return this._isCareerMode() ? this._t("points_display.current_points_label") : this._t("points_display.career_score_label"); } _rankedChildren() { return [...this._allChildren()].sort((a, b) => this._primaryValue(b) - this._primaryValue(a)); } _singleChild() { const cid = this.config.child_id; return this._allChildren().find(c => c.id === cid) || null; } /* ── Render helpers ─────────────────────────────────────────────────── */ _headerStyle() { return `background: ${_safeColor(this.config.header_color, DEFAULT_HEADER)};`; } _defaultTitle() { const mode = this.config.mode || "single"; if (mode === "single") { const c = this._singleChild(); return c ? this._t("points_display.single_title", { name: c.name }) : this._t("points_display.default_title"); } if (mode === "cumulative") return this._t("points_display.family_title"); return this._t("points_display.board_title"); } /* ── Single mode ────────────────────────────────────────────────────── */ _renderSingle() { const child = this._singleChild(); if (!child) { return html`
${this._t("points_display.empty_no_child")}
${this._t("points_display.empty_set_child_id")}
`; } const children = this._allChildren(); const colour = CHILD_COLOURS[children.indexOf(child) % CHILD_COLOURS.length]; const primary = this._primaryValue(child); const secondary = this._secondaryValue(child); const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; const weekly = weeklyPoints(child, tz); const streak = child.streak || 0; const rank = this._rankedChildren().findIndex(c => c.id === child.id) + 1; return html`
${childAvatar(child, colour)}
${child.name}
${this._primaryLabel()}
\u2B50${primary.toLocaleString()}
${this._secondaryLabel()}: ${secondary.toLocaleString()}
${this.config.show_weekly ? html`
${this._t("points_display.weekly_pts", { count: weekly })}
${this._t("points_display.this_week")}
` : ""} ${this.config.show_streak ? html`
${streak === 1 ? this._t("points_display.streak_day", { count: streak }) : this._t("points_display.streak_days", { count: streak })}
${this._t("points_display.streak")}
` : ""} ${this.config.show_rank && children.length > 1 ? html`
${rank <= 3 ? RANK_MEDAL[rank - 1] : `#${rank}`}
${this._t("points_display.rank")}
` : ""}
`; } /* ── Multi mode ─────────────────────────────────────────────────────── */ _renderMulti() { const ranked = this._rankedChildren(); if (!ranked.length) { return html`
${this._t("points_display.empty_no_children")}
`; } const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; return html`
${ranked.map((child, idx) => { const colour = CHILD_COLOURS[this._allChildren().indexOf(child) % CHILD_COLOURS.length]; const primary = this._primaryValue(child); const secondary = this._secondaryValue(child); const weekly = weeklyPoints(child, tz); const streak = child.streak || 0; const isTop = idx === 0; return html`
${isTop ? html`
\u{1F947}
` : idx < 3 && this.config.show_rank ? html`
${RANK_MEDAL[idx]}
` : ""} ${childAvatar(child, colour)}
${child.name}
\u2B50${primary.toLocaleString()}
${this._isCareerMode() ? this._t("points_display.career_score_label") : this._t("points_display.points_label")}
${this._secondaryLabel()}: ${secondary.toLocaleString()}
${this.config.show_weekly ? html`
${this._t("points_display.weekly_plus", { count: weekly })}
` : ""} ${this.config.show_streak ? html`
\u{1F525} ${streak}
` : ""} ${isTop ? html`
` : ""}
`; })}
`; } /* ── Cumulative mode ────────────────────────────────────────────────── */ _renderCumulative() { const ranked = this._rankedChildren(); if (!ranked.length) { return html`
${this._t("points_display.empty_no_children")}
`; } const total = ranked.reduce((s, c) => s + this._primaryValue(c), 0); const secTotal = ranked.reduce((s, c) => s + this._secondaryValue(c), 0); return html`
${this._t("points_display.combined_family_total")}
\u{1F31F}${total.toLocaleString()}
${this._secondaryLabel()}: ${secTotal.toLocaleString()}
${this._t("points_display.family_subtitle", { count: ranked.length })}
${this._t("points_display.individual_scores")}
${ranked.map((child, idx) => { const colour = CHILD_COLOURS[this._allChildren().indexOf(child) % CHILD_COLOURS.length]; const primary = this._primaryValue(child); const secondary = this._secondaryValue(child); const pct = total > 0 ? Math.round((primary / total) * 100) : 0; return html`
${childAvatar(child, colour)}
${child.name} \u2B50${primary.toLocaleString()}
${this._secondaryLabel()}: ${secondary.toLocaleString()}
${pct}%
${this.config.show_rank && idx < 3 ? html`
${RANK_MEDAL[idx]}
` : ""}
`; })}
`; } /* ── Main render ────────────────────────────────────────────────────── */ render() { 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 mode = this.config.mode || "single"; const title = this.config.title || this._defaultTitle(); const modeLabel = { single: this._t("points_display.mode_single"), multi: this._t("points_display.mode_multi"), cumulative: this._t("points_display.mode_cumulative"), }[mode] || mode; const modeIcon = { single: "mdi:star-circle", multi: "mdi:account-group", cumulative: "mdi:sigma" }[mode] || "mdi:star"; let body; if (mode === "multi") body = this._renderMulti(); else if (mode === "cumulative") body = this._renderCumulative(); else body = this._renderSingle(); return html`
${title}
${modeLabel}
${body}
`; } /* ══════════════════════════════════════════════════════════════════════ DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js Ported from docs/design/redesigns (§card-points-display). ══════════════════════════════════════════════════════════════════════ */ _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}
`; } _renderDesigned(design) { const mode = this.config.mode || "single"; const title = this.config.title || this._defaultTitle(); const hd = _safeColor(this.config.header_color, DEFAULT_HEADER); const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; let ranked = this._rankedChildren(); if (mode === "single") { const c = this._singleChild(); ranked = c ? [c] : []; } const header = html`
${title} ${mode === "multi" ? html`${this._t("points_display.mode_multi")}` : ""}
`; if (!ranked.length) { return html` ${header}
${this._t("points_display.empty_no_children")}
`; } const rows = ranked.map((child) => ({ child, tone: this._designTone(this._allChildren().indexOf(child)), points: this._primaryValue(child), weekly: weeklyPoints(child, tz), // Overview summary exposes current_streak (child.streak doesn't exist). streak: child.current_streak ?? child.streak ?? 0, pct: 0, })); const total = rows.reduce((s, r) => s + r.points, 0); rows.forEach((r) => { r.pct = total > 0 ? Math.round((r.points / total) * 100) : 0; }); rows.forEach((r, i) => { r.rank = i; }); const body = mode === "cumulative" ? this._cumulBody(rows, total) : design === "playroom" ? this._designPlayroom(rows) : design === "console" ? this._designConsole(rows) : this._designCleanpro(rows, total); return html`${header}
${body}
`; } _designPlayroom(rows) { const showRank = this.config.show_rank !== false; const showWeekly = this.config.show_weekly !== false; const showStreak = this.config.show_streak !== false; return html`
${rows.map((r) => html`
${showRank && r.rank < 3 ? html`
${RANK_MEDAL[r.rank]}
` : ""} ${this._av(r.child, r.tone, 52)}
${r.points.toLocaleString()}
${r.child.name}
${showWeekly || (showStreak && r.streak) ? html`
${showWeekly ? html`
${this._t("points_display.weekly_plus", { count: r.weekly })}
` : ""} ${showStreak && r.streak ? html`
\u{1F525} ${r.streak}
` : ""}
` : ""}
`)}
`; } _designConsole(rows) { const max = Math.max(...rows.map((r) => r.points), 1); const showRank = this.config.show_rank !== false; const showWeekly = this.config.show_weekly !== false; const showStreak = this.config.show_streak !== false; return html`
${rows.map((r) => html`
${showRank ? html`
#${r.rank + 1}
` : ""} ${this._av(r.child, r.tone, 38)}
${r.child.name}
${r.points.toLocaleString()}
XP${showWeekly ? html` · +${r.weekly}` : ""}${showStreak && r.streak ? html` · \u{1F525}${r.streak}` : ""}
`)}
`; } _designCleanpro(rows, total) { const showRank = this.config.show_rank !== false; const showWeekly = this.config.show_weekly !== false; const showStreak = this.config.show_streak !== false; return html`
${rows.map((r) => html`
${this._av(r.child, r.tone, 46)}
${r.points.toLocaleString()}
${r.child.name}
${showRank ? html`
${r.rank === 0 ? html` ` : ""}#${r.rank + 1}
` : ""} ${showWeekly ? html`
+${r.weekly}
` : ""} ${showStreak && r.streak ? html`
\u{1F525} ${r.streak}
` : ""}
`)}
${rows.length > 1 ? html`
${this._t("points_display.combined_family_total")} ${total.toLocaleString()} ${this._t("points_display.points_label")}
` : ""}`; } _cumulBody(rows, total) { return html`
${this._t("points_display.combined_family_total")}
${total.toLocaleString()}
${rows.map((r) => html`
${this._av(r.child, r.tone, 40)}
${r.child.name} ${r.points.toLocaleString()}
`)}
`; } } customElements.define("taskmate-points-display-card", TaskMatePointsDisplayCard); /* ═══════════════════════════════════════════════════════════════════════ UI EDITOR ═══════════════════════════════════════════════════════════════════════ */ class TaskMatePointsDisplayCardEditor extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _children: { type: Array }, }; } constructor() { super(); this._children = []; } _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: 8px 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; } updated(changed) { if (changed.has('hass') && this.hass && this.config?.entity) { this._loadChildren(); } } _loadChildren() { const state = this.hass?.states?.[this.config.entity]; if (!state) { this._children = []; return; } this._children = getChildren(state); } _buildSchema() { const mode = this.config.mode || 'single'; const schema = [ { name: 'entity', selector: { entity: { domain: 'sensor' } } }, { name: 'title', selector: { text: {} } }, { name: 'mode', selector: { select: { options: [ { value: 'single', label: this._t('points_display.editor.mode_single') }, { value: 'multi', label: this._t('points_display.editor.mode_multi') }, { value: 'cumulative', label: this._t('points_display.editor.mode_cumulative') }, ], 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: 'primary_display', selector: { select: { options: [ { value: 'current_points', label: this._t('points_display.editor.primary_current_points') }, { value: 'career_score', label: this._t('points_display.editor.primary_career_score') }, ], mode: 'dropdown', }, }, }, ]; if (mode === 'single') { schema.push({ name: 'child_id', selector: { select: { options: [ { value: '', label: this._t('points_display.editor.select_child') }, ...this._children.map((c) => ({ value: c.id, label: c.name })), ], mode: 'dropdown', }, }, }); } schema.push({ name: 'show_weekly', selector: { boolean: {} } }); schema.push({ name: 'show_streak', selector: { boolean: {} } }); if (mode !== 'single') { schema.push({ name: 'show_rank', selector: { boolean: {} } }); } return schema; } _computeLabel = (entry) => { const labels = { entity: this._t('points_display.editor.entity_label'), title: this._t('common.editor.card_title'), mode: this._t('points_display.editor.mode_label'), card_design: this._t('common.design.field_label'), primary_display: this._t('points_display.editor.primary_display'), child_id: this._t('points_display.editor.child_label'), show_weekly: this._t('points_display.editor.show_weekly'), show_streak: this._t('points_display.editor.show_streak'), show_rank: this._t('points_display.editor.show_rank'), }; return labels[entry.name] ?? entry.name; }; _computeHelper = () => ''; render() { if (!this.config) return html``; const mode = this.config.mode || 'single'; const data = { entity: this.config.entity || '', title: this.config.title || '', mode, card_design: this.config.card_design || 'global', primary_display: this.config.primary_display || 'current_points', child_id: this.config.child_id || '', show_weekly: this.config.show_weekly !== false, show_streak: this.config.show_streak !== false, show_rank: this.config.show_rank !== false, }; return html` ${this._renderColourPicker('header_color', DEFAULT_HEADER)} `; } _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 === 'mode' && value === 'single') { delete newConfig[key]; } else if (key === 'primary_display' && value === 'current_points') { delete newConfig[key]; } else if ((key === 'show_weekly' || key === 'show_streak' || key === 'show_rank') && value === true) { delete newConfig[key]; } else { newConfig[key] = value; } } this._fire(newConfig); } _fire(config) { this.dispatchEvent(new CustomEvent('config-changed', { detail: { config }, bubbles: true, composed: true, })); } _set(key, value) { this._fire({ ...this.config, [key]: value }); } } customElements.define("taskmate-points-display-card-editor", TaskMatePointsDisplayCardEditor); /* ── Registration ─────────────────────────────────────────────────────── */ window.customCards = window.customCards || []; window.customCards.push({ type: "taskmate-points-display-card", name: "TaskMate \u2014 Points Display", description: "Kid-friendly display of points. Supports single child, all children, or combined family total.", preview: true, configElement: "taskmate-points-display-card-editor", getEntitySuggestion: (hass, entityId) => window.__taskmate_suggest(hass, entityId, "taskmate-points-display-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-points-display-card.js"]')) .map(s => s.src.split("?")[1]).find(Boolean) || "" ).get("v") || "?"; console.info( "%c TASKMATE POINTS DISPLAY CARD %c v" + _tmVersion + " ", "background:#566573;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;" );