/**
* TaskMate Rewards Card
* A Lovelace card for displaying available rewards.
* Shows rewards in a vertical list with star cost, name, description, and progress gauges.
* Supports regular rewards, Jackpot rewards, and (v3.0+) Pool Mode "savings jar" rewards.
*
* Version: 0.1.3
* Last Updated: 2026-04-17
*/
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 TaskMateRewardsCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_loading: { type: Object },
_selectedChildId: { type: String },
_pendingClaim: { type: Object },
};
}
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
return window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
}
return true;
}
constructor() {
super();
this._loading = {};
this._selectedChildId = null;
this._pendingClaim = null;
}
_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;
--reward-purple: #9b59b6;
--reward-purple-light: #a569bd;
--reward-gold: #f1c40f;
--reward-gold-dark: #d4a80a;
--text-primary: var(--primary-text-color, #212121);
--text-secondary: var(--secondary-text-color, #757575);
--card-bg: var(--card-background-color, #fff);
--divider: var(--divider-color, #e0e0e0);
}
ha-card {
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--taskmate-header-bg, #e67e22);
color: white;
}
.header-content {
display: flex;
align-items: center;
gap: 12px;
}
.header-icon {
--mdc-icon-size: 32px;
opacity: 0.95;
}
.header-title {
font-size: 1.3rem;
font-weight: 600;
}
.reward-count {
background: rgba(255, 255, 255, 0.2);
padding: 4px 12px;
border-radius: 16px;
font-size: 0.9rem;
font-weight: 500;
}
/* ── Child tabs (shown when no child_id is pinned and 2+ children) ── */
.child-tabs {
display: flex;
gap: 6px;
padding: 12px 16px 0;
overflow-x: auto;
scrollbar-width: none;
}
.child-tabs::-webkit-scrollbar { display: none; }
.child-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
background: var(--divider, rgba(0, 0, 0, 0.08));
border: 2px solid transparent;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
color: var(--text-secondary, #666);
transition: all 0.15s;
}
.child-tab ha-icon { --mdc-icon-size: 18px; }
.child-tab.selected {
background: color-mix(in srgb, var(--taskmate-header-bg, #e67e22) 16%, transparent);
border-color: var(--taskmate-header-bg, #e67e22);
color: var(--taskmate-header-bg, #e67e22);
}
.card-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Individual reward row */
.reward-row {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: var(--card-bg);
border: 1px solid var(--divider);
border-radius: 12px;
transition: box-shadow 0.2s ease, transform 0.15s ease;
}
.reward-row:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
/* Cost badge - prominently displayed */
.cost-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 70px;
padding: 12px 8px;
background: linear-gradient(135deg, var(--reward-gold) 0%, var(--reward-gold-dark) 100%);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(241, 196, 15, 0.3);
flex-shrink: 0;
}
.cost-badge ha-icon {
--mdc-icon-size: 24px;
color: white;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
margin-bottom: 4px;
}
.cost-value {
font-size: 1.4rem;
font-weight: 700;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
line-height: 1;
}
.cost-label {
font-size: 0.65rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
/* Reward details */
.reward-details {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0;
padding-top: 4px;
}
.reward-name {
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
}
.reward-description {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.4;
}
/* Child assignment indicator */
.assigned-children {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.child-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: rgba(155, 89, 182, 0.15);
border-radius: 12px;
font-size: 0.75rem;
color: var(--reward-purple);
font-weight: 500;
}
.child-badge.all-children {
background: rgba(46, 204, 113, 0.15);
color: #27ae60;
}
/* Progress bar styles */
.progress-section {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
width: 100%;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.progress-bar {
flex: 1;
height: 14px;
background: var(--divider-color, #e0e0e0);
border-radius: 7px;
overflow: hidden;
position: relative;
border: 2px solid rgba(52, 152, 219, 0.4);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.progress-fill {
height: 100%;
border-radius: 7px;
background: linear-gradient(90deg, #3498db 0%, #2ecc71 100%);
transition: width 0.4s ease;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 200%; }
}
.progress-text {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
min-width: 0;
text-align: right;
}
/* Jackpot reward styles */
.reward-row.jackpot {
border: 2px solid var(--reward-gold);
background: linear-gradient(135deg, rgba(241, 196, 15, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
position: relative;
overflow: hidden;
}
.reward-row.jackpot::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
45deg,
transparent 40%,
rgba(241, 196, 15, 0.1) 50%,
transparent 60%
);
animation: jackpot-shine 40s ease-in-out infinite;
pointer-events: none;
}
/* Shine 3 times (each ~2.5s = 7.5s total), then wait ~32.5s before repeating */
@keyframes jackpot-shine {
0% { transform: translateX(-100%); }
2.5% { transform: translateX(100%); }
5% { transform: translateX(-100%); }
7.5% { transform: translateX(100%); }
10% { transform: translateX(-100%); }
12.5% { transform: translateX(100%); }
15%, 100% { transform: translateX(-100%); opacity: 0; }
}
.jackpot-label {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: linear-gradient(135deg, var(--reward-gold) 0%, #e67e22 100%);
border-radius: 12px;
font-size: 0.7rem;
font-weight: 700;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
box-shadow: 0 2px 6px rgba(241, 196, 15, 0.4);
}
.jackpot-label span {
font-size: 0.8rem;
}
/* Multi-child progress bar for jackpot */
.jackpot-progress-bar {
flex: 1;
height: 18px;
background: var(--divider-color, #e0e0e0);
border-radius: 9px;
overflow: hidden;
position: relative;
display: flex;
border: 2px solid rgba(241, 196, 15, 0.5);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.jackpot-segment {
height: 100%;
transition: width 0.4s ease;
position: relative;
overflow: hidden;
}
.jackpot-segment::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: jackpot-segment-shimmer 40s infinite;
}
/* Shimmer 3 times then wait ~30s before repeating */
@keyframes jackpot-segment-shimmer {
0% { left: -100%; }
2.5% { left: 200%; }
5% { left: -100%; }
7.5% { left: 200%; }
10% { left: -100%; }
12.5% { left: 200%; }
15%, 100% { left: -100%; opacity: 0; }
}
.jackpot-segment:first-child {
border-radius: 9px 0 0 9px;
}
.jackpot-segment:last-child {
border-radius: 0 9px 9px 0;
}
.jackpot-segment:only-child {
border-radius: 9px;
}
/* Fun kid-friendly colors for segments */
.jackpot-segment.color-0 { background: linear-gradient(180deg, #ff6b9d 0%, #e91e63 100%); }
.jackpot-segment.color-1 { background: linear-gradient(180deg, #64b5f6 0%, #2196f3 100%); }
.jackpot-segment.color-2 { background: linear-gradient(180deg, #81c784 0%, #4caf50 100%); }
.jackpot-segment.color-3 { background: linear-gradient(180deg, #ffb74d 0%, #ff9800 100%); }
.jackpot-segment.color-4 { background: linear-gradient(180deg, #ba68c8 0%, #9c27b0 100%); }
.jackpot-segment.color-5 { background: linear-gradient(180deg, #4dd0e1 0%, #00bcd4 100%); }
.jackpot-breakdown {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.jackpot-child-contribution {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
background: color-mix(in srgb, var(--primary-text-color, #212121) 8%, transparent);
border-radius: 10px;
font-weight: 500;
}
.jackpot-child-contribution .color-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.jackpot-child-contribution ha-icon {
--mdc-icon-size: 13px;
vertical-align: middle;
position: relative;
top: -1px;
}
.jackpot-child-contribution .color-dot.color-0 { background: #e91e63; }
.jackpot-child-contribution .color-dot.color-1 { background: #2196f3; }
.jackpot-child-contribution .color-dot.color-2 { background: #4caf50; }
.jackpot-child-contribution .color-dot.color-3 { background: #ff9800; }
.jackpot-child-contribution .color-dot.color-4 { background: #9c27b0; }
.jackpot-child-contribution .color-dot.color-5 { background: #00bcd4; }
.jackpot-total {
font-weight: 700;
color: var(--reward-gold-dark);
padding: 2px 8px;
background: rgba(241, 196, 15, 0.15);
border-radius: 10px;
}
/* Cost badge for jackpot - special styling */
.reward-row.jackpot .cost-badge {
background: linear-gradient(135deg, var(--reward-gold) 0%, #e67e22 100%);
box-shadow: 0 3px 10px rgba(241, 196, 15, 0.4);
}
/* Reward icon + claim button wrapper */
/* Child picker shown when no child_id configured */
.child-picker-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: var(--secondary-background-color, #f5f5f5);
border-bottom: 1px solid var(--divider-color, #e0e0e0);
}
.child-picker-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--secondary-text-color);
white-space: nowrap;
}
.child-picker-select {
flex: 1;
padding: 5px 8px;
border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 6px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 0.85rem;
cursor: pointer;
}
.reward-right-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.reward-icon-container {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, var(--reward-purple) 0%, var(--reward-purple-light) 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.reward-icon-container ha-icon {
--mdc-icon-size: 24px;
color: white;
}
/* Pending approval state */
.reward-row.pending-approval {
opacity: 0.6;
border-left: 3px solid #e67e22;
}
.pending-label {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(230,126,34,0.12);
color: #e67e22;
border-radius: 8px;
padding: 3px 8px;
font-size: 0.75rem;
font-weight: 600;
margin-top: 4px;
}
.pending-label ha-icon { --mdc-icon-size: 12px; }
/* Availability badges (low stock / sold out / expiring / expired) */
.availability-badges { display: inline-flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.availability-badge {
display: inline-flex;
align-items: center;
border-radius: 8px;
padding: 3px 8px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.availability-badge.badge-sold-out {
background: rgba(189,195,199,0.25);
color: var(--secondary-text-color, #757575);
}
.availability-badge.badge-expired {
background: color-mix(in srgb, var(--secondary-text-color, #757575) 18%, transparent);
color: var(--secondary-text-color, #757575);
}
.availability-badge.badge-low-stock {
background: rgba(231,76,60,0.15);
color: #c0392b;
}
.availability-badge.badge-expiring-soon {
background: rgba(243,156,18,0.18);
color: #d35400;
}
.reward-row.unavailable { opacity: 0.55; }
/* Claim button */
.claim-btn {
width: 42px; height: 42px;
border-radius: 50%; border: none; cursor: pointer;
background: linear-gradient(135deg, #9b59b6, #8e44ad);
color: white;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 3px 10px rgba(155,89,182,0.35);
transition: transform 0.15s, box-shadow 0.15s;
flex-shrink: 0;
}
.claim-btn:hover { transform: scale(1.08); box-shadow: 0 4px 14px rgba(155,89,182,0.45); }
.claim-btn:active { transform: scale(0.96); }
.claim-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.claim-btn ha-icon { --mdc-icon-size: 22px; }
.claim-btn.cant-afford {
background: linear-gradient(135deg, #bdc3c7, #95a5a6);
box-shadow: none;
cursor: not-allowed;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--text-secondary);
text-align: center;
}
.empty-state ha-icon {
--mdc-icon-size: 56px;
margin-bottom: 16px;
opacity: 0.5;
color: var(--reward-purple);
}
.empty-state .message {
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 4px;
color: var(--text-primary);
}
.empty-state .submessage {
font-size: 0.9rem;
opacity: 0.8;
}
/* Error state */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--error-color, #f44336);
text-align: center;
}
.error-state ha-icon {
--mdc-icon-size: 48px;
margin-bottom: 16px;
}
/* Responsive adjustments */
@media (max-width: 430px) {
.card-header {
padding: 14px 18px;
}
.header-title {
font-size: 1.15rem;
}
.card-content {
padding: 12px;
gap: 10px;
}
.reward-row {
padding: 12px;
gap: 12px;
}
.cost-badge {
min-width: 60px;
padding: 10px 6px;
}
.cost-badge ha-icon {
--mdc-icon-size: 20px;
}
.cost-value {
font-size: 1.2rem;
}
.reward-name {
font-size: 1.05rem;
}
.reward-description {
font-size: 0.85rem;
}
.reward-icon-container {
width: 38px;
height: 38px;
}
.reward-icon-container ha-icon {
--mdc-icon-size: 20px;
}
}
/* v3.0 Pool Mode styles */
.spendable-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin: 8px 14px 0 14px;
background: linear-gradient(135deg, rgba(52, 152, 219, 0.15), rgba(46, 204, 113, 0.12));
border-radius: 10px;
border: 1px solid rgba(52, 152, 219, 0.25);
}
.spendable-banner ha-icon {
color: var(--reward-purple);
--mdc-icon-size: 22px;
}
.spendable-banner .spendable-label {
color: var(--text-secondary);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.spendable-banner .spendable-value {
font-weight: 700;
font-size: 1.15rem;
color: var(--text-primary);
margin-left: auto;
}
.spendable-banner .spendable-of {
font-weight: 400;
color: var(--text-secondary);
font-size: 0.82rem;
margin-left: 4px;
}
/* Pool Mode controls — render BELOW the progress bar, full width */
.pool-controls {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-start;
margin-top: 4px;
}
.pool-controls-redeem {
justify-content: flex-start;
}
.alloc-btn {
flex: 1 1 0;
min-width: 56px;
padding: 7px 10px;
border-radius: 8px;
border: none;
cursor: pointer;
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
font-weight: 700;
font-size: 0.9rem;
transition: transform 0.1s, opacity 0.1s, box-shadow 0.15s;
box-shadow: 0 1px 3px rgba(52, 152, 219, 0.3);
}
.alloc-btn:hover:not(:disabled) {
transform: scale(1.04);
box-shadow: 0 2px 6px rgba(52, 152, 219, 0.5);
}
.alloc-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Custom-amount deposit (#559): sits beside the quick buttons, wraps
below them on narrow widths. */
.deposit-custom {
display: flex;
align-items: stretch;
gap: 6px;
margin-left: auto;
flex: 0 0 auto;
}
.deposit-custom input {
width: 72px;
min-width: 0;
padding: 7px 8px;
border-radius: 8px;
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.18));
background: var(--card-background-color, #fff);
color: var(--primary-text-color, #111);
font-size: 0.9rem;
font-weight: 600;
text-align: center;
}
.deposit-custom input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.deposit-custom .alloc-btn.deposit-add {
flex: 0 0 auto;
min-width: 0;
}
/* Hide the number spinners for a cleaner look */
.deposit-custom input::-webkit-outer-spin-button,
.deposit-custom input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.deposit-custom input[type="number"] { -moz-appearance: textfield; }
.redeem-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1 1 auto;
padding: 9px 18px;
border-radius: 20px;
border: none;
cursor: pointer;
background: linear-gradient(135deg, #2ecc71, #27ae60);
color: white;
font-weight: 700;
font-size: 0.95rem;
box-shadow: 0 3px 10px rgba(46, 204, 113, 0.35);
animation: pulse-green 1.6s ease-in-out infinite;
}
.redeem-btn ha-icon {
--mdc-icon-size: 18px;
}
@keyframes pulse-green {
0%, 100% { box-shadow: 0 3px 10px rgba(46, 204, 113, 0.35); transform: scale(1); }
50% { box-shadow: 0 3px 18px rgba(46, 204, 113, 0.65); transform: scale(1.02); }
}
.pool-locked-note {
font-size: 0.72rem;
color: var(--text-secondary);
font-style: italic;
margin-top: 4px;
}
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.confirm-dialog {
background: var(--card-background-color, #fff);
border-radius: 16px;
padding: 24px;
max-width: 320px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 16px;
}
.confirm-dialog .dialog-icon {
display: flex;
justify-content: center;
--mdc-icon-size: 48px;
color: var(--reward-purple);
}
.confirm-dialog .dialog-title {
font-size: 1.15rem;
font-weight: 600;
color: var(--primary-text-color);
text-align: center;
}
.confirm-dialog .dialog-body {
font-size: 0.9rem;
color: var(--secondary-text-color);
text-align: center;
line-height: 1.5;
}
.confirm-dialog .dialog-buttons {
display: flex;
gap: 10px;
}
.confirm-dialog button {
flex: 1;
padding: 10px;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.confirm-dialog button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.confirm-dialog button:active {
transform: scale(0.97);
}
.confirm-dialog .btn-cancel {
background: var(--secondary-background-color, #f5f5f5);
color: var(--primary-text-color);
}
.confirm-dialog .btn-confirm {
background: linear-gradient(135deg, var(--reward-purple) 0%, var(--reward-purple-light) 100%);
color: white;
}
.dialog-points-summary {
background: var(--secondary-background-color, #f5f5f5);
border-radius: 8px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.9rem;
text-align: left;
}
.dialog-points-row {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--primary-text-color);
}
.dialog-points-row.cost {
color: var(--secondary-text-color);
border-top: 1px solid var(--divider-color, #e0e0e0);
padding-top: 6px;
}
.dialog-points-row.remaining {
font-weight: 700;
border-top: 1px solid var(--divider-color, #e0e0e0);
padding-top: 6px;
color: var(--reward-purple);
}
.points-val {
display: flex;
align-items: center;
gap: 2px;
font-weight: 600;
}
/* ── Designed (playroom / console / cleanpro) — shared .tmd kit comes
from taskmate-design.js styles(); only card-specific layout below. ── */
.rw-list { display: flex; flex-direction: column; gap: 11px; }
/* #604: default is fluid — the list grows to fit every reward. Opt into a
fixed 360px scroll region with expand_to_fit: false. */
.rw-list.rw-scroll { max-height: 360px; overflow-y: auto; }
.rw-jackpot-label { align-self: flex-start; background: var(--tmd-gold); color: #3a2e26;
border-color: transparent; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.04em; font-size: 10.5px; margin-bottom: 2px; }
.rw-badges { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.rw-badge { font-size: 11px; padding: 2px 8px; }
.rw-badge-all { background: color-mix(in srgb, var(--tmd-good) 16%, transparent);
color: var(--tmd-good); border-color: transparent; }
.rw-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 2px; }
.rw-sel { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
border-radius: var(--tmd-radius-sm); padding: 9px 12px; }
.rw-sel-name { flex: 1; min-width: 0; font-weight: 800; }
.rw-card { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
border-radius: var(--tmd-radius-sm); padding: 13px; display: flex;
flex-direction: column; gap: 9px; }
.rw-card.rw-unavail { opacity: 0.55; }
.rw-jackpot { border: 2px dashed var(--tmd-accent);
background: color-mix(in srgb, var(--tmd-accent) 9%, var(--tmd-surface-2)); }
.rw-top { align-items: flex-start; }
.rw-emoji { font-size: 28px; line-height: 1; flex: none; }
.rw-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.rw-name { font-weight: 800; font-size: 15px; }
.rw-desc { font-size: 12px; }
.rw-avail { align-self: flex-start; font-size: 11px; }
.rw-avail-warn { background: color-mix(in srgb, var(--tmd-warn) 16%, transparent);
color: var(--tmd-warn); }
.rw-avail-bad { background: color-mix(in srgb, var(--tmd-bad) 16%, transparent);
color: var(--tmd-bad); }
.rw-cost { flex: none; align-self: flex-start; background: var(--tmd-gold);
color: #3a2e26; border-color: transparent; font-weight: 800; }
.rw-pool-bar { height: 14px; display: flex; gap: 0; padding: 0; }
.rw-pool-bar > i { border-radius: 0; }
.rw-pool-foot { flex-wrap: wrap; gap: 6px; }
.rw-contrib { gap: 5px; font-size: 11px; }
.rw-foot { gap: 8px; }
.rw-status { font-size: 12px; }
.rw-status-good { color: var(--tmd-good); font-weight: 800; }
.rw-pool-btns { gap: 6px; flex-wrap: wrap; align-items: center; }
.rw-pool-btns .deposit-custom { margin-left: auto; gap: 6px; }
.rw-deposit-input {
width: 70px;
min-width: 0;
padding: 7px 8px;
border-radius: var(--tmd-radius-sm);
border: 1px solid var(--tmd-border);
background: var(--tmd-surface);
color: var(--tmd-text);
font-size: 13px;
font-weight: 700;
text-align: center;
}
.rw-deposit-input:disabled { opacity: 0.4; cursor: not-allowed; }
.rw-deposit-input::-webkit-outer-spin-button,
.rw-deposit-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.rw-deposit-input[type="number"] { -moz-appearance: textfield; }
`;
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('rewards.error.entity_required'));
}
this.config = {
title: null,
child_id: null, // Optional: filter rewards for a specific child
show_child_badges: true, // Show which children can claim each reward
enable_pool_mode: false, // v3.0: "savings jar" allocation mode (opt-in per card)
expand_to_fit: true, // #604: grow the card to fit all rewards (designed styles). false = fixed 360px scroll.
deposit_amounts: [1, 5, 10], // #559: quick-deposit button amounts for pool/jackpot rewards
header_color: '#e67e22',
...config,
};
}
getCardSize() {
return 3;
}
static getConfigElement() {
return document.createElement("taskmate-rewards-card-editor");
}
static getStubConfig() {
return {
entity: "sensor.taskmate_overview",
title: "Rewards",
child_id: null,
show_child_badges: true,
enable_pool_mode: false,
};
}
willUpdate() {
// #547: when the card isn't pinned to a child via config.child_id, default the
// in-card selection to the first child so the claim button renders (and the
// child tabs have a selection). Without this, childId stays null and no claim
// button is ever shown, making rewards impossible to claim.
if (this.hass && this.config && !this.config.child_id && !this._selectedChildId) {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|| this.hass.states?.[this.config.entity]?.attributes || {};
const kids = attrs.children || [];
if (kids.length) this._selectedChildId = kids[0].id;
}
}
_selectChild(id) {
this._selectedChildId = id;
}
// Classic child picker — shown only when no child_id is pinned and 2+ children.
_renderChildTabs(children) {
if (this.config.child_id || children.length <= 1) return html``;
const activeId = this._selectedChildId || children[0]?.id;
return html`
${children.map((c) => html`
this._selectChild(c.id)}>
${c.name}
`)}
`;
}
// Designed-style child picker (reuses the .btn sm button styling).
_designChildTabs(children) {
if (this.config.child_id || children.length <= 1) return html``;
const activeId = this._selectedChildId || children[0]?.id;
return html`
${children.map((c) => html`
this._selectChild(c.id)}>${c.name}
`)}
`;
}
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`
${this._t('common.entity_not_found', { entity: this.config.entity })}
`;
}
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const allRewards = attrs.rewards || [];
const children = attrs.children || [];
const pointsIcon = attrs.points_icon || "mdi:star";
const pointsName = attrs.points_name || this._t("common.stars");
// Filter rewards based on child_id if configured
let rewards = allRewards;
if (this.config.child_id) {
rewards = allRewards.filter((reward) => {
const assignedTo = reward.assigned_to || [];
// Show reward if assigned_to is empty (available to all) OR contains the child_id
return assignedTo.length === 0 || assignedTo.includes(this.config.child_id);
});
}
// Sort rewards: jackpot first, then by calculated cost ascending within each group
const sortedRewards = [...rewards].sort((a, b) => {
// Jackpot rewards come first
if (a.is_jackpot && !b.is_jackpot) return -1;
if (!a.is_jackpot && b.is_jackpot) return 1;
// Within same type, sort by calculated cost ascending
const aCost = this._getDisplayCost(a, children);
const bCost = this._getDisplayCost(b, children);
return aCost - bCost;
});
// Create a map of child IDs to names for badge display
const childMap = {};
children.forEach((child) => {
childMap[child.id] = child.name;
});
// Card-level override: when true, every reward on this card is treated as a pool reward.
// Otherwise each reward decides for itself via reward.pool_enabled.
const cardForcesPool = this.config.enable_pool_mode === true;
const activeChildId = this.config.child_id || this._selectedChildId;
const activeChild = activeChildId ? children.find((c) => c.id === activeChildId) : null;
// Show the spendable banner when any visible reward is in pool mode
const anyPoolReward = sortedRewards.some(
(r) => cardForcesPool || r.pool_enabled === true || r.is_jackpot
);
return html`
${this._renderChildTabs(children)}
${anyPoolReward && activeChild ? this._renderSpendableBanner(activeChild, pointsIcon, pointsName) : ''}
${sortedRewards.length === 0
? this._renderEmptyState()
: sortedRewards.map((reward) => this._renderRewardRow(reward, pointsIcon, pointsName, childMap, children))}
${this._pendingClaim ? html`
e.stopPropagation()}">
${this._t('rewards.confirm_title')}
${(() => {
const r = this._pendingClaim;
const child = r._child;
const cost = r.cost;
// Pool/jackpot redemptions spend POOLED points, not the claiming
// child's wallet. Showing the child's balance vs the full cost
// produced a nonsensical negative "Remaining" (#557). Use the pool
// total (jackpot: summed across all children; savings-jar: this
// child's allocation) so the calculation reflects the pooled points.
const isJackpot = r.is_jackpot === true;
const isPoolRedeem = isJackpot || r.pool_enabled === true || this.config.enable_pool_mode === true;
let currentPoints;
let availableLabel;
if (isPoolRedeem) {
const poolAllocations = r.pool_allocations || {};
currentPoints = isJackpot
? (typeof r.jackpot_pool_total === 'number' ? r.jackpot_pool_total : 0)
: (child ? (poolAllocations[child.id] || 0) : 0);
availableLabel = this._t('rewards.confirm_pool_total');
} else {
currentPoints = child ? (typeof child.spendable_balance === 'number' ? child.spendable_balance : (child.points || 0)) : null;
availableLabel = this._t('rewards.confirm_your_points', { name: pointsName });
}
const remaining = currentPoints !== null ? currentPoints - cost : null;
return html`
${this._t('rewards.confirm_body', { name: r.name })}
${currentPoints !== null ? html`
${availableLabel}
${currentPoints}
${this._t('rewards.confirm_cost')}
−${cost}
${this._t('rewards.confirm_remaining')}
${remaining}
` : ''}
`;
})()}
${this._t('common.cancel')}
${this._t('rewards.confirm_claim_btn')}
` : ""}
`;
}
_renderSpendableBanner(child, pointsIcon, pointsName) {
const spendable = typeof child.spendable_balance === 'number' ? child.spendable_balance : (child.points || 0);
const gross = child.points || 0;
// Only show "/ gross" when points are reserved by a pending claim.
const detail = spendable !== gross
? html`/ ${gross} ${pointsName} `
: html`${pointsName} `;
return html`
${this._t('rewards.spendable_balance')}
${spendable}
${detail}
`;
}
_renderEmptyState() {
return html`
${this._t('rewards.empty_title')}
${this._t('rewards.empty_subtitle')}
`;
}
_renderRewardRow(reward, pointsIcon, pointsName, childMap, children) {
const rewardIcon = reward.icon || "mdi:gift";
const hasDescription = reward.description && reward.description.trim().length > 0;
const assignedTo = reward.assigned_to || [];
const showChildBadges = this.config.show_child_badges !== false;
const isJackpot = reward.is_jackpot || false;
// Pool mode is enabled per-reward (reward.pool_enabled) OR forced on via card config.
const cardForcesPool = this.config.enable_pool_mode === true;
// Jackpot rewards are always pool-mode (#552): everyone deposits into the
// shared jar and it's redeemed when full. Combined points only work via the
// pool, so jackpots never show the single-child claim button.
const enablePoolMode = cardForcesPool || reward.pool_enabled === true || isJackpot;
// All costs are static — just use reward.cost
const displayCost = this._getDisplayCost(reward, children);
// Get relevant children for this reward
const relevantChildren = assignedTo.length === 0
? children
: children.filter(c => assignedTo.includes(c.id));
// Determine active child context
const childId = this.config?.child_id || this._selectedChildId;
const relevantChild = childId
? children.find(c => c.id === childId)
: relevantChildren[0];
// Pool allocations for this reward (v3.0). Falls back to empty dict in wallet mode.
const poolAllocations = reward.pool_allocations || {};
const jackpotPoolTotal = typeof reward.jackpot_pool_total === 'number' ? reward.jackpot_pool_total : 0;
// Calculate progress
let currentStars = 0;
let childContributions = [];
if (isJackpot) {
const sharePerChild = relevantChildren.length > 0
? Math.round(displayCost / relevantChildren.length)
: displayCost;
relevantChildren.forEach((child, index) => {
// In pool mode, show allocated points only; in wallet mode show wallet points
const points = enablePoolMode
? (poolAllocations[child.id] || 0)
: (child.points || 0);
const shareOfGoal = relevantChildren.length > 0 ? (100 / relevantChildren.length) : 100;
const weightedProgress = sharePerChild > 0 ? Math.min((points / sharePerChild) * 100, 100) : 0;
currentStars += points;
childContributions.push({
name: child.name,
points: points,
colorIndex: index % 6,
expectedContribution: sharePerChild,
weightedProgress: weightedProgress,
shareOfGoal: shareOfGoal,
});
});
} else {
if (enablePoolMode) {
// Pool mode: show this child's allocation for this reward
currentStars = childId ? (poolAllocations[childId] || 0) : 0;
} else if (this.config.child_id) {
const filteredChild = children.find(c => c.id === this.config.child_id);
currentStars = filteredChild ? (filteredChild.points || 0) : 0;
} else if (relevantChildren.length > 0) {
currentStars = relevantChildren[0].points || 0;
}
}
const percentage = displayCost > 0 ? Math.min((currentStars / displayCost) * 100, 100) : 0;
// Pending claim check
const pcAttrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || this.hass?.states?.[this.config?.entity]?.attributes || {};
const pendingClaims = pcAttrs.pending_reward_claims || [];
const hasPendingClaim = pendingClaims.some(c =>
c.reward_id === reward.id && (!childId || c.child_id === childId)
);
// Pool mode affordability / redeem state
const spendable = relevantChild
? (typeof relevantChild.spendable_balance === 'number'
? relevantChild.spendable_balance
: (relevantChild.points || 0) - (relevantChild.committed_points || 0))
: 0;
const poolFull = enablePoolMode && (
isJackpot ? jackpotPoolTotal >= displayCost : currentStars >= displayCost
);
// Wallet mode: affordability based on gross − committed
const committedPoints = relevantChild?.committed_points || 0;
const availablePoints = (relevantChild?.points || 0) - committedPoints;
const canAfford = relevantChild && availablePoints >= displayCost;
const isLoading = this._loading[reward.id];
const isAllocLoading = this._loading[`${reward.id}_alloc`];
// Availability (quantity / expiration)
const isSoldOut = reward.is_sold_out === true;
const isExpired = reward.is_expired === true;
const isUnavailable = isSoldOut || isExpired;
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
? reward.days_until_expiry
: null;
const availabilityBadge = this._renderAvailabilityBadge(reward, isSoldOut, isExpired, daysUntilExpiry);
// Pool-mode controls render below the progress bar (full width) rather than
// in the right column, so the progress bar keeps its full width.
const showPoolControls = enablePoolMode && childId && !hasPendingClaim && !isUnavailable;
return html`
${displayCost}
${pointsName}
${isJackpot ? html`
🎰 ${this._t('rewards.jackpot')}
` : ''}
${reward.name}
${availabilityBadge}
${hasDescription
? html`
${reward.description}
`
: ""}
${isJackpot
? this._renderJackpotProgress(reward, childContributions, currentStars, pointsIcon, displayCost)
: this._renderRegularProgress(currentStars, displayCost, percentage, pointsIcon)}
${showPoolControls
? this._renderPoolControls(reward, relevantChild, spendable, poolFull, isAllocLoading, isLoading)
: ''}
${hasPendingClaim ? html`
${this._t('rewards.awaiting_approval')}
` : ''}
${showChildBadges && !isJackpot
? html`
${assignedTo.length === 0
? html`${this._t('rewards.all_children')} `
: assignedTo.map(
(cid) =>
html`${childMap[cid] || cid} `
)}
`
: ""}
${!enablePoolMode && !hasPendingClaim && childId && !isUnavailable ? html`
{ e.stopPropagation(); this._promptClaim(reward, relevantChild); }}"
title="${canAfford ? this._t('rewards.claim_reward') : this._t('rewards.not_enough_points')}"
>
` : html`
`}
`;
}
_renderAvailabilityBadge(reward, isSoldOut, isExpired, daysUntilExpiry) {
if (isExpired) {
return html`${this._t('rewards.expired')}
`;
}
if (isSoldOut) {
return html`${this._t('rewards.sold_out')}
`;
}
const badges = [];
if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
badges.push(html`${this._t('rewards.only_n_left', { count: reward.quantity })}
`);
}
if (typeof daysUntilExpiry === 'number' && daysUntilExpiry >= 0 && daysUntilExpiry <= 7) {
const label = 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 }));
badges.push(html`${label}
`);
}
if (badges.length === 0) return '';
return html`${badges}
`;
}
_renderPoolControls(reward, child, spendable, poolFull, isAllocLoading, isRedeemLoading) {
if (poolFull) {
return html`
{ e.stopPropagation(); this._handleRedeem(reward, child); }}"
title="${this._t('rewards.redeem')}"
>
${this._t('rewards.redeem')}
`;
}
const amounts = this._quickDepositAmounts();
return html`
${amounts.map((amt) => {
const disabled = !child || spendable < amt || isAllocLoading;
const tooltip = this._depositTooltip({ child, spendable, amt, isAllocLoading });
return html`
{ e.stopPropagation(); this._handleAllocate(reward, child, amt); }}"
title="${tooltip}"
aria-label="${tooltip}"
>+${amt}
`;
})}
${this._renderCustomDeposit(reward, child, spendable, isAllocLoading)}
`;
}
_depositTooltip({ child, spendable, amt, isAllocLoading }) {
if (isAllocLoading) return this._t('rewards.deposit_disabled_loading');
if (!child) return this._t('rewards.deposit_disabled_no_child');
if (spendable < amt) {
return this._t('rewards.deposit_disabled_insufficient', { spendable });
}
return this._t('rewards.deposit_points', { amount: amt });
}
/**
* Quick-deposit button amounts from config.deposit_amounts (#559). Sanitised
* to positive integers (deduped, order preserved); falls back to [1, 5, 10]
* when unset or invalid so existing cards are unaffected.
*/
_quickDepositAmounts() {
const raw = this.config && this.config.deposit_amounts;
if (Array.isArray(raw)) {
const cleaned = [];
for (const v of raw) {
const n = Math.floor(Number(v));
if (Number.isFinite(n) && n >= 1 && !cleaned.includes(n)) cleaned.push(n);
}
if (cleaned.length) return cleaned;
}
return [1, 5, 10];
}
/**
* Deposit a typed custom amount (#559). The is uncontrolled — we read
* it from the DOM on submit so keystrokes don't trigger re-renders (which
* would steal focus). The backend silently caps the amount to the child's
* spendable balance and the pool's remaining room.
*/
_handleCustomDeposit(reward, child, e) {
const wrap = e.target.closest(".deposit-custom");
const input = wrap && wrap.querySelector("input");
if (!input) return;
const amount = Math.floor(Number(input.value));
if (!Number.isFinite(amount) || amount < 1) return;
input.value = "";
this._handleAllocate(reward, child, amount);
}
/**
* The "type any amount + Add" control shown beside the quick-deposit buttons.
* Shared by the classic and designed layouts; `cls` carries layout-specific
* button/input classes so each matches its surrounding style.
*/
_renderCustomDeposit(reward, child, spendable, isAllocLoading, cls = {}) {
if (!child) return "";
const disabled = spendable < 1 || isAllocLoading;
const inputCls = cls.input || "";
const btnCls = cls.button || "alloc-btn";
const submit = (e) => { e.stopPropagation(); this._handleCustomDeposit(reward, child, e); };
return html`
e.stopPropagation()}"
@keydown="${(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(e); } }}"
>
${this._t('rewards.deposit_custom_add')}
`;
}
_promptClaim(reward, child) {
if (!child || !reward) return;
this._pendingClaim = { ...reward, _child: child };
}
_cancelClaim() {
this._pendingClaim = null;
}
async _confirmClaim() {
const pending = this._pendingClaim;
if (!pending) return;
const child = pending._child;
this._pendingClaim = null;
return this._handleClaim(pending, child);
}
async _handleClaim(reward, child) {
if (!child || !reward) return;
if (this._loading[reward.id]) return;
this._loading = { ...this._loading, [reward.id]: true };
this.requestUpdate();
try {
// Prefer pressing the per-child/per-reward button entity so HA
// state-trigger automations on button.taskmate__claim_
// fire. Falls back to the service call if the entity isn't registered.
const buttonEntityId = window.__taskmate_find_button
&& window.__taskmate_find_button(this.hass, child.id, "claim", reward.id);
if (buttonEntityId) {
await this.hass.callService("button", "press", { entity_id: buttonEntityId });
} else {
await this.hass.callService("taskmate", "claim_reward", {
reward_id: reward.id,
child_id: child.id,
});
}
} catch (e) {
console.error("Failed to claim reward:", e);
if (this.hass && this.hass.callService) {
this.hass.callService("persistent_notification", "create", {
title: this._t('approvals.error_title'),
message: this._t('approvals.error_failed_service', {
service: "claim reward",
message: e && e.message ? e.message : String(e),
}),
notification_id: `taskmate_reward_claim_${reward.id}`,
});
}
} finally {
this._loading = { ...this._loading, [reward.id]: false };
this.requestUpdate();
}
}
async _handleAllocate(reward, child, points) {
if (!child || !reward) return;
const key = `${reward.id}_alloc`;
if (this._loading[key]) return;
this._loading = { ...this._loading, [key]: true };
this.requestUpdate();
try {
await this.hass.callService("taskmate", "allocate_points_to_pool", {
child_id: child.id,
reward_id: reward.id,
points,
});
} catch (e) {
console.error("Failed to allocate points to pool:", e);
if (this.hass && this.hass.callService) {
this.hass.callService("persistent_notification", "create", {
title: this._t('approvals.error_title'),
message: this._t('approvals.error_failed_service', {
service: "allocate_points_to_pool",
message: e && e.message ? e.message : String(e),
}),
notification_id: `taskmate_pool_alloc_${reward.id}`,
});
}
} finally {
this._loading = { ...this._loading, [key]: false };
this.requestUpdate();
}
}
async _handleRedeem(reward, child) {
return this._promptClaim(reward, child);
}
_renderRegularProgress(currentStars, cost, percentage, pointsIcon) {
return html`
`;
}
/**
* Get the display cost for a reward based on child context.
* For non-jackpot rewards, each child has their own calculated cost.
* For jackpot rewards, all children share the same cost.
*/
_getDisplayCost(reward, children) {
// All reward costs are static — just return reward.cost
// calculated_costs is still provided by the sensor for jackpot display purposes
const calculatedCosts = reward.calculated_costs || {};
if (this.config.child_id && calculatedCosts[this.config.child_id]) {
return calculatedCosts[this.config.child_id];
}
return reward.cost;
}
_renderJackpotProgress(reward, childContributions, totalStars, pointsIcon, displayCost) {
const cost = displayCost || reward.cost;
// For weighted display: each child's segment shows their progress toward their expected share
// The meter shows how much of their "responsibility" each child has fulfilled
const hasWeightedData = childContributions.some(c => c.expectedContribution > 0);
// Calculate weighted segments - each segment width = (child's share of goal) * (their progress %)
// This way, if a child has 50% share and is at 100% progress, they fill 50% of the bar
const segments = childContributions.map((contrib) => {
let width;
if (hasWeightedData && contrib.shareOfGoal > 0) {
// Weighted: segment width = share of goal * progress percentage
// e.g., 40% share at 50% progress = 20% of bar filled
width = (contrib.shareOfGoal / 100) * (contrib.weightedProgress / 100) * 100;
} else {
// Fallback: raw contribution relative to cost
width = cost > 0 ? Math.min((contrib.points / cost) * 100, 100) : 0;
}
return {
...contrib,
width: width
};
});
// Calculate total percentage for display
const totalPercentage = cost > 0 ? Math.min((totalStars / cost) * 100, 100) : 0;
return html`
${segments.map((seg) => html`
`)}
${totalStars}/${cost}
${childContributions.map((contrib) => html`
${contrib.name} :
${contrib.points}
${hasWeightedData && contrib.expectedContribution > 0 ? html`
(${Math.round(contrib.weightedProgress)}%)
` : ''}
`)}
`;
}
/* ══════════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
Ported from docs/design/redesigns (§card-rewards). FIXED COSTS ONLY —
never any dynamic/surge pricing. All existing claim/pool/confirm logic is
preserved: designed buttons call the SAME handlers as classic.
══════════════════════════════════════════════════════════════════════ */
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
_av(child, tone, size) {
const a = (child && child.avatar) || "";
const inner = a.startsWith("mdi:")
? html` `
: a
? html` `
: ((child && child.name) || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
return html`${inner}
`;
}
/**
* Compute all per-reward display data once, mirroring the classic
* _renderRewardRow calculations. FIXED COST is reward.cost (via
* _getDisplayCost — static only). Returns everything the designed rows need.
*/
_rewardData(reward, children) {
const assignedTo = reward.assigned_to || [];
const isJackpot = reward.is_jackpot || false;
const cardForcesPool = this.config.enable_pool_mode === true;
// Jackpot rewards are always pool-mode (#552): everyone deposits into the
// shared jar and it's redeemed when full. Combined points only work via the
// pool, so jackpots never show the single-child claim button.
const enablePoolMode = cardForcesPool || reward.pool_enabled === true || isJackpot;
const displayCost = this._getDisplayCost(reward, children);
const relevantChildren = assignedTo.length === 0
? children
: children.filter(c => assignedTo.includes(c.id));
const childId = this.config?.child_id || this._selectedChildId;
const relevantChild = childId
? children.find(c => c.id === childId)
: relevantChildren[0];
const poolAllocations = reward.pool_allocations || {};
const jackpotPoolTotal = typeof reward.jackpot_pool_total === 'number' ? reward.jackpot_pool_total : 0;
let currentStars = 0;
const childContributions = [];
if (isJackpot) {
const sharePerChild = relevantChildren.length > 0
? Math.round(displayCost / relevantChildren.length)
: displayCost;
relevantChildren.forEach((child, index) => {
const points = enablePoolMode
? (poolAllocations[child.id] || 0)
: (child.points || 0);
currentStars += points;
childContributions.push({
name: child.name,
child,
points,
tone: this._designTone(index),
});
});
} else if (enablePoolMode) {
currentStars = childId ? (poolAllocations[childId] || 0) : 0;
} else if (this.config.child_id) {
const fc = children.find(c => c.id === this.config.child_id);
currentStars = fc ? (fc.points || 0) : 0;
} else if (relevantChildren.length > 0) {
currentStars = relevantChildren[0].points || 0;
}
const percentage = displayCost > 0 ? Math.min((currentStars / displayCost) * 100, 100) : 0;
const pcAttrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const pendingClaims = pcAttrs.pending_reward_claims || [];
const hasPendingClaim = pendingClaims.some(c =>
c.reward_id === reward.id && (!childId || c.child_id === childId)
);
const spendable = relevantChild
? (typeof relevantChild.spendable_balance === 'number'
? relevantChild.spendable_balance
: (relevantChild.points || 0) - (relevantChild.committed_points || 0))
: 0;
const poolFull = enablePoolMode && (
isJackpot ? jackpotPoolTotal >= displayCost : currentStars >= displayCost
);
const committedPoints = relevantChild?.committed_points || 0;
const availablePoints = (relevantChild?.points || 0) - committedPoints;
const canAfford = !!relevantChild && availablePoints >= displayCost;
const isSoldOut = reward.is_sold_out === true;
const isExpired = reward.is_expired === true;
const isUnavailable = isSoldOut || isExpired;
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
? reward.days_until_expiry : null;
return {
reward, isJackpot, enablePoolMode, displayCost, relevantChildren, relevantChild,
childId, currentStars, childContributions, percentage, hasPendingClaim,
spendable, poolFull, canAfford, isSoldOut, isExpired, isUnavailable, daysUntilExpiry,
showClaim: !enablePoolMode && !hasPendingClaim && childId && !isUnavailable,
showPoolControls: enablePoolMode && childId && !hasPendingClaim && !isUnavailable,
};
}
/** Designed availability chip label/tone for a reward, or null. */
_designAvail(d) {
if (d.isExpired) return { label: this._t('rewards.expired'), tone: 'muted' };
if (d.isSoldOut) return { label: this._t('rewards.sold_out'), tone: 'muted' };
if (typeof d.reward.quantity === 'number' && d.reward.quantity > 0 && d.reward.quantity <= 3) {
return { label: this._t('rewards.only_n_left', { count: d.reward.quantity }), tone: 'bad' };
}
if (typeof d.daysUntilExpiry === 'number' && d.daysUntilExpiry >= 0 && d.daysUntilExpiry <= 7) {
const label = d.daysUntilExpiry === 0
? this._t('rewards.expires_today')
: (d.daysUntilExpiry === 1
? this._t('rewards.expires_in_days', { count: d.daysUntilExpiry })
: this._t('rewards.expires_in_days_plural', { count: d.daysUntilExpiry }));
return { label, tone: 'warn' };
}
return null;
}
_renderDesigned(design) {
const entity = this.hass.states[this.config.entity];
const hd = _safeColor(this.config.header_color, '#e67e22');
if (!entity) {
return html`
${this._designHeader(0, hd)}
${this._t('common.entity_not_found', { entity: this.config.entity })}
`;
}
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const allRewards = attrs.rewards || [];
const children = attrs.children || [];
const pointsIcon = attrs.points_icon || "mdi:star";
const pointsName = attrs.points_name || this._t("common.stars");
let rewards = allRewards;
if (this.config.child_id) {
rewards = allRewards.filter((reward) => {
const assignedTo = reward.assigned_to || [];
return assignedTo.length === 0 || assignedTo.includes(this.config.child_id);
});
}
const sortedRewards = [...rewards].sort((a, b) => {
if (a.is_jackpot && !b.is_jackpot) return -1;
if (!a.is_jackpot && b.is_jackpot) return 1;
return this._getDisplayCost(a, children) - this._getDisplayCost(b, children);
});
const cardForcesPool = this.config.enable_pool_mode === true;
const activeChildId = this.config.child_id || this._selectedChildId;
const activeChild = activeChildId ? children.find((c) => c.id === activeChildId) : null;
const anyPoolReward = sortedRewards.some((r) => cardForcesPool || r.pool_enabled === true || r.is_jackpot);
const header = this._designHeader(rewards.length, hd);
const body = sortedRewards.length === 0
? html`${this._t('rewards.empty_title')} ${this._t('rewards.empty_subtitle')}
`
: html`
${this._designChildTabs(children)}
${(anyPoolReward && activeChild) || activeChild
? this._designChildSelector(activeChild, pointsIcon, pointsName, design)
: ''}
${sortedRewards.map((r) => this._designRewardRow(r, children, pointsIcon, pointsName, design))}
`;
return html`
${header}
${body}
${this._renderConfirmDialog(pointsIcon, pointsName)}
`;
}
_designHeader(count, hd) {
return html`
🎁
${this.config.title || this._t('rewards.default_title')}${count
? html`${count === 1
? this._t('rewards.reward_count_singular', { count })
: this._t('rewards.reward_count_plural', { count })} ` : ''}
`;
}
_designChildSelector(child, pointsIcon, pointsName, design) {
const spendable = typeof child.spendable_balance === 'number'
? child.spendable_balance : (child.points || 0);
return html`
${this._av(child, this._designTone(0), 38)}
${child.name}
⭐
${this._t('rewards.spendable_balance')}: ${spendable}
`;
}
_designRewardRow(reward, children, pointsIcon, pointsName, design) {
const d = this._rewardData(reward, children);
const tone = this._designTone((reward.id || reward.name || '').length);
const isLoading = this._loading[reward.id];
const avail = this._designAvail(d);
const emoji = "🎁";
// Child-assignment badges — honour show_child_badges (default on), classic-parity:
// only for non-jackpot rewards; "all children" chip when unassigned.
const showChildBadges = this.config.show_child_badges !== false;
const assignedTo = reward.assigned_to || [];
const childMap = {};
children.forEach((c) => { childMap[c.id] = c.name; });
const badges = showChildBadges && !d.isJackpot
? (assignedTo.length === 0
? html`${this._t('rewards.all_children')} `
: assignedTo.map((cid) => html`${childMap[cid] || cid} `))
: '';
const costBadge = html`⭐ ${d.displayCost} ${pointsName} `;
const bar = d.isJackpot
? html`
${d.childContributions.map((c) => {
const w = d.displayCost > 0 ? Math.min((c.points / d.displayCost) * 100, 100) : 0;
return html` `;
})}
`
: html`
`;
const claimBtn = d.showClaim
? html` { e.stopPropagation(); this._promptClaim(reward, d.relevantChild); }}"
title="${d.canAfford ? this._t('rewards.claim_reward') : this._t('rewards.not_enough_points')}"
>${this._t('rewards.claim_reward')} `
: '';
const poolControls = d.showPoolControls
? (d.poolFull
? html` { e.stopPropagation(); this._handleRedeem(reward, d.relevantChild); }}"
>${this._t('rewards.redeem')} `
: html`
${this._quickDepositAmounts().map((amt, i, arr) => {
const disabled = !d.relevantChild || d.spendable < amt || this._loading[`${reward.id}_alloc`];
return html` { e.stopPropagation(); this._handleAllocate(reward, d.relevantChild, amt); }}"
>+${amt} `;
})}
${this._renderCustomDeposit(reward, d.relevantChild, d.spendable, this._loading[`${reward.id}_alloc`], { button: 'btn sm', input: 'rw-deposit-input' })}
`)
: '';
const statusLine = d.hasPendingClaim
? html`⏳ ${this._t('rewards.awaiting_approval')} `
: d.canAfford && d.showClaim
? html`${this._t('reward_progress.ready_to_claim')} `
: html`${d.currentStars} / ${d.displayCost} `;
return html`
${emoji}
${d.isJackpot ? html`
🎰 ${this._t('rewards.jackpot')} ` : ''}
${reward.name}
${reward.description ? html`
${reward.description}
` : ''}
${avail ? html`
${avail.label} ` : ''}
${badges ? html`
${badges}
` : ''}
${costBadge}
${bar}
${d.isJackpot ? html`
` : ''}
`;
}
/** The confirm dialog markup, shared by classic + designed paths. */
_renderConfirmDialog(pointsIcon, pointsName) {
if (!this._pendingClaim) return '';
return html`
e.stopPropagation()}">
${this._t('rewards.confirm_title')}
${(() => {
const r = this._pendingClaim;
const child = r._child;
const currentPoints = child ? (typeof child.spendable_balance === 'number' ? child.spendable_balance : (child.points || 0)) : null;
const cost = r.cost;
const remaining = currentPoints !== null ? currentPoints - cost : null;
return html`
${this._t('rewards.confirm_body', { name: r.name })}
${currentPoints !== null ? html`
${this._t('rewards.confirm_your_points', { name: pointsName })}
${currentPoints}
${this._t('rewards.confirm_cost')}
−${cost}
${this._t('rewards.confirm_remaining')}
${remaining}
` : ''}
`;
})()}
${this._t('common.cancel')}
${this._t('rewards.confirm_claim_btn')}
`;
}
}
// Card Editor for visual configuration in Lovelace UI
class TaskMateRewardsCardEditor 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;
}
// Build the ha-form schema based on the current state (e.g. child list from the entity).
_buildSchema() {
const entity = this.config?.entity ? this.hass?.states?.[this.config.entity] : null;
const children = entity?.attributes?.children || [];
const childOptions = [
{ value: '__all__', label: this._t('rewards.editor.filter_by_child_all') },
...children.map((c) => ({ value: c.id, label: c.name })),
];
return [
{
name: 'entity',
selector: { entity: { domain: 'sensor' } },
},
{
name: 'title',
selector: { text: {} },
},
{
name: 'child_id',
selector: { select: { options: childOptions, mode: 'dropdown' } },
},
{
name: 'card_design',
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: 'global', label: 'Use global default' }],
mode: 'dropdown',
},
},
},
{
name: 'show_child_badges',
selector: { boolean: {} },
},
{
name: 'enable_pool_mode',
selector: { boolean: {} },
},
{
name: 'expand_to_fit',
selector: { boolean: {} },
},
];
}
_computeLabel = (schemaEntry) => {
const labels = {
entity: this._t('rewards.editor.entity'),
title: this._t('rewards.editor.title'),
child_id: this._t('rewards.editor.filter_by_child'),
card_design: this._t('common.design.field_label'),
show_child_badges: this._t('rewards.editor.show_child_badges'),
enable_pool_mode: this._t('rewards.editor.enable_pool_mode'),
expand_to_fit: this._t('rewards.editor.expand_to_fit'),
};
return labels[schemaEntry.name] ?? schemaEntry.name;
};
_computeHelper = (schemaEntry) => {
const helpers = {
entity: this._t('rewards.editor.entity_helper'),
title: this._t('rewards.editor.title_helper'),
child_id: this._t('rewards.editor.filter_by_child_helper'),
show_child_badges: this._t('rewards.editor.show_child_badges_helper'),
enable_pool_mode: this._t('rewards.editor.enable_pool_mode_helper'),
expand_to_fit: this._t('rewards.editor.expand_to_fit_helper'),
};
return helpers[schemaEntry.name] ?? '';
};
render() {
if (!this.hass || !this.config) {
return html``;
}
// ha-form treats missing keys as empty; normalise for consistent values.
const data = {
entity: this.config.entity || '',
title: this.config.title || '',
child_id: this.config.child_id || '__all__',
card_design: this.config.card_design || 'global',
show_child_badges: this.config.show_child_badges !== false,
enable_pool_mode: this.config.enable_pool_mode === true,
expand_to_fit: this.config.expand_to_fit !== false,
};
return html`
${this._renderColourPicker('header_color', '#e67e22')}
`;
}
_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._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}
_formChanged(e) {
const newValues = e.detail.value || {};
// Merge into config, removing keys that are empty/default so the YAML stays clean.
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
if (
value === '' || value === null || value === undefined
|| (key === 'child_id' && value === '__all__')
) {
delete newConfig[key];
} else if (key === 'card_design' && value === 'global') {
delete newConfig[key];
} else if (key === 'show_child_badges' && value === true) {
// Default is true — omit to keep config minimal
delete newConfig[key];
} else if (key === 'enable_pool_mode' && value === false) {
delete newConfig[key];
} else if (key === 'expand_to_fit' && value === true) {
// Default is true (fluid) — omit to keep config minimal
delete newConfig[key];
} else {
newConfig[key] = value;
}
}
this._dispatchConfig(newConfig);
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === undefined || value === '' || value === null) {
delete newConfig[key];
}
this._dispatchConfig(newConfig);
}
_dispatchConfig(newConfig) {
this.dispatchEvent(
new CustomEvent('config-changed', {
detail: { config: newConfig },
bubbles: true,
composed: true,
})
);
}
}
// Register the cards
customElements.define("taskmate-rewards-card", TaskMateRewardsCard);
customElements.define("taskmate-rewards-card-editor", TaskMateRewardsCardEditor);
// Register with Home Assistant
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-rewards-card",
name: "TaskMate Rewards Card",
description: "A card displaying available rewards with star costs",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-rewards-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-rewards-card.js"]'))
.map(s => s.src.split("?")[1]).find(Boolean) || ""
).get("v") || "?";
console.info(
"%c TASKMATE REWARDS CARD %c v" + _tmVersion + " ",
"background:#e67e22;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;"
);