Files
Home-Assistant/custom_components/taskmate/www/taskmate-reward-progress-card.js
T
2026-07-08 10:43:39 -04:00

997 lines
38 KiB
JavaScript

/**
* TaskMate Reward Progress Card
* Full-screen motivational display showing a single reward's progress.
* Designed for wall tablets as a persistent motivation display.
*
* 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 _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
class TaskMateRewardProgressCard extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { 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;
}
static get styles() {
const base = css`
:host { display: block; }
ha-card { overflow: hidden; }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--taskmate-header-bg, var(--primary-color));
color: var(--text-primary-color, #fff);
gap: 12px;
}
.header-content { display: flex; align-items: center; gap: 10px; min-width: 0; }
.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;
}
.card-content { padding: 20px 18px; }
/* Reward hero section */
.reward-hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 12px 0 20px;
gap: 10px;
}
.reward-icon-wrap {
width: 90px;
height: 90px;
border-radius: 50%;
background: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--ha-card-box-shadow, none);
animation: hero-float 3s ease-in-out infinite;
}
@keyframes hero-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.reward-icon-wrap ha-icon { --mdc-icon-size: 52px; color: var(--text-primary-color, #fff); }
.reward-name {
font-size: 1.6rem;
font-weight: 700;
color: var(--primary-text-color);
line-height: 1.2;
}
.reward-description {
font-size: 0.9rem;
color: var(--secondary-text-color);
max-width: 280px;
}
/* Children progress blocks */
.children-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.child-progress-block {
background: var(--secondary-background-color, #f8f8f8);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.child-progress-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.child-progress-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.child-avatar {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 50%;
background: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
}
.child-avatar ha-icon { --mdc-icon-size: 24px; color: var(--text-primary-color, #fff); }
.child-progress-name {
font-size: 1rem;
font-weight: 600;
color: var(--primary-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.child-points-label {
font-size: 0.8rem;
color: var(--secondary-text-color);
}
.child-progress-cost {
text-align: right;
flex-shrink: 0;
}
.cost-label {
font-size: 0.75rem;
color: var(--secondary-text-color);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.cost-value {
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-color);
display: flex;
align-items: center;
gap: 3px;
justify-content: flex-end;
}
.cost-value ha-icon { --mdc-icon-size: 16px; color: #ca8a04; }
/* Big animated progress bar */
.big-progress-wrap {
display: flex;
flex-direction: column;
gap: 6px;
}
.big-progress-bar {
height: 22px;
background: var(--divider-color, #e0e0e0);
border-radius: 11px;
overflow: hidden;
position: relative;
}
.big-progress-fill {
height: 100%;
border-radius: 11px;
transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
overflow: hidden;
}
.big-progress-fill::after {
content: '';
position: absolute;
top: 0; left: -100%;
width: 60%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 200%; }
}
.big-progress-fill.affordable {
background: var(--success-color, #4caf50);
}
.big-progress-fill.close {
background: var(--warning-color, #ff9800);
}
.big-progress-fill.far {
background: var(--primary-color);
}
.big-progress-fill.complete {
background: var(--success-color, #4caf50);
}
.progress-stat-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.82rem;
}
.progress-have {
font-weight: 600;
color: var(--primary-text-color);
}
.progress-need {
color: var(--secondary-text-color);
}
.progress-pct {
font-weight: 700;
font-size: 0.95rem;
}
.progress-pct.affordable { color: var(--success-color, #4caf50); }
.progress-pct.close { color: var(--warning-color, #ff9800); }
.progress-pct.far { color: var(--primary-color); }
/* Can afford badge */
.can-afford-badge {
display: inline-flex;
align-items: center;
gap: 5px;
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
color: var(--success-color, #4caf50);
border-radius: 20px;
padding: 5px 12px;
font-size: 0.85rem;
font-weight: 700;
animation: pulse-green 2s ease-in-out infinite;
align-self: center;
}
.can-afford-badge ha-icon { --mdc-icon-size: 16px; }
@keyframes pulse-green {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--success-color, #4caf50) 30%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--success-color, #4caf50) 0%, transparent); }
}
/* Jackpot section */
.jackpot-badge {
display: inline-flex;
align-items: center;
gap: 5px;
background: #ca8a04;
color: var(--text-primary-color, #fff);
border-radius: 20px;
padding: 4px 12px;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
align-self: flex-start;
}
.jackpot-badge ha-icon { --mdc-icon-size: 14px; }
/* Availability badges (low stock / sold out / expiring / expired) */
.availability-badge {
display: inline-flex;
align-items: center;
border-radius: 20px;
padding: 3px 10px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: flex-start;
}
.availability-badge.badge-sold-out {
background: color-mix(in srgb, var(--secondary-text-color, #757575) 20%, transparent);
color: var(--secondary-text-color, #757575);
}
.availability-badge.badge-expired {
background: color-mix(in srgb, var(--secondary-text-color, #757575) 20%, transparent);
color: var(--secondary-text-color, #757575);
}
.availability-badge.badge-low-stock {
background: color-mix(in srgb, var(--error-color, #db4437) 18%, transparent);
color: var(--error-color, #db4437);
}
.availability-badge.badge-expiring-soon {
background: color-mix(in srgb, var(--warning-color, #ff9800) 20%, transparent);
color: var(--warning-color, #ff9800);
}
.jackpot-pool {
display: flex;
flex-direction: column;
gap: 8px;
background: color-mix(in srgb, #ca8a04 8%, transparent);
border: 1px solid color-mix(in srgb, #ca8a04 25%, transparent);
border-radius: 12px;
padding: 12px;
}
.jackpot-pool-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--secondary-text-color);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.jackpot-contributors {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.jackpot-contributor {
display: flex;
align-items: center;
gap: 6px;
background: var(--card-background-color, #fff);
border-radius: 20px;
padding: 4px 10px 4px 6px;
font-size: 0.82rem;
font-weight: 600;
color: var(--primary-text-color);
}
.jackpot-contributor .mini-avatar {
width: 22px; height: 22px;
border-radius: 50%;
background: #ca8a04;
display: flex; align-items: center; justify-content: center;
}
.jackpot-contributor .mini-avatar ha-icon { --mdc-icon-size: 13px; color: var(--text-primary-color, #fff); }
/* Error / empty */
.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, #db4437); }
.error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
/* Responsive */
@media (max-width: 480px) {
.card-content { padding: 14px 12px; }
.reward-icon-wrap { width: 70px; height: 70px; }
.reward-icon-wrap ha-icon { --mdc-icon-size: 40px; }
.reward-name { font-size: 1.3rem; }
.big-progress-bar { height: 18px; border-radius: 9px; }
.child-progress-block { padding: 12px; }
}
/* ── Designed (playroom / console / cleanpro) — shared .tmd kit comes
from taskmate-design.js styles(); only card-specific layout below. ── */
.rp-wrap { text-align: center; }
.rp-hero-emoji { font-size: 60px; line-height: 1; margin: 4px 0;
filter: drop-shadow(0 6px 10px rgba(0,0,0,.15)); }
.rp-hero-icon { width: 84px; height: 84px; border-radius: 50%; margin: 4px auto;
display: grid; place-items: center; background: var(--tmd-accent);
box-shadow: var(--tmd-shadow); }
.rp-hero-icon ha-icon { --mdc-icon-size: 46px; color: #fff; }
:host([data-tm-design="console"]) .rp-hero-emoji {
filter: drop-shadow(0 0 18px color-mix(in srgb, var(--tmd-accent) 70%, transparent)); }
.rp-name { font-size: 24px; color: var(--tmd-accent); }
.rp-desc { font-size: 13px; margin-top: 4px; max-width: 280px; margin-left: auto; margin-right: auto; }
.rp-jackpot { margin-top: 8px; background: var(--tmd-gold); color: #3a2e26;
border-color: transparent; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.04em; font-size: 11px; }
.rp-avail { margin-top: 8px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.04em; font-size: 11px; border-color: transparent; }
.rp-avail-dim { background: color-mix(in srgb, var(--tmd-dim) 20%, transparent); color: var(--tmd-dim); }
.rp-avail-bad { background: color-mix(in srgb, var(--tmd-bad) 18%, transparent); color: var(--tmd-bad); }
.rp-avail-warn { background: color-mix(in srgb, var(--tmd-warn) 20%, transparent); color: var(--tmd-warn); }
.rp-cost { margin-top: 8px; background: var(--tmd-gold); color: #3a2e26;
border-color: transparent; font-weight: 800; }
.rp-bar { height: 24px; margin-top: 16px; }
.rp-pct { color: var(--tmd-accent); }
.rp-pct-c { font-size: 38px; margin-top: 12px; }
.rp-need-c { font-weight: 800; font-size: 15px; margin-top: 2px; }
.rp-stat { justify-content: space-between; margin-top: 10px; }
.rp-stat .rp-pct { font-size: 34px; }
.rp-stat-r { text-align: right; }
.rp-frac { font-size: 18px; }
.rp-need { font-size: 12px; }
.rp-kids { grid-template-columns: repeat(var(--n, 3), 1fr); gap: 9px; margin-top: 16px; }
.rp-kid { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
border-radius: var(--tmd-radius-sm); padding: 10px; display: flex;
flex-direction: column; align-items: center; gap: 4px; }
.rp-kid .av { margin: 0 auto 2px; }
.rp-kid-pts { font-size: 20px; color: var(--tmd-accent); }
.rp-kid-pts span { font-size: 12px; margin-left: 1px; }
.rp-kid-name { font-size: 12px; font-weight: 800; }
.rp-kid-bar { width: 100%; height: 7px; margin-top: 3px; }
`;
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(this._t('reward_progress.error.entity_required'));
this.config = {
title: null,
reward_id: null,
child_id: null,
...config,
};
}
getCardSize() { return 5; }
static getConfigElement() { return document.createElement("taskmate-reward-progress-card-editor"); }
static getStubConfig() {
return { entity: "sensor.taskmate_overview", title: "Reward Goal" };
}
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`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('common.entity_not_found', { entity: this.config.entity })}</div></div></ha-card>`;
if (entity.state === "unavailable" || entity.state === "unknown") return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('reward_progress.unavailable')}</div></div></ha-card>`;
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const rewards = attrs.rewards || [];
const children = attrs.children || [];
const pointsIcon = attrs.points_icon || "mdi:star";
const pointsName = attrs.points_name || this._t("common.points");
// Pick reward
let reward = this.config.reward_id
? rewards.find(r => r.id === this.config.reward_id)
: rewards[0];
if (!reward) return html`<ha-card><div class="empty-state"><ha-icon icon="mdi:gift-outline"></ha-icon><div>${this._t('reward_progress.no_rewards')}</div></div></ha-card>`;
// Which children to show
let showChildren = children;
if (this.config.child_id) showChildren = children.filter(c => c.id === this.config.child_id);
if (reward.assigned_to?.length) showChildren = showChildren.filter(c => reward.assigned_to.includes(c.id));
if (!showChildren.length) showChildren = children;
const isJackpot = reward.is_jackpot;
const isSoldOut = reward.is_sold_out === true;
const isExpired = reward.is_expired === true;
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
? reward.days_until_expiry
: null;
let availabilityLabel = '';
let availabilityClass = '';
if (isExpired) {
availabilityLabel = this._t('rewards.expired');
availabilityClass = 'badge-expired';
} else if (isSoldOut) {
availabilityLabel = this._t('rewards.sold_out');
availabilityClass = 'badge-sold-out';
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
availabilityClass = 'badge-low-stock';
} else if (typeof daysUntilExpiry === 'number' && daysUntilExpiry >= 0 && daysUntilExpiry <= 7) {
availabilityLabel = daysUntilExpiry === 0
? this._t('rewards.expires_today')
: (daysUntilExpiry === 1
? this._t('rewards.expires_in_days', { count: daysUntilExpiry })
: this._t('rewards.expires_in_days_plural', { count: daysUntilExpiry }));
availabilityClass = 'badge-expiring-soon';
}
const headerColor = _safeColor(this.config.header_color, '');
const headerStyle = headerColor
? `--taskmate-header-bg: ${headerColor};`
: '';
return html`
<ha-card>
${headerStyle ? html`<style>:host { ${headerStyle} }</style>` : ''}
<div class="card-header">
<div class="header-content">
<ha-icon class="header-icon" icon="mdi:trophy-outline"></ha-icon>
<span class="header-title">${this.config.title || this._t('reward_progress.default_title')}</span>
</div>
</div>
<div class="card-content">
<div class="reward-hero">
<div class="reward-icon-wrap">
<ha-icon icon="${reward.icon || 'mdi:gift'}"></ha-icon>
</div>
<div class="reward-name">${reward.name}</div>
${reward.description ? html`<div class="reward-description">${reward.description}</div>` : ''}
${availabilityLabel ? html`
<div class="availability-badge ${availabilityClass}">${availabilityLabel}</div>
` : ''}
${isJackpot ? html`
<div class="jackpot-badge">
<ha-icon icon="mdi:star-shooting"></ha-icon>
${this._t('reward_progress.jackpot_reward')}
</div>
` : ''}
</div>
${isJackpot
? this._renderJackpot(reward, showChildren, pointsIcon, pointsName)
: html`
<div class="children-section">
${showChildren.map(child => this._renderChildProgress(child, reward, pointsIcon, pointsName))}
</div>
`}
</div>
</ha-card>
`;
}
_renderChildProgress(child, reward, pointsIcon, pointsName) {
const cost = reward.calculated_costs?.[child.id] ?? reward.cost;
const have = child.points || 0;
const pct = Math.min(100, Math.round((have / cost) * 100));
const canAfford = have >= cost;
const close = pct >= 70;
const cls = canAfford ? "complete" : close ? "close" : "far";
const pctCls = canAfford ? "affordable" : close ? "close" : "far";
return html`
<div class="child-progress-block">
<div class="child-progress-header">
<div class="child-progress-left">
<div class="child-avatar">
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
</div>
<div>
<div class="child-progress-name">${child.name}</div>
<div class="child-points-label">${have} ${pointsName}</div>
</div>
</div>
<div class="child-progress-cost">
<div class="cost-label">${this._t('common.goal')}</div>
<div class="cost-value">
<ha-icon icon="${pointsIcon}"></ha-icon>
${cost}
</div>
</div>
</div>
<div class="big-progress-wrap">
<div class="big-progress-bar">
<div class="big-progress-fill ${cls}" style="width: ${pct}%"></div>
</div>
<div class="progress-stat-row">
<span class="progress-have">${have} / ${cost} ${pointsName}</span>
${canAfford
? html`<span class="progress-need">${this._t('reward_progress.ready_to_claim_emoji')}</span>`
: html`<span class="progress-need">${this._t('reward_progress.more_needed', { amount: cost - have })}</span>`}
<span class="progress-pct ${pctCls}">${pct}%</span>
</div>
</div>
${canAfford ? html`
<div class="can-afford-badge">
<ha-icon icon="mdi:check-circle"></ha-icon>
${this._t('reward_progress.ready_to_claim')}
</div>
` : ''}
</div>
`;
}
_renderJackpot(reward, children, pointsIcon, pointsName) {
const cost = reward.calculated_costs
? Object.values(reward.calculated_costs)[0] ?? reward.cost
: reward.cost;
const totalHave = children.reduce((s, c) => s + (c.points || 0), 0);
const pct = Math.min(100, Math.round((totalHave / cost) * 100));
const canAfford = totalHave >= cost;
const close = pct >= 70;
const cls = canAfford ? "complete" : close ? "close" : "far";
const pctCls = canAfford ? "affordable" : close ? "close" : "far";
return html`
<div class="children-section">
<div class="child-progress-block">
<div class="jackpot-pool">
<div class="jackpot-pool-title">${this._t('reward_progress.combined_points_pool')}</div>
<div class="jackpot-contributors">
${children.map(child => html`
<div class="jackpot-contributor">
<div class="mini-avatar">
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
</div>
<span>${child.name}: ${child.points}</span>
</div>
`)}
</div>
</div>
<div class="child-progress-header" style="margin-top:4px">
<div>
<div class="child-progress-name">${this._t('reward_progress.total_pool')}</div>
<div class="child-points-label">${totalHave} ${pointsName} ${this._t('reward_progress.combined')}</div>
</div>
<div class="child-progress-cost">
<div class="cost-label">${this._t('common.goal')}</div>
<div class="cost-value">
<ha-icon icon="${pointsIcon}"></ha-icon>
${cost}
</div>
</div>
</div>
<div class="big-progress-wrap">
<div class="big-progress-bar">
<div class="big-progress-fill ${cls}" style="width: ${pct}%"></div>
</div>
<div class="progress-stat-row">
<span class="progress-have">${totalHave} / ${cost} ${pointsName}</span>
${canAfford
? html`<span class="progress-need">${this._t('reward_progress.ready')}</span>`
: html`<span class="progress-need">${this._t('reward_progress.more_needed', { amount: cost - totalHave })}</span>`}
<span class="progress-pct ${pctCls}">${pct}%</span>
</div>
</div>
${canAfford ? html`
<div class="can-afford-badge">
<ha-icon icon="mdi:check-circle"></ha-icon>
${this._t('reward_progress.ready_to_claim')}
</div>
` : ''}
</div>
</div>
`;
}
/* ══════════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
Ported from docs/design/redesigns (§card-reward-progress). Wall display
for ONE reward, FIXED cost only.
══════════════════════════════════════════════════════════════════════ */
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
_av(child, tone, size) {
const a = (child && child.avatar) || "";
const inner = a.startsWith("mdi:")
? html`<ha-icon icon="${a}"></ha-icon>`
: a
? html`<img src="${a}" alt="${child.name}">`
: ((child && child.name) || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
}
_renderDesigned(design) {
const hd = _safeColor(this.config.header_color, '#00897b');
const wrap = (body, icon) => html`
<ha-card class="tmd" style="--hd:${hd}">
<div class="tmd-hd">
<span class="ic">🎯</span>
<span class="tt">${this.config.title || this._t('reward_progress.default_title')}<small>${this._t('common.goal')}</small></span>
</div>
<div class="tmd-bd">${body}</div>
</ha-card>`;
const entity = this.hass.states[this.config.entity];
if (!entity) return wrap(html`<div class="tmd-empty">${this._t('common.entity_not_found', { entity: this.config.entity })}</div>`);
if (entity.state === "unavailable" || entity.state === "unknown")
return wrap(html`<div class="tmd-empty">${this._t('reward_progress.unavailable')}</div>`);
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const rewards = attrs.rewards || [];
const children = attrs.children || [];
const pointsName = attrs.points_name || this._t("common.points");
let reward = this.config.reward_id
? rewards.find(r => r.id === this.config.reward_id)
: rewards[0];
if (!reward) return wrap(html`<div class="tmd-empty">${this._t('reward_progress.no_rewards')}</div>`);
let showChildren = children;
if (this.config.child_id) showChildren = children.filter(c => c.id === this.config.child_id);
if (reward.assigned_to?.length) showChildren = showChildren.filter(c => reward.assigned_to.includes(c.id));
if (!showChildren.length) showChildren = children;
const isJackpot = reward.is_jackpot;
let cost, have, contributors;
if (isJackpot) {
cost = reward.calculated_costs ? (Object.values(reward.calculated_costs)[0] ?? reward.cost) : reward.cost;
have = showChildren.reduce((s, c) => s + (c.points || 0), 0);
contributors = showChildren.map((c, i) => ({ child: c, points: c.points || 0, tone: this._designTone(i) }));
} else {
const ref = showChildren[0];
cost = (ref && reward.calculated_costs?.[ref.id]) ?? reward.cost;
contributors = showChildren.map((c, i) => ({
child: c, points: c.points || 0, tone: this._designTone(i),
cost: reward.calculated_costs?.[c.id] ?? reward.cost,
}));
have = contributors.length === 1 ? contributors[0].points
: contributors.reduce((s, c) => s + c.points, 0);
if (contributors.length === 1) cost = contributors[0].cost;
}
const pct = cost > 0 ? Math.min(100, Math.round((have / cost) * 100)) : 0;
const remaining = Math.max(0, cost - have);
// Availability badge — same precedence as the classic render.
const isSoldOut = reward.is_sold_out === true;
const isExpired = reward.is_expired === true;
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number') ? reward.days_until_expiry : null;
let availabilityLabel = '';
let availabilityTone = '';
if (isExpired) {
availabilityLabel = this._t('rewards.expired');
availabilityTone = 'dim';
} else if (isSoldOut) {
availabilityLabel = this._t('rewards.sold_out');
availabilityTone = 'dim';
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
availabilityTone = 'bad';
} else if (typeof daysUntilExpiry === 'number' && daysUntilExpiry >= 0 && daysUntilExpiry <= 7) {
availabilityLabel = daysUntilExpiry === 0
? this._t('rewards.expires_today')
: (daysUntilExpiry === 1
? this._t('rewards.expires_in_days', { count: daysUntilExpiry })
: this._t('rewards.expires_in_days_plural', { count: daysUntilExpiry }));
availabilityTone = 'warn';
}
return wrap(html`${this._designBody(reward, design, cost, have, pct, remaining, contributors, pointsName, availabilityLabel, availabilityTone)}`);
}
_designBody(reward, design, cost, have, pct, remaining, contributors, pointsName, availabilityLabel, availabilityTone) {
const icon = reward.icon || "mdi:gift";
const isIcon = typeof icon === "string" && icon.startsWith("mdi:");
const hero = isIcon
? html`<div class="rp-hero-icon"><ha-icon icon="${icon}"></ha-icon></div>`
: html`<div class="rp-hero-emoji">${icon}</div>`;
return html`
<div class="rp-wrap">
${hero}
<div class="big rp-name">${reward.name}</div>
${reward.description ? html`<div class="muted rp-desc">${reward.description}</div>` : ''}
${reward.is_jackpot ? html`<div class="chip rp-jackpot">🎰 ${this._t('reward_progress.jackpot_reward')}</div>` : ''}
${availabilityLabel ? html`<div class="chip rp-avail rp-avail-${availabilityTone}">${availabilityLabel}</div>` : ''}
<div class="chip rp-cost">⭐ ${cost} ${pointsName}</div>
<div class="bar rp-bar"><i style="width:${pct}%;${pct >= 100 ? 'background:var(--tmd-good)' : ''}"></i></div>
${design === "cleanpro"
? html`<div class="row rp-stat">
<div class="big rp-pct">${pct}%</div>
<div class="rp-stat-r">
<div class="num rp-frac">${have} / ${cost}</div>
<div class="muted rp-need">${this._t('reward_progress.more_needed', { amount: remaining })}</div>
</div>
</div>`
: html`
<div class="big rp-pct rp-pct-c">${pct}%</div>
<div class="rp-need rp-need-c">${remaining > 0
? this._t('reward_progress.more_needed', { amount: remaining })
: this._t('reward_progress.ready_to_claim')}</div>`}
<div class="grid rp-kids" style="--n:${contributors.length}">
${contributors.map((c) => html`
<div class="rp-kid" style="--ac:${c.tone}">
${this._av(c.child, c.tone, 36)}
<div class="big rp-kid-pts">${c.points}<span>⭐</span></div>
<div class="rp-kid-name">${c.child.name}</div>
${design === "cleanpro" && !reward.is_jackpot && c.cost > 0
? html`<div class="bar rp-kid-bar"><i style="width:${Math.min(100, Math.round((c.points / c.cost) * 100))}%;background:${c.tone}"></i></div>` : ''}
</div>`)}
</div>
</div>`;
}
}
class TaskMateRewardProgressCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
static get styles() {
return css`
:host { display: block; }
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; }
_buildSchema() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || (this.config?.entity ? this.hass?.states?.[this.config.entity]?.attributes : null) || {};
const rewards = attrs.rewards || [];
const children = attrs.children || [];
return [
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'title', selector: { text: {} } },
{
name: 'reward_id',
selector: {
select: {
options: [
{ value: '', label: this._t('reward_progress.editor.first_available') },
...rewards.map((r) => ({ value: r.id, label: r.name })),
],
mode: 'dropdown',
},
},
},
{
name: 'child_id',
selector: {
select: {
options: [
{ value: '', label: this._t('reward_progress.editor.child_filter_all') },
...children.map((c) => ({ value: c.id, label: c.name })),
],
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',
},
},
},
];
}
_computeLabel = (entry) => {
const labels = {
entity: this._t('common.editor.overview_entity'),
title: this._t('common.editor.card_title'),
reward_id: this._t('reward_progress.editor.reward_to_display'),
child_id: this._t('common.editor.filter_by_child'),
card_design: this._t('common.design.field_label'),
};
return labels[entry.name] ?? entry.name;
};
_computeHelper = (entry) => {
const helpers = {
entity: this._t('common.editor.overview_entity_helper'),
reward_id: this._t('reward_progress.editor.reward_helper'),
child_id: this._t('reward_progress.editor.child_helper'),
};
return helpers[entry.name] ?? '';
};
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || '',
title: this.config.title || '',
reward_id: this.config.reward_id || '',
child_id: this.config.child_id || '',
card_design: this.config.card_design || 'global',
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker('header_color', '')}
`;
}
_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 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 };
if (!value) delete cfg[key];
this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: cfg }, bubbles: true, composed: true }));
}
}
customElements.define("taskmate-reward-progress-card", TaskMateRewardProgressCard);
customElements.define("taskmate-reward-progress-card-editor", TaskMateRewardProgressCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-reward-progress-card",
name: "TaskMate Reward Progress",
description: "Full-screen motivational reward progress display",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-reward-progress-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-reward-progress-card.js"]'))
.map(s => s.src.split("?")[1]).find(Boolean) || ""
).get("v") || "?";
console.info(
"%c TASKMATE REWARD PROGRESS CARD %c v" + _tmVersion + " ",
"background:#03a9f4;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;"
);