Files
HomeAssistantVS/custom_components/taskmate/www/taskmate-reward-progress-card.js
T

1020 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
flex-wrap: wrap;
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-time-locked {
background: color-mix(in srgb, var(--info-color, #3498db) 18%, transparent);
color: var(--info-color, #2471a3);
/* Longer than the other badges — uppercasing a whole window reads badly. */
text-transform: none;
letter-spacing: 0;
}
.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; }
@media (max-width: 430px) { .rp-kids { grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); } }
.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 (reward.is_time_locked === true) {
availabilityLabel = this._timeLockLabel(reward);
availabilityClass = 'badge-time-locked';
} 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>
`;
}
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
_timeLockLabel(reward) {
const summary = window.__taskmate_time_lock_label
? window.__taskmate_time_lock_label(reward.time_lock, (k, pr) => this._t(k, pr))
: '';
return summary ? this._t('rewards.available_when', { window: summary }) : this._t('rewards.locked_now');
}
_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) => 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 (reward.is_time_locked === true) {
availabilityLabel = this._timeLockLabel(reward);
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;"
);