/** * TaskMate Badges Card * Displays a full grid of achievement badges for a single child. * Earned badges show tier colour and earn date; locked badges show * closest-criterion progress bar. * * Config: * entity - (required) full entity id, e.g. sensor.taskmate_badges_mia * title - card title override * * 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 DESIGN_HEADER = "#6c5ce7"; const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); class TaskMateBadgesCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _filterTier: { type: String }, _justEarned: { type: String }, }; } constructor() { super(); this._filterTier = "all"; this._justEarned = null; this._unsubscribeEvents = null; this._subscribing = false; this._justEarnedTimeout = null; } connectedCallback() { super.connectedCallback(); this._subscribeEvents(); } disconnectedCallback() { super.disconnectedCallback(); this._unsubscribeEvents?.(); this._unsubscribeEvents = null; if (this._justEarnedTimeout) { clearTimeout(this._justEarnedTimeout); this._justEarnedTimeout = null; } } _subscribeEvents() { if (!this.hass) return; if (this._unsubscribeEvents || this._subscribing) return; this._subscribing = true; this.hass.connection.subscribeEvents((event) => { const data = event.data || {}; if (data.child_id && this.config?.child_id && String(data.child_id) !== String(this.config.child_id)) return; if (data.badge_id) { this._justEarned = String(data.badge_id); this.requestUpdate(); this._justEarnedTimeout = setTimeout(() => { this._justEarned = null; this.requestUpdate(); }, 1800); } }, "taskmate_badge_earned").then(unsub => { this._subscribing = false; if (!this.isConnected) { unsub(); return; } this._unsubscribeEvents = unsub; }).catch(() => { this._subscribing = false; }); } 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; } static get styles() { const base = css` :host { display: block; --t-bronze: #e08a3c; --t-silver: #a8b8c8; --t-gold: #ffc107; --t-platinum: #4dd9e8; --t-bronze-bg: linear-gradient(135deg, #fdf0e4 0%, #f9dfc2 100%); --t-silver-bg: linear-gradient(135deg, #f0f3f7 0%, #dce4ec 100%); --t-gold-bg: linear-gradient(135deg, #fffde6 0%, #fff3b0 100%); --t-platinum-bg: linear-gradient(135deg, #e8fbfd 0%, #c4f0f6 100%); --t-locked-bg: linear-gradient(135deg, #f8f9fa 0%, #eef0f2 100%); } ha-card { overflow: hidden; } /* ── Header ── */ .badges-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); gap: 12px; } .badges-head-left { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; } .child-avatar { width: 44px; height: 44px; border-radius: 50%; background: rgba(255,255,255,0.25); border: 2px solid rgba(255,255,255,0.5); display: flex; align-items: center; justify-content: center; flex-shrink: 0; --mdc-icon-size: 26px; color: white; } .badges-title { font-size: 1.1rem; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .badges-count { font-size: 0.75rem; color: rgba(255,255,255,0.8); margin-top: 2px; } .badges-filter { background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.35); color: #fff; padding: 6px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; flex-shrink: 0; cursor: pointer; backdrop-filter: blur(4px); } .badges-filter option { color: var(--primary-text-color, #333); background: var(--card-background-color, #fff); } /* ── Grid ── */ .badge-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; padding: 18px; } /* ── Tier colour helpers ── */ .tier-bronze { --t: var(--t-bronze); --t-glow: rgba(224,138,60,0.35); --t-bg: var(--t-bronze-bg); } .tier-silver { --t: var(--t-silver); --t-glow: rgba(168,184,200,0.35); --t-bg: var(--t-silver-bg); } .tier-gold { --t: var(--t-gold); --t-glow: rgba(255,193,7,0.40); --t-bg: var(--t-gold-bg); } .tier-platinum { --t: var(--t-platinum); --t-glow: rgba(77,217,232,0.40); --t-bg: var(--t-platinum-bg); } /* ── Badge tile ── */ .badge { background: var(--t-locked-bg); border-radius: 16px; padding: 18px 12px 14px; text-align: center; position: relative; transition: transform 0.2s ease, box-shadow 0.2s ease; border: 2px dashed var(--divider-color, #d0d5db); } .badge.earned { background: var(--t-bg); border: 2.5px solid var(--t); border-style: solid; box-shadow: 0 4px 16px var(--t-glow), 0 1px 3px rgba(0,0,0,0.06); } .badge.earned:hover { transform: translateY(-3px) scale(1.02); box-shadow: 0 8px 24px var(--t-glow), 0 2px 6px rgba(0,0,0,0.08); } .badge.locked { opacity: 0.5; filter: grayscale(0.3); } .badge.locked:hover { opacity: 0.65; transform: scale(1.02); } .badge.just-earned { animation: earn-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), earn-shimmer 1.5s ease-out 0.6s; } @keyframes earn-pop { 0% { transform: scale(0.3) rotate(-8deg); opacity: 0; } 60% { transform: scale(1.12) rotate(2deg); opacity: 1; } 100% { transform: scale(1) rotate(0deg); } } @keyframes earn-shimmer { 0% { box-shadow: 0 0 0 0 var(--t-glow), 0 4px 16px var(--t-glow); } 50% { box-shadow: 0 0 0 12px transparent, 0 4px 24px var(--t-glow); } 100% { box-shadow: 0 4px 16px var(--t-glow), 0 1px 3px rgba(0,0,0,0.06); } } .badge-icon { width: 58px; height: 58px; margin: 0 auto 10px; border-radius: 50%; display: flex; align-items: center; justify-content: center; --mdc-icon-size: 30px; background: var(--divider-color, #e0e0e0); color: var(--secondary-text-color); transition: transform 0.2s ease; } .badge.earned .badge-icon { background: var(--t); color: #fff; box-shadow: 0 3px 10px var(--t-glow); } .badge.earned:hover .badge-icon { transform: scale(1.1) rotate(-5deg); } .tier-gold.earned .badge-icon, .tier-bronze.earned .badge-icon { color: #2d2000; } .badge-tier { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 1.2px; font-weight: 800; color: var(--t, var(--secondary-text-color)); margin-bottom: 4px; } .badge-name { font-size: 0.82rem; font-weight: 700; color: var(--primary-text-color); margin-bottom: 3px; line-height: 1.25; } .badge.locked .badge-name { color: var(--secondary-text-color); } .badge-meta { font-size: 0.65rem; color: var(--secondary-text-color); margin-bottom: 6px; font-weight: 500; } .badge.earned .badge-meta { color: var(--t); font-weight: 600; } .badge-progress { height: 7px; background: var(--divider-color, #ddd); border-radius: 4px; overflow: hidden; margin-top: 8px; } .badge-progress-fill { height: 100%; background: linear-gradient(90deg, var(--t), color-mix(in srgb, var(--t), white 25%)); border-radius: 4px; transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); } .badge-progress-label { font-size: 0.65rem; color: var(--secondary-text-color); margin-top: 4px; font-weight: 600; } .badge-bonus { position: absolute; top: -4px; right: -4px; background: linear-gradient(135deg, var(--t), color-mix(in srgb, var(--t), black 15%)); color: #fff; font-size: 0.65rem; font-weight: 800; padding: 3px 7px; border-radius: 999px; box-shadow: 0 2px 6px rgba(0,0,0,0.15); z-index: 1; } .tier-gold .badge-bonus, .tier-bronze .badge-bonus { color: #2d2000; } /* ── Empty / error states ── */ .error-state, .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--secondary-text-color); text-align: center; } .error-state { color: var(--error-color, #f44336); } .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; } /* ── Designed styles (playroom / console / cleanpro) ── */ /* Shared .tmd kit + tokens come from taskmate-design.js styles(). */ .bd-filter { gap: 6px; flex-wrap: wrap; margin-bottom: 11px; } .bd-chip { cursor: pointer; } .bd-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(82px, 1fr)); gap: 9px; max-height: 360px; overflow-y: auto; } .bd-tile { background: var(--tmd-surface-2); border-radius: 16px; padding: 11px 6px; text-align: center; position: relative; } .bd-tile.locked { opacity: 0.55; } .bd-bonus { position: absolute; top: 6px; right: 8px; font-size: 10px; font-weight: 800; color: var(--tmd-good); } .bd-ic { width: 44px; height: 44px; border-radius: 50%; margin: 0 auto 6px; display: grid; place-items: center; font-size: 22px; --mdc-icon-size: 24px; } .bd-ic.earned { background: color-mix(in srgb, var(--bt, var(--tmd-accent)) 22%, var(--tmd-surface)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bt, var(--tmd-accent)) 55%, transparent); color: var(--bt, var(--tmd-accent)); } .bd-ic.locked { background: var(--tmd-surface); box-shadow: inset 0 0 0 2px var(--tmd-border); color: var(--tmd-dim); } .bd-name { font-weight: 800; font-size: 11.5px; } .bd-tile.locked .bd-name { color: var(--tmd-dim); } .bd-date { font-size: 10px; } .bd-prog-label { font-size: 10px; margin-top: 3px; } /* Console tile overrides */ :host([data-tm-design="console"]) .bd-tile { border-radius: 8px; } :host([data-tm-design="console"]) .bd-tile.earned { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bt, var(--tmd-accent)) 50%, transparent), 0 0 16px color-mix(in srgb, var(--bt, var(--tmd-accent)) 22%, transparent); } :host([data-tm-design="console"]) .bd-tile.locked { background: #0b1424; border: 1px solid var(--tmd-border); opacity: 1; } :host([data-tm-design="console"]) .bd-ic.earned { background: transparent; box-shadow: inset 0 0 0 1px var(--bt, var(--tmd-accent)), 0 0 14px color-mix(in srgb, var(--bt, var(--tmd-accent)) 55%, transparent); } :host([data-tm-design="console"]) .bd-ic.locked { box-shadow: inset 0 0 0 1px var(--tmd-border); } :host([data-tm-design="console"]) .bd-name { font-weight: 700; font-size: 11px; } /* Clean Pro tile overrides */ :host([data-tm-design="cleanpro"]) .bd-tile { background: transparent; border: 1px solid var(--tmd-border); border-radius: 11px; } :host([data-tm-design="cleanpro"]) .bd-tile.locked { background: var(--tmd-surface-2); } :host([data-tm-design="cleanpro"]) .bd-ic { width: 40px; height: 40px; font-size: 20px; } :host([data-tm-design="cleanpro"]) .bd-ic.earned { background: color-mix(in srgb, var(--bt, var(--tmd-accent)) 14%, transparent); box-shadow: none; } :host([data-tm-design="cleanpro"]) .bd-ic.locked { box-shadow: none; } :host([data-tm-design="cleanpro"]) .bd-name { font-weight: 600; } `; 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-badges-card: 'entity' is required (e.g. sensor.taskmate_badges_mia)"); } this.config = { title: null, ...config }; // Extract child_id from entity name for event matching: // sensor.taskmate_badges_ → slug const match = config.entity.match(/sensor\.taskmate_badges_(.+)/); this._childSlug = match ? match[1] : null; } getCardSize() { return 5; } static getConfigElement() { return document.createElement("taskmate-badges-card-editor"); } static getStubConfig() { return { entity: "sensor.taskmate_overview" }; } _tierLabel(tier) { return this._t("badge.tier_" + (tier || "bronze")); } _formatDate(iso) { if (!iso) return ""; try { return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short" }); } catch { return iso; } } render() { if (!this.hass || !this.config) return html``; const entity = this.hass.states[this.config.entity]; if (!entity) { return html`
${this._t('badges.entity_not_found', { entity: this.config.entity })}
`; } if (entity.state === "unavailable" || entity.state === "unknown") { return html`
${this._t('common.unavailable')}
`; } const attrs = entity.attributes || {}; const isPreview = !attrs.earned && !attrs.available; const earned = isPreview ? [{ id: "s1", name: "Early Bird", icon: "mdi:weather-sunny", tier: "gold", point_bonus: 10, earned_at: new Date().toISOString() }, { id: "s2", name: "Streak Star", icon: "mdi:fire", tier: "silver", point_bonus: 5, earned_at: new Date().toISOString() }] : (attrs.earned || []); const available = isPreview ? [{ id: "s3", name: "Helping Hand", icon: "mdi:hand-heart", tier: "bronze", point_bonus: 5, progress_pct: 60, progress_label: "3 / 5" }] : (attrs.available || []); const childName = isPreview ? "Child" : (attrs.child_name || attrs.name || ""); const childAvatar = attrs.child_avatar || "mdi:account-circle"; const totalBadges = attrs.total_badges || (earned.length + available.length); const filter = this._filterTier; const filteredEarned = filter === "all" ? earned : earned.filter(b => b.tier === filter); const filteredLocked = filter === "all" ? available : available.filter(b => b.tier === filter); const latestEarned = earned.length > 0 ? earned[0] : null; const countLabel = earned.length > 0 && latestEarned ? this._t("badges.count_label_latest", { earned: earned.length, total: totalBadges, latest: latestEarned.name }) : this._t("badges.count_label", { earned: earned.length, total: totalBadges }); const title = this.config.title || (childName ? this._t("badges.title_with_name", { name: childName }) : this._t("badges.default_title")); 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, { title, childName, earned, available, totalBadges, filteredEarned, filteredLocked, filter, }); } return html`
${title}
${countLabel}
${filteredEarned.map(b => this._renderEarned(b))} ${filteredLocked.map(b => this._renderLocked(b))}
`; } _renderEarned(b) { const justEarned = this._justEarned && String(this._justEarned) === String(b.id); return html`
${b.point_bonus > 0 ? html`
+${b.point_bonus}
` : ''}
${this._tierLabel(b.tier)}
${b.name}
${this._t("badge.earned_date", { date: this._formatDate(b.earned_at) })}
`; } _renderLocked(b) { // progress: either provided directly, or computed from closest_criterion const pct = (b.progress_pct != null) ? b.progress_pct : (b.closest_criterion ? Math.min(100, Math.round((b.closest_criterion.current / b.closest_criterion.target) * 100)) : 0); const progressLabel = b.progress_label || (b.closest_criterion ? `${b.closest_criterion.current} / ${b.closest_criterion.target}` : null); return html`
${b.point_bonus > 0 ? html`
+${b.point_bonus}
` : ''}
${this._tierLabel(b.tier)}
${b.name}
${progressLabel ? html`
${progressLabel}
` : ''}
`; } } // Designed-style render methods are added to the prototype below. TaskMateBadgesCard.prototype._tierTone = function (tier) { // Map badge tiers onto shared design tokens (kit defines --tmd-gold). switch (tier) { case "platinum": return "var(--tmd-accent)"; case "gold": return "var(--tmd-gold)"; case "silver": return "var(--tmd-dim)"; case "bronze": return "var(--tmd-c6)"; default: return "var(--tmd-accent)"; } }; TaskMateBadgesCard.prototype._designFilters = function (filter) { const tiers = ["all", "bronze", "silver", "gold", "platinum"]; return html`
${tiers.map((t) => html` `)}
`; }; TaskMateBadgesCard.prototype._designEarnedTile = function (b) { const tone = this._tierTone(b.tier); const a = b.icon || "mdi:medal"; const icon = a.startsWith("mdi:") ? html`` : a; return html`
${b.point_bonus > 0 ? html`
+${b.point_bonus}
` : ""}
${icon}
${b.name}
${this._formatDate(b.earned_at)}
`; }; TaskMateBadgesCard.prototype._designLockedTile = function (b) { const tone = this._tierTone(b.tier); const pct = (b.progress_pct != null) ? b.progress_pct : (b.closest_criterion ? Math.min(100, Math.round((b.closest_criterion.current / b.closest_criterion.target) * 100)) : 0); const progressLabel = b.progress_label || (b.closest_criterion ? `${b.closest_criterion.current} / ${b.closest_criterion.target}` : null); const a = b.icon || "mdi:medal-outline"; const icon = a.startsWith("mdi:") ? html`` : a; return html`
${b.point_bonus > 0 ? html`
+${b.point_bonus}
` : ""}
${icon}
${b.name}
${progressLabel ? html`
${progressLabel}
` : ""}
`; }; TaskMateBadgesCard.prototype._renderDesigned = function (design, d) { const hd = _safeColor(this.config.header_color, DESIGN_HEADER); const ic = design === "console" ? "✦" : design === "cleanpro" ? "◈" : "🎖️"; const sub = d.childName; const countPill = this._t("badges.count_label", { earned: d.earned.length, total: d.totalBadges }); return html`
${ic} ${d.title}${sub ? html`${sub}` : ""} ${countPill}
${this._designFilters(d.filter)} ${(d.filteredEarned.length + d.filteredLocked.length) === 0 ? html`
${this._t("badges.count_label", { earned: 0, total: d.totalBadges })}
` : html`
${d.filteredEarned.map((b) => this._designEarnedTile(b))} ${d.filteredLocked.map((b) => this._designLockedTile(b))}
`}
`; }; // Editor: entity + per-card design override. class TaskMateBadgesCardEditor extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object } }; } 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', }, }, }, ]; } _computeLabel = (entry) => { const labels = { entity: this._t('common.entity'), title: this._t('common.editor.card_title'), card_design: this._t('common.design.field_label'), }; return labels[entry.name] ?? entry.name; }; render() { if (!this.config) return html``; const data = { entity: this.config.entity || '', title: this.config.title || '', card_design: this.config.card_design || 'global', }; return html`

${this._t('badges.editor_help')}

`; } _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 { newConfig[key] = value; } } this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: newConfig }, bubbles: true, composed: true, })); } } customElements.define("taskmate-badges-card", TaskMateBadgesCard); customElements.define("taskmate-badges-card-editor", TaskMateBadgesCardEditor); window.customCards = window.customCards || []; window.customCards.push({ type: "taskmate-badges-card", name: "TaskMate Badges", description: "Achievement badge grid for a single child", preview: true, getEntitySuggestion: (hass, entityId) => window.__taskmate_suggest(hass, entityId, "taskmate-badges-card", "overview"), }); const _tmBadgesVersion = new URLSearchParams( Array.from(document.querySelectorAll('script[src*="/taskmate-badges-card.js"]')) .map(s => s.src.split("?")[1]).find(Boolean) || "" ).get("v") || "?"; console.info( "%c TASKMATE BADGES CARD %c v" + _tmBadgesVersion + " ", "background:#9b59b6;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;", "background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;" );