999 lines
38 KiB
JavaScript
999 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;
|
|
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-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 (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;"
|
|
);
|