456 files
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,8 @@ class TaskMateActivityCard extends LitElement {
|
||||
return [
|
||||
"Weekend bonus", "Streak milestone bonus", "Perfect week bonus",
|
||||
"Allocated to pool:", "Pool refund", "Points decay",
|
||||
"Savings interest", "Badge",
|
||||
"Savings interest", "Badge", "Streak freeze",
|
||||
"Wish savings", "Wish refund", "Wish pledge", "Wish redeemed",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,6 +50,20 @@ class TaskMateActivityCard extends LitElement {
|
||||
return !TaskMateActivityCard._UNDO_DENY_PREFIXES.some(p => r.startsWith(p));
|
||||
}
|
||||
|
||||
// Streak-freeze token movements (#925) are logged as zero-point rows. Worded
|
||||
// as points they would read "lost 0", so both render paths give them their
|
||||
// own line: the child, the translated reason, a snowflake — no points.
|
||||
// Wishlist pledges and redemptions (#932) are zero-point rows too — the
|
||||
// pledged points never touch the child's balance — and share the same line.
|
||||
_isFreezeEntry(item) {
|
||||
const reason = item.reason || "";
|
||||
return !item.points && (reason.startsWith("Streak freeze") || reason.startsWith("Wish "));
|
||||
}
|
||||
|
||||
_isWishEntry(item) {
|
||||
return (item.reason || "").startsWith("Wish ");
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
@@ -77,6 +92,8 @@ class TaskMateActivityCard extends LitElement {
|
||||
['Pool refund (reward assignment changed):', 'activity.reason_pool_refund_assignment_changed'],
|
||||
['Penalty:', 'activity.reason_penalty'],
|
||||
['Bonus:', 'activity.reason_bonus'],
|
||||
// Surprise inspections (#981): the pass bonus.
|
||||
['Inspection passed:', 'activity.reason_inspection_passed'],
|
||||
];
|
||||
for (const [prefix, key] of prefixMap) {
|
||||
if (reason.startsWith(prefix)) {
|
||||
@@ -100,6 +117,42 @@ class TaskMateActivityCard extends LitElement {
|
||||
if (streakMatch) {
|
||||
return this._t('activity.reason_streak_milestone', { days: streakMatch[1] });
|
||||
}
|
||||
// Streak freeze tokens (#925). Zero-point rows: the reason is the news.
|
||||
const freezeUsed = reason.match(/^Streak freeze used \((\d{4}-\d{2}-\d{2})\)$/);
|
||||
if (freezeUsed) {
|
||||
return this._t('activity.reason_streak_freeze_used', { date: freezeUsed[1] });
|
||||
}
|
||||
const freezeEarned = reason.match(/^Streak freeze earned \((\d+) day streak!\)$/);
|
||||
if (freezeEarned) {
|
||||
return this._t('activity.reason_streak_freeze_earned', { days: freezeEarned[1] });
|
||||
}
|
||||
const freezeAdjusted = reason.match(/^Streak freezes adjusted \(([+-]\d+)\)$/);
|
||||
if (freezeAdjusted) {
|
||||
return this._t('activity.reason_streak_freeze_adjusted', { delta: freezeAdjusted[1] });
|
||||
}
|
||||
if (reason === 'Streak freeze reversed') {
|
||||
return this._t('activity.reason_streak_freeze_reversed');
|
||||
}
|
||||
// Wishlist (#932). Pledges carry who and how much, so they need a match
|
||||
// rather than a prefix.
|
||||
const wishPrefixes = [
|
||||
['Wish savings taken back:', 'activity.reason_wish_taken_back'],
|
||||
['Wish savings:', 'activity.reason_wish_savings'],
|
||||
['Wish refund (declined):', 'activity.reason_wish_refund_declined'],
|
||||
['Wish refund (removed):', 'activity.reason_wish_refund_removed'],
|
||||
['Wish redeemed:', 'activity.reason_wish_redeemed'],
|
||||
];
|
||||
for (const [prefix, key] of wishPrefixes) {
|
||||
if (reason.startsWith(prefix)) return this._t(key, { name: reason.slice(prefix.length).trim() });
|
||||
}
|
||||
const pledgeAdded = reason.match(/^Wish pledge from (.+) \(\+(\d+)\): (.*)$/);
|
||||
if (pledgeAdded) {
|
||||
return this._t('activity.reason_wish_pledge', { who: pledgeAdded[1], points: pledgeAdded[2], name: pledgeAdded[3] });
|
||||
}
|
||||
const pledgeGone = reason.match(/^Wish pledge (removed|voided) \((.+), (\d+)\): (.*)$/);
|
||||
if (pledgeGone) {
|
||||
return this._t(`activity.reason_wish_pledge_${pledgeGone[1]}`, { who: pledgeGone[2], points: pledgeGone[3], name: pledgeGone[4] });
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
@@ -158,7 +211,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
.header-meta {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-left: 2px;
|
||||
margin-inline-start: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -230,7 +283,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
letter-spacing: 0.2px;
|
||||
font-weight: 500;
|
||||
opacity: 0.75;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
/* ── Activity item ─────────────────────────────────── */
|
||||
@@ -255,6 +308,9 @@ class TaskMateActivityCard extends LitElement {
|
||||
.activity-item.t-penalty .event-stripe { background: var(--tm-stripe-penalty); }
|
||||
.activity-item.t-reward .event-stripe { background: var(--tm-stripe-reward); }
|
||||
.activity-item.t-bonus .event-stripe { background: var(--tm-stripe-bonus); }
|
||||
.activity-item.t-inspection .event-stripe { background: #f39c12; }
|
||||
.activity-icon.t-inspection { background: rgba(243,156,18,0.16); color: #c2740a; }
|
||||
.activity-insp-note { font-size: 0.8rem; font-style: italic; color: var(--secondary-text-color); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
|
||||
.activity-row {
|
||||
flex: 1;
|
||||
@@ -317,6 +373,13 @@ class TaskMateActivityCard extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
font-style: normal;
|
||||
}
|
||||
.activity-reject-reason {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--tm-stripe-rejected, #ef4444);
|
||||
margin-top: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.activity-meta {
|
||||
display: flex;
|
||||
@@ -330,7 +393,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
.activity-time { color: var(--secondary-text-color); }
|
||||
.activity-ago::before {
|
||||
content: "·";
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -409,7 +472,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: 2px;
|
||||
margin-inline-start: 2px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
background: transparent;
|
||||
@@ -433,6 +496,11 @@ class TaskMateActivityCard extends LitElement {
|
||||
.tmd-undo { padding: 4px 11px; }
|
||||
.act-line { font-weight: 700; font-size: 13.5px; line-height: 1.35; }
|
||||
.act-line .reason { font-weight: 400; color: var(--tmd-dim); }
|
||||
.act-insp-note { font-size: 12px; font-style: italic; font-weight: 400; color: var(--tmd-dim); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.act-reject-reason { font-size: 12px; font-weight: 600; color: var(--tmd-bad); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
/* Quality rating the parent gave at approval (#927), both render paths. */
|
||||
.act-rating { color: var(--tmd-gold, #f5b301); font-weight: 700; letter-spacing: 1px; white-space: nowrap; }
|
||||
.act-rating .off { color: var(--tmd-dim, var(--secondary-text-color, #999)); font-weight: 400; }
|
||||
.act-ago { font-size: 11.5px; }
|
||||
.act-rel { opacity: 0.8; }
|
||||
|
||||
@@ -448,14 +516,14 @@ class TaskMateActivityCard extends LitElement {
|
||||
.act-cn-row { gap: 10px; padding: 9px 2px; border-bottom: 1px solid var(--tmd-border); }
|
||||
.act-cn-row:last-child { border-bottom: none; }
|
||||
.act-cn-mid { flex: 1; min-width: 0; font-size: 12.5px; font-weight: 600; }
|
||||
.act-cn-time { font-size: 11px; flex-shrink: 0; text-align: right; line-height: 1.3; white-space: nowrap; }
|
||||
.act-cn-time { font-size: 11px; flex-shrink: 0; text-align: end; line-height: 1.3; white-space: nowrap; }
|
||||
|
||||
/* Clean Pro — connecting rail */
|
||||
.act-cp-rail { position: relative; padding-left: 26px; }
|
||||
.act-cp-line { position: absolute; left: 11px; top: 6px; bottom: 6px; width: 2px; background: var(--tmd-border); }
|
||||
.act-cp-rail { position: relative; padding-inline-start: 26px; }
|
||||
.act-cp-line { position: absolute; inset-inline-start: 11px; top: 6px; bottom: 6px; width: 2px; background: var(--tmd-border); }
|
||||
.act-cp { gap: 14px; }
|
||||
.act-cp-node { position: relative; }
|
||||
.act-cp-dot { position: absolute; left: -26px; top: 0; width: 22px; height: 22px; border-radius: 50%;
|
||||
.act-cp-dot { position: absolute; inset-inline-start: -26px; top: 0; width: 22px; height: 22px; border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ac, var(--tmd-accent)) 18%, transparent); color: var(--ac, var(--tmd-accent));
|
||||
display: grid; place-items: center; font-size: 11px; line-height: 1; box-shadow: 0 0 0 3px var(--tmd-surface); }
|
||||
.act-cp-head { justify-content: space-between; align-items: flex-start; gap: 10px; }
|
||||
@@ -465,7 +533,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
/* Vertical scroll (parity with classic) — extend to the card edge so the
|
||||
scrollbar sits at the edge, not floating inset by the body padding. */
|
||||
.act-pl, .act-cn, .act-cp-rail { max-height: 360px; overflow-y: auto;
|
||||
margin-right: -15px; padding-right: 8px; }
|
||||
margin-inline-end: -15px; padding-inline-end: 8px; }
|
||||
|
||||
/* Undo confirm overlay (works in classic + designed; no mwc dependency) */
|
||||
.undo-ov { position: fixed; inset: 0; z-index: 99; display: grid; place-items: center;
|
||||
@@ -515,15 +583,17 @@ class TaskMateActivityCard extends LitElement {
|
||||
_eventBucket(item) {
|
||||
const t = item.type || "chore";
|
||||
if (t === "points_added" || t === "points_removed") return "adjustments";
|
||||
if (t === "reward" || t === "reward_claimed" || t === "reward_approved") return "rewards";
|
||||
if (t === "reward" || t === "reward_claimed" || t === "reward_approved" || t === "reward_rejected") return "rewards";
|
||||
return "chores";
|
||||
}
|
||||
|
||||
_classifyItem(item) {
|
||||
const t = item.type || "chore";
|
||||
if (t === "chore_rejected" || t === "reward_rejected") return "rejected";
|
||||
if (t.startsWith("inspection_")) return "inspection";
|
||||
if (t === "points_added") {
|
||||
const reason = item.reason || "";
|
||||
if (reason.startsWith("Bonus:") || reason.startsWith("Perfect week bonus") ||
|
||||
if (reason.startsWith("Bonus:") || reason.startsWith("Inspection passed:") || reason.startsWith("Perfect week bonus") ||
|
||||
reason.startsWith("Weekend bonus") || reason.startsWith("Streak milestone bonus")) {
|
||||
return "bonus";
|
||||
}
|
||||
@@ -691,19 +761,45 @@ class TaskMateActivityCard extends LitElement {
|
||||
const ago = this._formatAgo(new Date(item.completed_at));
|
||||
const klass = this._classifyItem(item);
|
||||
|
||||
// ── Streak-freeze tokens (#925) ───────────────────────
|
||||
if ((type === "points_added" || type === "points_removed") && this._isFreezeEntry(item)) {
|
||||
return html`
|
||||
<div class="activity-item t-bonus">
|
||||
${this.config.accent_stripes !== false ? html`<div class="event-stripe"></div>` : ''}
|
||||
<div class="activity-row">
|
||||
<div class="activity-icon t-bonus">
|
||||
<ha-icon icon="${this._isWishEntry(item) ? "mdi:heart" : "mdi:snowflake"}"></ha-icon>
|
||||
</div>
|
||||
<div class="activity-body">
|
||||
<div class="activity-title">
|
||||
<strong>${childName}</strong>
|
||||
<span class="reason">— ${this._translateReason(item.reason)}</span>
|
||||
</div>
|
||||
<div class="activity-meta">
|
||||
<span class="activity-time">${time}</span>
|
||||
${this.config.show_relative_time !== false ? html`<span class="activity-ago">${ago}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Manual points transactions ────────────────────────
|
||||
if (type === "points_added" || type === "points_removed") {
|
||||
const isAdd = type === "points_added";
|
||||
const pts = Math.abs(item.points || 0);
|
||||
const reason = item.reason || '';
|
||||
const isPenalty = reason.startsWith('Penalty:');
|
||||
const isPoolAllocation = reason.startsWith('Allocated to pool:');
|
||||
// Saving into a wish (#932) is the same kind of move as a savings jar.
|
||||
const isPoolAllocation = reason.startsWith('Allocated to pool:') || reason.startsWith('Wish savings:');
|
||||
const isSpend = !isAdd && !isPenalty && isPoolAllocation;
|
||||
const verb = isAdd ? this._t('activity.received')
|
||||
: isSpend ? this._t('activity.spent')
|
||||
: this._t('activity.lost');
|
||||
const pillClass = isAdd ? 'gain' : (isSpend ? 'spend' : 'loss');
|
||||
const icon = isAdd ? 'mdi:star-plus' : (isPenalty ? 'mdi:minus-circle' : 'mdi:star-minus');
|
||||
const icon = reason.startsWith('Inspection passed:') ? 'mdi:magnify-scan'
|
||||
: isAdd ? 'mdi:star-plus' : (isPenalty ? 'mdi:minus-circle' : 'mdi:star-minus');
|
||||
const displayReason = this._translateReason(item.reason);
|
||||
|
||||
return html`
|
||||
@@ -736,6 +832,60 @@ class TaskMateActivityCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Surprise inspections (#981) ───────────────────────
|
||||
if (type.startsWith("inspection_")) {
|
||||
const d = this._inspectionText(item);
|
||||
return html`
|
||||
<div class="activity-item t-inspection">
|
||||
${this.config.accent_stripes !== false ? html`<div class="event-stripe"></div>` : ''}
|
||||
<div class="activity-row">
|
||||
<div class="activity-icon t-inspection">
|
||||
<ha-icon icon="${type === "inspection_redo" ? "mdi:restore" : "mdi:magnify-scan"}"></ha-icon>
|
||||
</div>
|
||||
<div class="activity-body">
|
||||
<div class="activity-title">
|
||||
<strong>${childName}</strong> · ${d.label}
|
||||
<span class="reason">— ${d.name}</span>
|
||||
</div>
|
||||
${item.note ? html`<div class="activity-insp-note">“${item.note}”</div>` : ''}
|
||||
<div class="activity-meta">
|
||||
<span class="activity-time">${time}</span>
|
||||
${this.config.show_relative_time !== false ? html`<span class="activity-ago">${ago}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Rejected with a reason (#976) ─────────────────────
|
||||
if (type === "chore_rejected" || type === "reward_rejected") {
|
||||
const isReward = type === "reward_rejected";
|
||||
const name = (isReward ? item.reward_name : item.chore_name)
|
||||
|| this._t(isReward ? 'activity.a_reward' : 'activity.a_chore');
|
||||
return html`
|
||||
<div class="activity-item t-rejected">
|
||||
${this.config.accent_stripes !== false ? html`<div class="event-stripe"></div>` : ''}
|
||||
<div class="activity-row">
|
||||
<div class="activity-icon t-rejected">
|
||||
<ha-icon icon="${isReward ? 'mdi:gift-off-outline' : 'mdi:close-circle'}"></ha-icon>
|
||||
</div>
|
||||
<div class="activity-body">
|
||||
<div class="activity-title">
|
||||
<strong>${childName}</strong> · ${name}
|
||||
<span class="activity-status rejected">${this._t('common.rejected')}</span>
|
||||
</div>
|
||||
${item.reason ? html`<div class="activity-reject-reason">${this._t('activity.rejected_reason', { reason: item.reason })}</div>` : ''}
|
||||
<div class="activity-meta">
|
||||
<span class="activity-time">${time}</span>
|
||||
${this.config.show_relative_time !== false ? html`<span class="activity-ago">${ago}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Reward claim events ───────────────────────────────
|
||||
if (type === "reward_claimed" || type === "reward_approved") {
|
||||
const pts = Math.abs(item.points || 0);
|
||||
@@ -794,6 +944,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
<div class="activity-body">
|
||||
<div class="activity-title">
|
||||
<strong>${childName}</strong> · ${choreName}
|
||||
${this._ratingStars(item)}
|
||||
${status !== 'approved' ? html`
|
||||
<span class="activity-status ${status}">${this._t('common.' + status)}</span>
|
||||
` : ''}
|
||||
@@ -817,6 +968,14 @@ class TaskMateActivityCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Stars a parent gave the approval (#927); nothing for unrated completions.
|
||||
_ratingStars(item) {
|
||||
const n = Number(item && item.rating) || 0;
|
||||
if (n < 1 || n > 3) return '';
|
||||
const label = this._t('activity.rated', { count: n });
|
||||
return html`<span class="act-rating" title="${label}" aria-label="${label}" role="img">${'★'.repeat(n)}<span class="off">${'☆'.repeat(3 - n)}</span></span>`;
|
||||
}
|
||||
|
||||
// ── Undo affordance ──────────────────────────────────────
|
||||
// kind: 'chore' (undo an approval → pending) or 'txn' (reverse a points txn).
|
||||
_renderUndoButton(kind, id, detail, child, points) {
|
||||
@@ -834,7 +993,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
?disabled=${loading}
|
||||
@click=${() => { this._confirm = { kind, id, message }; }}
|
||||
>
|
||||
<ha-icon icon="mdi:undo-variant"></ha-icon>
|
||||
<ha-icon class="tm-rtl-flip" icon="mdi:undo-variant"></ha-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -853,7 +1012,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
${this._t('common.cancel')}
|
||||
</button>
|
||||
<button class="undo-modal-btn go" @click=${() => this._doUndo()}>
|
||||
↩ ${this._t('activity.undo')}
|
||||
${this.getAttribute?.("dir") === "rtl" ? "↪" : "↩"} ${this._t('activity.undo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -894,6 +1053,20 @@ class TaskMateActivityCard extends LitElement {
|
||||
Ported from docs/design/redesigns/frag/17-activity.html
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
// Surprise inspections (#981): label + chore for an inspection_* event.
|
||||
_inspectionText(item) {
|
||||
const keys = {
|
||||
inspection_started: 'activity.inspection_started',
|
||||
inspection_passed: 'activity.inspection_passed',
|
||||
inspection_failed: 'activity.inspection_failed',
|
||||
inspection_redo: 'activity.inspection_redo',
|
||||
};
|
||||
return {
|
||||
label: this._t(keys[item.type] || 'activity.inspection_started'),
|
||||
name: item.chore_name || this._t('activity.a_chore'),
|
||||
};
|
||||
}
|
||||
|
||||
// Normalise a raw event into a descriptor the designed renderers consume.
|
||||
// Preserves the SAME undo affordance/handler used by the classic path.
|
||||
_describeEvent(item, childNames, pointsIcon, chorePointsMap) {
|
||||
@@ -902,18 +1075,31 @@ class TaskMateActivityCard extends LitElement {
|
||||
const ago = this._formatAgo(new Date(item.completed_at)) || this._formatTime(new Date(item.completed_at));
|
||||
const time = this._formatTime(new Date(item.completed_at));
|
||||
|
||||
if ((type === "points_added" || type === "points_removed") && this._isFreezeEntry(item)) {
|
||||
const displayReason = this._translateReason(item.reason);
|
||||
return {
|
||||
childName, tone: 'accent', emoji: this._isWishEntry(item) ? '💝' : '❄️', sign: '', pts: '',
|
||||
text: html`<strong>${childName}</strong> <span class="reason">— ${displayReason}</span>`,
|
||||
plain: `${childName} · ${displayReason}`,
|
||||
ago, time,
|
||||
ptsClass: 'accent',
|
||||
undo: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "points_added" || type === "points_removed") {
|
||||
const isAdd = type === "points_added";
|
||||
const pts = Math.abs(item.points || 0);
|
||||
const reason = item.reason || '';
|
||||
const isPenalty = reason.startsWith('Penalty:');
|
||||
const isPoolAllocation = reason.startsWith('Allocated to pool:');
|
||||
// Saving into a wish (#932) is the same kind of move as a savings jar.
|
||||
const isPoolAllocation = reason.startsWith('Allocated to pool:') || reason.startsWith('Wish savings:');
|
||||
const isSpend = !isAdd && !isPenalty && isPoolAllocation;
|
||||
const displayReason = this._translateReason(item.reason);
|
||||
const verb = isAdd ? this._t('activity.received') : isSpend ? this._t('activity.spent') : this._t('activity.lost');
|
||||
const sign = isAdd ? '+' : '−';
|
||||
const tone = isAdd ? 'good' : (isSpend ? 'accent' : 'bad');
|
||||
const emoji = isAdd ? '⭐' : (isPenalty ? '⚠️' : '➖');
|
||||
const emoji = reason.startsWith('Inspection passed:') ? '🔍' : isAdd ? '⭐' : (isPenalty ? '⚠️' : '➖');
|
||||
return {
|
||||
childName, tone, emoji, sign, pts,
|
||||
text: html`<strong>${childName}</strong> ${verb} <strong>${pts}</strong>${item.reason ? html` <span class="reason">— ${displayReason}</span>` : ''}`,
|
||||
@@ -926,6 +1112,34 @@ class TaskMateActivityCard extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
if (type.startsWith("inspection_")) {
|
||||
const d = this._inspectionText(item);
|
||||
return {
|
||||
childName, tone: type === "inspection_failed" ? 'accent' : 'good',
|
||||
emoji: type === "inspection_redo" ? '🔁' : '🔍', sign: '', pts: '',
|
||||
text: html`<strong>${childName}</strong> · ${d.label} <span class="reason">— ${d.name}</span>${item.note ? html`<div class="act-insp-note">“${item.note}”</div>` : ''}`,
|
||||
plain: `${childName} · ${d.label} — ${d.name}`,
|
||||
ago, time,
|
||||
ptsClass: 'good',
|
||||
undo: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "chore_rejected" || type === "reward_rejected") {
|
||||
const isReward = type === "reward_rejected";
|
||||
const name = (isReward ? item.reward_name : item.chore_name)
|
||||
|| this._t(isReward ? 'activity.a_reward' : 'activity.a_chore');
|
||||
const why = item.reason ? this._t('activity.rejected_reason', { reason: item.reason }) : '';
|
||||
return {
|
||||
childName, tone: 'bad', emoji: '↩️', sign: '', pts: '',
|
||||
text: html`<strong>${childName}</strong> · ${name} <span class="reason">— ${this._t('common.rejected')}</span>${why ? html`<div class="act-reject-reason">${why}</div>` : ''}`,
|
||||
plain: `${childName} · ${name} · ${this._t('common.rejected')}${why ? ` — ${why}` : ''}`,
|
||||
ago, time,
|
||||
ptsClass: 'bad',
|
||||
undo: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "reward_claimed" || type === "reward_approved") {
|
||||
const pts = Math.abs(item.points || 0);
|
||||
const isPending = type === "reward_claimed" && !item.approved;
|
||||
@@ -948,8 +1162,10 @@ class TaskMateActivityCard extends LitElement {
|
||||
const emoji = status === 'approved' ? '✅' : status === 'rejected' ? '❌' : '⏳';
|
||||
return {
|
||||
childName, tone, emoji, sign: '+', pts,
|
||||
text: html`<strong>${childName}</strong> ${this._t('activity.completed')} ${choreName}`,
|
||||
text: html`<strong>${childName}</strong> ${this._t('activity.completed')} ${choreName} ${this._ratingStars(item)}`,
|
||||
plain: `${childName} · ${choreName}`,
|
||||
// Console renders `plain`, so it carries the stars separately.
|
||||
stars: this._ratingStars(item),
|
||||
ago, time,
|
||||
ptsClass: status === 'approved' ? 'good' : status === 'rejected' ? 'bad' : 'muted',
|
||||
undo: status === 'approved'
|
||||
@@ -965,7 +1181,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
? this._t('activity.undo_confirm_chore', { detail: undo.detail, child: undo.child, points: undo.points })
|
||||
: this._t('activity.undo_confirm_txn', { detail: undo.detail, child: undo.child, points: undo.points });
|
||||
return html`<button class="btn ghost sm tmd-undo" ?disabled=${loading}
|
||||
@click=${() => { this._confirm = { kind: undo.kind, id: undo.id, message }; }}>↩ ${label}</button>`;
|
||||
@click=${() => { this._confirm = { kind: undo.kind, id: undo.id, message }; }}>${this.getAttribute?.("dir") === "rtl" ? "↪" : "↩"} ${label}</button>`;
|
||||
}
|
||||
|
||||
_designFilterChips() {
|
||||
@@ -1067,7 +1283,7 @@ class TaskMateActivityCard extends LitElement {
|
||||
<div class="row act-cn-row" style="--ac:var(--tmd-${r.tone})">
|
||||
${this._stripe(r)}
|
||||
<div class="av" style="--av:26px;--ac:var(--tmd-${r.tone})">${r.emoji}</div>
|
||||
<div class="act-cn-mid">${r.plain} <span class="num" style="color:var(--tmd-${r.ptsClass})">${r.sign}${r.pts}</span></div>
|
||||
<div class="act-cn-mid">${r.plain} ${r.stars || ''} <span class="num" style="color:var(--tmd-${r.ptsClass})">${r.sign}${r.pts}</span></div>
|
||||
${this._designUndoBtn(r.undo, this._t('activity.undo'))}
|
||||
<div class="num muted act-cn-time">${this._timeMeta(r)}</div>
|
||||
</div>`)}
|
||||
@@ -1177,7 +1393,7 @@ class TaskMateActivityCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
_loading: { type: Object },
|
||||
_signed: { state: true },
|
||||
_review: { state: true },
|
||||
_ratings: { state: true },
|
||||
_rejecting: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +51,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._loading = {};
|
||||
this._ratings = {}; // completion_id -> picked 1-3 star rating (#927)
|
||||
this._signed = {}; // safe photo path -> per-viewer signed path
|
||||
this._inflight = new Set();
|
||||
}
|
||||
@@ -212,7 +215,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
|
||||
.time-group {
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -331,11 +334,11 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
|
||||
.action-buttons.left {
|
||||
margin-right: 12px;
|
||||
margin-inline-end: 12px;
|
||||
}
|
||||
|
||||
.action-buttons.right {
|
||||
margin-left: 12px;
|
||||
margin-inline-start: 12px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
@@ -411,8 +414,8 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
|
||||
.tm-rv-quote {
|
||||
border-left: 3px solid var(--accent-color, #ffc107);
|
||||
padding: 6px 0 6px 10px;
|
||||
border-inline-start: 3px solid var(--accent-color, #ffc107);
|
||||
padding-block: 6px; padding-inline: 10px 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.92rem;
|
||||
white-space: pre-wrap;
|
||||
@@ -468,6 +471,29 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
color: var(--primary-text-color, #33373d);
|
||||
}
|
||||
.tm-rv-btn.primary { background: #4caf50; color: #fff; }
|
||||
.tm-rv-btn.danger { background: #f44336; color: #fff; }
|
||||
|
||||
/* Reject-with-a-reason sheet (#976) */
|
||||
.tm-rj-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.tm-rj-chip {
|
||||
border: 2px solid var(--divider-color, #e0e0e0);
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color, #33373d);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tm-rj-chip.on { border-color: #f44336; background: rgba(244, 67, 54, 0.12); }
|
||||
.tm-rj-input { font-size: 0.95rem; font-weight: 600; }
|
||||
.tm-rj-input:focus { border-color: #f44336; }
|
||||
.tm-rj-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text-color, #6b7280);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Mandatory-miss review actions (#532) */
|
||||
.action-button.penalty {
|
||||
@@ -486,7 +512,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
|
||||
.approval-item.mandatory {
|
||||
border-left: 5px solid var(--fun-red, #e74c3c);
|
||||
border-inline-start: 5px solid var(--fun-red, #e74c3c);
|
||||
}
|
||||
|
||||
.approval-item.mandatory .penalty-note {
|
||||
@@ -605,7 +631,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
/* Clean Pro — divided list */
|
||||
.ap-cp-item { padding: 10px 0; flex-wrap: wrap; row-gap: 8px; }
|
||||
.ap-cp-mid { flex: 1 1 130px; min-width: 130px; }
|
||||
.ap-cp-actions { display: flex; gap: 8px; flex: 0 0 auto; margin-left: auto; }
|
||||
.ap-cp-actions { display: flex; gap: 8px; flex: 0 0 auto; margin-inline-start: auto; }
|
||||
.ap-cp-title { font-weight: 600; }
|
||||
.ap-cp-sub { font-size: 12px; }
|
||||
.ap-cp-photo { width: 32px; height: 32px; border-radius: 8px; overflow: hidden;
|
||||
@@ -616,6 +642,22 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
|
||||
.ap-d-photo-link { line-height: 0; text-decoration: none; }
|
||||
|
||||
/* Quality rating star picker (#927) — shared by every design. */
|
||||
.ap-stars { display: inline-flex; align-items: center; gap: 1px; flex: none; }
|
||||
.ap-star {
|
||||
background: none; border: 0; margin: 0; padding: 2px 3px; cursor: pointer;
|
||||
font: inherit; font-size: 20px; line-height: 1; border-radius: 6px;
|
||||
color: var(--tmd-dim, var(--secondary-text-color, #9e9e9e));
|
||||
}
|
||||
.ap-star:hover, .ap-star.on { color: var(--tmd-gold, #f5b301); }
|
||||
/* Console rows are one tight line: the picker sits under the subtitle. */
|
||||
.ap-cn-stars { margin-top: 3px; }
|
||||
.ap-cn-stars .ap-star { font-size: 17px; padding: 1px 2px; }
|
||||
.ap-star:focus-visible { outline: 2px solid var(--tmd-accent, var(--primary-color)); outline-offset: 1px; }
|
||||
.ap-star:disabled { cursor: default; opacity: .5; }
|
||||
.tm-rv-stars { margin: 4px 0 10px; }
|
||||
.tm-rv-stars .ap-star { font-size: 28px; }
|
||||
|
||||
/* Scrollable list (parity with classic content) */
|
||||
.ap-d-scroll { max-height: 360px; overflow-y: auto; }
|
||||
`;
|
||||
@@ -633,6 +675,9 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
header_color: '#27ae60',
|
||||
...config,
|
||||
};
|
||||
// Dashboards saved before #1010 stored the English default title; treat it
|
||||
// as unset so the translated default is shown instead.
|
||||
if (this.config.title === "Pending Approvals") this.config.title = "";
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
@@ -646,7 +691,6 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
static getStubConfig() {
|
||||
return {
|
||||
entity: "sensor.taskmate_overview",
|
||||
title: "Pending Approvals",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -732,6 +776,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
`}
|
||||
</div>
|
||||
${this._renderReview()}
|
||||
${this._renderRejectSheet()}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
@@ -894,6 +939,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
${this._designHeader(hd, items.length, completions)}
|
||||
<div class="tmd-bd">${body}</div>
|
||||
${this._renderReview()}
|
||||
${this._renderRejectSheet()}
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
@@ -924,7 +970,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
if (it.kind === "claim") {
|
||||
const approve = () => this._handleApproveReward(it.id);
|
||||
const reject = () => this._handleRejectReward(it.id);
|
||||
const reject = () => this._handleRejectReward(it.id, it.title, it.childName);
|
||||
return this._apActionPair(approve, reject, isLoading, shape);
|
||||
}
|
||||
// mandatory miss: apply penalty (if any) / postpone / dismiss
|
||||
@@ -952,7 +998,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
|
||||
_apActionPair(approve, reject, isLoading, shape, completion = null) {
|
||||
const adjust = completion
|
||||
? html`<button class="btn ghost sm ${shape === "round" ? "round" : ""}" ?disabled="${isLoading}"
|
||||
? html`${shape === "round" ? "" : this._renderStars(completion)}<button class="btn ghost sm ${shape === "round" ? "round" : ""}" ?disabled="${isLoading}"
|
||||
title="${this._t('approvals.award_label')}"
|
||||
@click="${() => this._openReview(completion)}">${this._reviewGlyph(completion)}</button>`
|
||||
: "";
|
||||
@@ -1036,6 +1082,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="ap-cn-title">${it.title}</div>
|
||||
<div class="muted num ap-cn-sub">${(it.childName || '').toUpperCase()} · +${it.points} XP</div>
|
||||
${it.kind === "completion" ? this._renderStars(it.completion, "ap-cn-stars") : ""}
|
||||
</div>
|
||||
${this._apPhotoDesigned(it, "ap-cn-photo")}
|
||||
${this._designActions(it, "round")}
|
||||
@@ -1078,7 +1125,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
return html`
|
||||
<div class="day-group">
|
||||
<div class="day-header">
|
||||
<ha-icon icon="mdi:gift-outline" style="--mdc-icon-size: 18px; vertical-align: -3px; margin-right: 6px;"></ha-icon>
|
||||
<ha-icon icon="mdi:gift-outline" style="--mdc-icon-size: 18px; vertical-align: -3px; margin-inline-end: 6px;"></ha-icon>
|
||||
${this._t('approvals.reward_claims_section')}
|
||||
</div>
|
||||
${claims.map((claim) => this._renderClaimItem(claim))}
|
||||
@@ -1098,7 +1145,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
<div class="action-buttons left">
|
||||
<button
|
||||
class="action-button reject ${isLoading ? 'loading' : ''}"
|
||||
@click="${() => this._handleRejectReward(claimId)}"
|
||||
@click="${() => this._handleRejectReward(claimId, rewardName, childName)}"
|
||||
title="${this._t('approvals.reject')}"
|
||||
?disabled="${isLoading}"
|
||||
>
|
||||
@@ -1107,7 +1154,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<span class="chore-name">
|
||||
<ha-icon icon="${claim.reward_icon || 'mdi:gift'}" style="--mdc-icon-size: 16px; vertical-align: -3px; margin-right: 4px;"></ha-icon>
|
||||
<ha-icon icon="${claim.reward_icon || 'mdi:gift'}" style="--mdc-icon-size: 16px; vertical-align: -3px; margin-inline-end: 4px;"></ha-icon>
|
||||
${rewardName}
|
||||
</span>
|
||||
<div class="item-details">
|
||||
@@ -1139,16 +1186,16 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
await this._callClaimService('approve_reward', claimId);
|
||||
}
|
||||
|
||||
async _handleRejectReward(claimId) {
|
||||
await this._callClaimService('reject_reward', claimId);
|
||||
_handleRejectReward(claimId, name = "", childName = "") {
|
||||
this._openRejectSheet({ kind: "reward", id: claimId, name, childName });
|
||||
}
|
||||
|
||||
async _callClaimService(service, claimId) {
|
||||
async _callClaimService(service, claimId, extra = null) {
|
||||
if (this._loading[claimId]) return;
|
||||
this._loading = { ...this._loading, [claimId]: true };
|
||||
this.requestUpdate();
|
||||
try {
|
||||
await this.hass.callService('taskmate', service, { claim_id: claimId });
|
||||
await this.hass.callService('taskmate', service, { claim_id: claimId, ...(extra || {}) });
|
||||
} catch (error) {
|
||||
console.error(`Failed to call ${service}:`, error);
|
||||
if (this.hass.callService) {
|
||||
@@ -1177,7 +1224,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
return html`
|
||||
<div class="day-group">
|
||||
<div class="day-header">
|
||||
<ha-icon icon="mdi:alert-octagon-outline" style="--mdc-icon-size: 18px; vertical-align: -3px; margin-right: 6px;"></ha-icon>
|
||||
<ha-icon icon="mdi:alert-octagon-outline" style="--mdc-icon-size: 18px; vertical-align: -3px; margin-inline-end: 6px;"></ha-icon>
|
||||
${this._t('approvals.mandatory_section')}
|
||||
</div>
|
||||
${misses.map((miss) => this._renderMissItem(miss))}
|
||||
@@ -1196,7 +1243,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
<div class="approval-item mandatory ${isLoading ? 'loading' : ''}">
|
||||
<div class="item-info">
|
||||
<span class="chore-name">
|
||||
<ha-icon icon="mdi:alert-circle-outline" style="--mdc-icon-size: 16px; vertical-align: -3px; margin-right: 4px;"></ha-icon>
|
||||
<ha-icon icon="mdi:alert-circle-outline" style="--mdc-icon-size: 16px; vertical-align: -3px; margin-inline-end: 4px;"></ha-icon>
|
||||
${choreName}
|
||||
</span>
|
||||
<div class="item-details">
|
||||
@@ -1503,6 +1550,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
<ha-icon icon="mdi:star"></ha-icon>
|
||||
${completion.points}
|
||||
</span>
|
||||
${this._renderStars(completion)}
|
||||
</div>
|
||||
${this._photoHref(completion.photo_url) ? html`
|
||||
<a class="approval-photo" href="${this._photoHref(completion.photo_url)}" target="_blank" rel="noopener"
|
||||
@@ -1533,11 +1581,157 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
|
||||
async _handleApprove(completion) {
|
||||
await this._callService("approve_chore", completion.completion_id);
|
||||
const id = completion.completion_id;
|
||||
const rating = this._ratingMultipliers() ? this._ratings[id] || 0 : 0;
|
||||
await this._callService("approve_chore", id, rating ? { rating } : null);
|
||||
if (rating) this._clearRating(id);
|
||||
}
|
||||
|
||||
async _handleReject(completion) {
|
||||
await this._callService("reject_chore", completion.completion_id);
|
||||
/* ── Quality rating (#927) ────────────────────────────────────────────
|
||||
Published by the pending-approvals sensor only while the feature is on,
|
||||
so its absence is the "off" switch. Picking a star is a selection, not an
|
||||
approval — Approve still does the approving, and tapping the picked star
|
||||
again clears it back to "unrated" (100%). */
|
||||
|
||||
_ratingMultipliers() {
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|
||||
|| this.hass?.states?.[this.config.entity]?.attributes || {};
|
||||
const m = attrs.quality_rating && attrs.quality_rating.multipliers;
|
||||
return Array.isArray(m) && m.length === 3 ? m : null;
|
||||
}
|
||||
|
||||
_pickRating(completion, n) {
|
||||
const id = completion.completion_id;
|
||||
const next = this._ratings[id] === n ? 0 : n;
|
||||
this._ratings = { ...this._ratings, [id]: next };
|
||||
// In the review sheet the parent sees the number they will pay, so a star
|
||||
// rescales it there rather than silently on the server.
|
||||
if (this._review && this._review.completion.completion_id === id) {
|
||||
const input = this.renderRoot && this.renderRoot.querySelector("#tm-review-points");
|
||||
const mults = this._ratingMultipliers();
|
||||
if (input && mults) {
|
||||
const base = Number(this._review.base) || 0;
|
||||
input.value = String(Math.max(0, Math.round(next ? base * mults[next - 1] : base)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_clearRating(id) {
|
||||
if (!(id in this._ratings)) return;
|
||||
const next = { ...this._ratings };
|
||||
delete next[id];
|
||||
this._ratings = next;
|
||||
}
|
||||
|
||||
_renderStars(completion, extraClass = "") {
|
||||
const mults = this._ratingMultipliers();
|
||||
if (!mults || !completion) return "";
|
||||
const id = completion.completion_id;
|
||||
const sel = this._ratings[id] || 0;
|
||||
const busy = !!this._loading[id];
|
||||
return html`<span class="ap-stars ${extraClass}" role="group" aria-label="${this._t('approvals.rating_label')}">
|
||||
${[1, 2, 3].map((n) => {
|
||||
const label = this._t('approvals.rating_star_title', { count: n, percent: Math.round(Number(mults[n - 1]) * 100) });
|
||||
// Filled vs outlined, not just gold vs grey, so the pick reads without colour.
|
||||
return html`<button type="button" class="ap-star ${n <= sel ? "on" : ""}" ?disabled="${busy}"
|
||||
aria-pressed="${n === sel ? "true" : "false"}" title="${label}" aria-label="${label}"
|
||||
@click="${(e) => { e.stopPropagation(); this._pickRating(completion, n); }}">${n <= sel ? "★" : "☆"}</button>`;
|
||||
})}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
_handleReject(completion) {
|
||||
this._openRejectSheet({
|
||||
kind: "chore",
|
||||
id: completion.completion_id,
|
||||
name: completion.chore_name || "",
|
||||
childName: completion.child_name || "",
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Reject with a reason (#976) ─────────────────────────────────────
|
||||
Reject opens a small sheet: quick-pick chips or free text, both
|
||||
optional — an empty box rejects exactly as before. Rendered from both
|
||||
the classic and the designed paths. The input is uncontrolled so a
|
||||
coordinator refresh can't wipe what the parent is typing. */
|
||||
|
||||
_rejectChips() {
|
||||
return ["reject.chip_not_finished", "reject.chip_redo", "reject.chip_not_today"].map(k => this._t(k));
|
||||
}
|
||||
|
||||
_rejectInput() {
|
||||
return this.renderRoot && this.renderRoot.querySelector("#tm-reject-reason");
|
||||
}
|
||||
|
||||
_openRejectSheet(target) {
|
||||
this._rejecting = { ...target, chip: "" };
|
||||
this.updateComplete.then(() => {
|
||||
const input = this._rejectInput();
|
||||
if (!input) return;
|
||||
input.value = "";
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
_closeRejectSheet() {
|
||||
this._rejecting = null;
|
||||
}
|
||||
|
||||
_pickRejectChip(text) {
|
||||
const input = this._rejectInput();
|
||||
const chip = this._rejecting && this._rejecting.chip === text ? "" : text;
|
||||
if (input) input.value = chip;
|
||||
this._rejecting = { ...this._rejecting, chip };
|
||||
}
|
||||
|
||||
async _confirmReject() {
|
||||
const target = this._rejecting;
|
||||
if (!target) return;
|
||||
const input = this._rejectInput();
|
||||
const reason = String((input && input.value) || "").trim().slice(0, 200);
|
||||
this._closeRejectSheet();
|
||||
const extra = reason ? { reason } : null;
|
||||
if (target.kind === "reward") await this._callClaimService("reject_reward", target.id, extra);
|
||||
else await this._callService("reject_chore", target.id, extra);
|
||||
}
|
||||
|
||||
_renderRejectSheet() {
|
||||
const target = this._rejecting;
|
||||
if (!target) return "";
|
||||
const stop = (e) => e.stopPropagation();
|
||||
const onKey = (e) => { if (e.key === "Enter") this._confirmReject(); };
|
||||
const onInput = (e) => {
|
||||
const chip = this._rejectChips().includes(e.target.value) ? e.target.value : "";
|
||||
if (chip !== target.chip) this._rejecting = { ...target, chip };
|
||||
};
|
||||
return html`
|
||||
<div class="tm-rv-overlay" @click="${() => this._closeRejectSheet()}">
|
||||
<div class="tm-rv-sheet tm-rj-sheet" role="dialog" aria-modal="true"
|
||||
aria-label="${this._t('reject.title', { name: target.name })}" @click="${stop}">
|
||||
<div class="tm-rv-title">${this._t('reject.title', { name: target.name })}</div>
|
||||
${target.childName ? html`<div class="tm-rv-sub">${target.childName}</div>` : ""}
|
||||
<label class="tm-rv-label" for="tm-reject-reason">${this._t('reject.reason_label')}</label>
|
||||
<div class="tm-rj-chips">
|
||||
${this._rejectChips().map(c => html`
|
||||
<button type="button" class="tm-rj-chip ${target.chip === c ? "on" : ""}"
|
||||
aria-pressed="${target.chip === c}"
|
||||
@click="${() => this._pickRejectChip(c)}">${c}</button>`)}
|
||||
</div>
|
||||
<input id="tm-reject-reason" class="tm-rv-points tm-rj-input" type="text" maxlength="200"
|
||||
placeholder="${this._t('reject.reason_placeholder')}"
|
||||
@input="${onInput}" @keydown="${onKey}">
|
||||
<div class="tm-rj-hint">${this._t('reject.reason_hint')}</div>
|
||||
<div class="tm-rv-row">
|
||||
<button class="tm-rv-btn ghost" @click="${() => this._closeRejectSheet()}">
|
||||
${this._t('common.cancel')}
|
||||
</button>
|
||||
<button class="tm-rv-btn danger" @click="${() => this._confirmReject()}">
|
||||
${this._t('reject.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async _handleApproveAll(completions) {
|
||||
@@ -1575,12 +1769,15 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
|
||||
_openReview(completion) {
|
||||
this._review = { completion };
|
||||
const base = completion.suggested_points || completion.points || 0;
|
||||
this._review = { completion, base };
|
||||
this.requestUpdate();
|
||||
this.updateComplete.then(() => {
|
||||
const input = this.renderRoot && this.renderRoot.querySelector("#tm-review-points");
|
||||
if (!input) return;
|
||||
input.value = String(completion.suggested_points || completion.points || 0);
|
||||
const mults = this._ratingMultipliers();
|
||||
const picked = this._ratings[completion.completion_id] || 0;
|
||||
input.value = String(mults && picked ? Math.max(0, Math.round(base * mults[picked - 1])) : base);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
@@ -1597,8 +1794,12 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
const input = this.renderRoot && this.renderRoot.querySelector("#tm-review-points");
|
||||
const points = Math.max(0, Math.round(Number(input && input.value) || 0));
|
||||
const completionId = review.completion.completion_id;
|
||||
const rating = this._ratingMultipliers() ? this._ratings[completionId] || 0 : 0;
|
||||
this._closeReview();
|
||||
await this._callService("approve_chore", completionId, { points });
|
||||
// The typed award is paid as-is; a picked rating is recorded with it
|
||||
// (the stars above already rescaled the number the parent confirmed).
|
||||
await this._callService("approve_chore", completionId, rating ? { points, rating } : { points });
|
||||
if (rating) this._clearRating(completionId);
|
||||
}
|
||||
|
||||
_renderReview() {
|
||||
@@ -1624,6 +1825,11 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
|
||||
${photoUrl ? html`<div class="tm-rv-photo"><img src="${photoUrl}" alt="" loading="lazy"></div>` : ""}
|
||||
|
||||
${this._ratingMultipliers() ? html`
|
||||
<div class="tm-rv-label">${this._t('approvals.rating_label')}</div>
|
||||
<div class="tm-rv-stars">${this._renderStars(completion)}</div>
|
||||
` : ""}
|
||||
|
||||
<label class="tm-rv-label" for="tm-review-points">${this._t('approvals.award_label')}</label>
|
||||
<input id="tm-review-points" class="tm-rv-points" type="number"
|
||||
min="0" max="9999" inputmode="numeric">
|
||||
@@ -1697,7 +1903,7 @@ class TaskMateApprovalsCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
// missing that prefix and so matched nothing at all, silently dropping
|
||||
// chore_completions and mandatory_misses from every merge (#798).
|
||||
"sensor.taskmate_pending_approvals",
|
||||
// The bounty board (#931): `bounties` for the bounty card.
|
||||
"sensor.taskmate_bounties",
|
||||
// Chore auctions (#982): `auctions`, a digest with no bid amounts. The
|
||||
// auction card re-fetches its own view over the WebSocket when it moves.
|
||||
"sensor.taskmate_auctions",
|
||||
];
|
||||
|
||||
// Attributes a companion must NOT contribute to the merge, because another
|
||||
@@ -164,8 +169,19 @@
|
||||
*/
|
||||
const TASKMATE_BUTTON_PREFIX = "button.taskmate_";
|
||||
|
||||
// HA's RTL flag for the language, as taskmate-design.js direction() reads it.
|
||||
function isRTL(hass) {
|
||||
const meta = hass.translationMetadata && hass.translationMetadata.translations;
|
||||
const entry = meta && hass.language && meta[hass.language];
|
||||
return entry ? !!entry.isRTL : undefined;
|
||||
}
|
||||
|
||||
function hasRelevantChange(oldHass, newHass, primaryEntityId) {
|
||||
if (!oldHass || !oldHass.states || !newHass || !newHass.states) return true;
|
||||
// A language change (#994) keeps the same states object, but every card
|
||||
// renders translated strings and stamps dir from the language.
|
||||
if (oldHass.language !== newHass.language) return true;
|
||||
if (isRTL(oldHass) !== isRTL(newHass)) return true;
|
||||
if (oldHass.states === newHass.states) return false;
|
||||
|
||||
if (primaryEntityId) {
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
/**
|
||||
* TaskMate Chore Auction Card (#982)
|
||||
*
|
||||
* A reverse auction on one occurrence of an unpopular chore: the child this
|
||||
* card belongs to bids the fewest points they would do it for, lowest bid
|
||||
* wins, and the winner gets that occurrence at their price. Bids are sealed —
|
||||
* the card shows how many bids are in, the child's own bid, and after closing
|
||||
* the winner and the winning price. Never anyone else's amount.
|
||||
*
|
||||
* The auctions are fetched over the `taskmate/auctions/list` WebSocket
|
||||
* command, which applies the linked-child rule and only ever returns this
|
||||
* child's own bid. `sensor.taskmate_auctions` carries a public digest (bid
|
||||
* counts, results); the card re-fetches whenever that digest moves.
|
||||
*
|
||||
* Single layout, so it consumes the design tokens directly (like the bounty
|
||||
* card) rather than carrying a second _renderDesigned() template. Every
|
||||
* --tmd-* reference has the card's original value as its fallback, so classic
|
||||
* — which defines no tokens — keeps the wireframe look. Layout classes carry
|
||||
* an ac- prefix: the shared design kit defines its own .btn / .bar / .chip.
|
||||
*/
|
||||
|
||||
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);
|
||||
const DEFAULT_ACCENT = "#5e35b1";
|
||||
const AUCTIONS_ENTITY = "sensor.taskmate_auctions";
|
||||
// Child avatar colours by position; a design's own palette wins when it has one.
|
||||
const CHILD_COLOURS = ["#ff6b9d", "#3498db", "#1abc9c", "#e67e22", "#9b59b6", "#2ecc71"];
|
||||
|
||||
class TaskMateAuctionCard extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
_auctions: { type: Array },
|
||||
_error: { type: String },
|
||||
_sheet: { type: Object },
|
||||
_busy: { type: Boolean },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._auctions = null;
|
||||
this._error = "";
|
||||
this._sheet = null;
|
||||
this._busy = false;
|
||||
this._tick = null;
|
||||
this._fetchedKey = null;
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
if (!config || !config.entity) throw new Error("You need to define an entity");
|
||||
if (!config.child_id) throw new Error("You need to define a child_id");
|
||||
this.config = config;
|
||||
this._fetchedKey = null;
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", child_id: "" };
|
||||
}
|
||||
|
||||
static getConfigElement() {
|
||||
return document.createElement("taskmate-auction-card-editor");
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return 6;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Countdowns tick client-side from the server's closing times; re-render
|
||||
// once a second only while an auction is actually open.
|
||||
this._tick = setInterval(() => {
|
||||
if ((this._auctions || []).some(a => a.status === "open")) this.requestUpdate();
|
||||
}, 1000);
|
||||
this._fetchedKey = null;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearInterval(this._tick);
|
||||
this._tick = null;
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass") && changedProps.size === 1) {
|
||||
return window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||||
: true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
updated() {
|
||||
// Re-fetch whenever the public digest moves (a bid came in, one closed).
|
||||
const key = this._digestKey();
|
||||
if (this.hass && key !== this._fetchedKey) {
|
||||
this._fetchedKey = key;
|
||||
this._fetch();
|
||||
}
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
_attrs() {
|
||||
return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|
||||
|| this.hass?.states?.[this.config.entity]?.attributes || {};
|
||||
}
|
||||
|
||||
_digestKey() {
|
||||
const digest = this._attrs().auctions || this.hass?.states?.[AUCTIONS_ENTITY]?.attributes?.auctions || [];
|
||||
return `${this.config?.child_id}|${JSON.stringify(digest)}`;
|
||||
}
|
||||
|
||||
_children() {
|
||||
return this._attrs().children || [];
|
||||
}
|
||||
|
||||
_child() {
|
||||
return this._children().find(c => String(c.id) === String(this.config.child_id));
|
||||
}
|
||||
|
||||
_childName(id) {
|
||||
const c = this._children().find(ch => String(ch.id) === String(id));
|
||||
return c ? c.name : "?";
|
||||
}
|
||||
|
||||
_lang() {
|
||||
return this.hass?.locale?.language || this.hass?.language || undefined;
|
||||
}
|
||||
|
||||
/** "Saturday 4 October" (long) or "Sat 4 Oct" for an ISO date. */
|
||||
_date(iso, long = true) {
|
||||
const d = new Date(`${iso}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return iso || "";
|
||||
return d.toLocaleDateString(this._lang(), long
|
||||
? { weekday: "long", day: "numeric", month: "long" }
|
||||
: { weekday: "short", day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
_when(isoInstant) {
|
||||
const d = new Date(isoInstant || "");
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleString(this._lang(), { weekday: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
_fmtDur(ms) {
|
||||
const sec = Math.max(0, Math.round(ms / 1000));
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
if (d) return this._t("bounty.dur_days", { d, h });
|
||||
if (h) return this._t("bounty.dur_hours", { h, m: String(m).padStart(2, "0") });
|
||||
return this._t("bounty.dur_minutes", { m, s: String(s).padStart(2, "0") });
|
||||
}
|
||||
|
||||
_left(a) {
|
||||
const t = Date.parse(a.closes_at || "");
|
||||
return Number.isFinite(t) ? t - Date.now() : 0;
|
||||
}
|
||||
|
||||
_avatar(childId, size = 30) {
|
||||
const children = this._children();
|
||||
const idx = Math.max(0, children.findIndex(c => String(c.id) === String(childId)));
|
||||
const name = this._childName(childId);
|
||||
const colour = `var(--tmd-c${(idx % 6) + 1}, ${CHILD_COLOURS[idx % CHILD_COLOURS.length]})`;
|
||||
return html`<span class="ac-av" style="--ac-ac:${colour};--ac-s:${size}px" title="${name}">${name.charAt(0).toUpperCase()}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the design, stamp data-tm-design on the host (so the :host-scoped
|
||||
* token block applies inside this shadow root) and settle the header colour.
|
||||
* The colour is an inline host property: a <style> in the template is
|
||||
* ordered before adoptedStyleSheets and would lose to the :host default.
|
||||
*/
|
||||
_applyDesign() {
|
||||
const design = window.__taskmate_design
|
||||
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
|
||||
: "classic";
|
||||
const configured = this.config.header_color;
|
||||
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
|
||||
this.style.setProperty("--ac-accent", _safeColor(configured, DEFAULT_ACCENT));
|
||||
} else {
|
||||
this.style.removeProperty("--ac-accent");
|
||||
}
|
||||
return design;
|
||||
}
|
||||
|
||||
// ── data ────────────────────────────────────────────────────────────────
|
||||
|
||||
async _fetch() {
|
||||
const conn = this.hass?.connection;
|
||||
if (!conn || !this.config?.child_id) return;
|
||||
try {
|
||||
const res = await conn.sendMessagePromise({ type: "taskmate/auctions/list", child_id: this.config.child_id });
|
||||
this._auctions = Array.isArray(res?.auctions) ? res.auctions : [];
|
||||
this._error = "";
|
||||
} catch (err) {
|
||||
this._error = String(err?.message || err?.code || err);
|
||||
}
|
||||
}
|
||||
|
||||
async _send(msg) {
|
||||
if (this._busy) return false;
|
||||
this._busy = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise(msg);
|
||||
await this._fetch();
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.dispatchEvent(new CustomEvent("hass-notification", {
|
||||
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
this._busy = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// ── bidding ─────────────────────────────────────────────────────────────
|
||||
|
||||
_openSheet(a) {
|
||||
const start = a.my_bid ?? Math.round((a.min_points + a.max_points) / 2);
|
||||
this._sheet = { id: a.id, value: Math.max(a.min_points, Math.min(a.max_points, start)), fresh: true };
|
||||
}
|
||||
|
||||
_setBid(value, a) {
|
||||
const v = Math.max(0, Math.min(a.max_points, Number(value) || 0));
|
||||
this._sheet = { ...this._sheet, value: v, fresh: false };
|
||||
}
|
||||
|
||||
_key(k, a) {
|
||||
const cur = String(this._sheet.value || "");
|
||||
if (k === "c") return this._setBid(0, a);
|
||||
if (k === "bs") return this._setBid(Number(cur.slice(0, -1)) || 0, a);
|
||||
return this._setBid(Number(this._sheet.fresh ? k : cur + k), a);
|
||||
}
|
||||
|
||||
async _seal(a) {
|
||||
const ok = await this._send({
|
||||
type: "taskmate/auctions/bid", auction_id: a.id, child_id: this.config.child_id, points: this._sheet.value,
|
||||
});
|
||||
if (ok) this._sheet = null;
|
||||
}
|
||||
|
||||
async _withdraw(a) {
|
||||
await this._send({ type: "taskmate/auctions/withdraw", auction_id: a.id, child_id: this.config.child_id });
|
||||
}
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
this._applyDesign();
|
||||
const title = this.config.title || this._t("auction.title");
|
||||
const child = this._child();
|
||||
if (!child) {
|
||||
return html`<ha-card>${this._header(title, null)}<div class="ac-empty">${this._t("auction.no_child")}</div></ha-card>`;
|
||||
}
|
||||
const all = this._auctions || [];
|
||||
const open = all.filter(a => a.status === "open");
|
||||
const done = all.filter(a => a.status !== "open");
|
||||
const sheetAuction = this._sheet && open.find(a => a.id === this._sheet.id);
|
||||
return html`
|
||||
<ha-card class="${sheetAuction ? "ac-sheet-open" : ""}">
|
||||
${this._header(title, this._auctions ? open.length : null)}
|
||||
<div class="ac-content">
|
||||
${this._error ? html`<div class="ac-empty ac-err">${this._t("auction.load_failed", { error: this._error })}</div>` : ""}
|
||||
${open.length
|
||||
? open.map(a => this._lot(a))
|
||||
: (this._auctions && !this._error ? html`<div class="ac-empty">${this._t("auction.empty")}</div>` : "")}
|
||||
${done.length ? html`
|
||||
<div class="ac-sec">${this._t("auction.results")}</div>
|
||||
${done.map(a => this._lot(a))}
|
||||
` : ""}
|
||||
</div>
|
||||
${sheetAuction ? this._renderSheet(sheetAuction) : ""}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_header(title, openCount) {
|
||||
return html`
|
||||
<div class="ac-hd">
|
||||
<span class="ac-hd-gavel"><ha-icon icon="mdi:gavel"></ha-icon></span>
|
||||
<div class="ac-hd-text">
|
||||
<div class="ac-hd-title">${title}</div>
|
||||
<div class="ac-hd-sub">${this._t("auction.subtitle")}</div>
|
||||
</div>
|
||||
${openCount !== null ? html`<span class="ac-hd-pill">${this._t("auction.open_count", { count: openCount })}</span>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_otherBids(a) {
|
||||
const others = Math.max(0, (a.bid_count || 0) - (a.my_bid != null ? 1 : 0));
|
||||
if (!others) return this._t("auction.no_other_bids");
|
||||
return others === 1 ? this._t("auction.other_bids_one") : this._t("auction.other_bids", { count: others });
|
||||
}
|
||||
|
||||
_gauge(a) {
|
||||
return html`
|
||||
<div class="ac-gauge">
|
||||
<div class="ac-gauge-row">
|
||||
<span>${a.min_points} ★</span><span class="ac-caps">${this._t("auction.lowest_wins")}</span><span>${this._t("auction.gauge_max", { points: a.max_points })}</span>
|
||||
</div>
|
||||
<div class="ac-gauge-bar"></div>
|
||||
<div class="ac-gauge-hint">${this._t("auction.gauge_hint", { points: a.normal_points })}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_lot(a) {
|
||||
const me = String(this.config.child_id);
|
||||
const head = html`
|
||||
<div class="ac-lot-top">
|
||||
<span class="ac-lot-ic"><ha-icon icon="${a.icon || "mdi:clipboard-check-outline"}"></ha-icon></span>
|
||||
<div class="ac-grow">
|
||||
<div class="ac-lot-t">${a.chore_name}</div>
|
||||
<div class="ac-lot-when"><ha-icon icon="mdi:calendar-blank-outline"></ha-icon>${this._date(a.occurrence)}</div>
|
||||
</div>
|
||||
${a.status === "open" ? html`<span class="ac-tag ac-tag-max">${this._t("auction.up_to", { points: a.max_points })}</span>` : ""}
|
||||
</div>`;
|
||||
|
||||
if (a.status === "open") {
|
||||
const mine = a.my_bid != null;
|
||||
return html`
|
||||
<div class="ac-lot">
|
||||
${head}
|
||||
${mine ? html`
|
||||
<div class="ac-sealed">
|
||||
<span class="ac-env"><ha-icon icon="mdi:email-lock"></ha-icon></span>
|
||||
<div class="ac-grow"><small>${this._t("auction.your_bid")}</small><b>${a.my_bid} ★</b></div>
|
||||
<button class="ac-btn ac-ghost ac-sm" ?disabled=${this._busy} @click=${() => this._openSheet(a)}>${this._t("auction.change")}</button>
|
||||
</div>` : this._gauge(a)}
|
||||
<div class="ac-lot-foot">
|
||||
<span class="ac-cdown"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("auction.closes_in", { time: this._fmtDur(this._left(a)) })}</span>
|
||||
<span class="ac-bidcount"><ha-icon icon="mdi:email-outline"></ha-icon>${this._otherBids(a)}</span>
|
||||
<span class="ac-grow"></span>
|
||||
${mine
|
||||
? html`<button class="ac-btn ac-text ac-sm" ?disabled=${this._busy} @click=${() => this._withdraw(a)}>${this._t("auction.withdraw")}</button>`
|
||||
: html`<button class="ac-btn ac-bid" ?disabled=${this._busy} @click=${() => this._openSheet(a)}><ha-icon icon="mdi:gavel"></ha-icon>${this._t("auction.place_bid")}</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const closed = html`<div class="ac-lot-foot"><span class="ac-faint">${this._t("auction.closed_at", { when: this._when(a.closed_at) })}</span></div>`;
|
||||
if (!a.winner_id) {
|
||||
return html`
|
||||
<div class="ac-lot ac-nobid">
|
||||
${head}
|
||||
<div class="ac-result ac-nob">
|
||||
<span class="ac-ri"><ha-icon icon="mdi:email-outline"></ha-icon></span>
|
||||
<div>${this._t("auction.no_bids")}<small>${this._t("auction.no_bids_sub", { chore: a.chore_name, date: this._date(a.occurrence, false) })}</small></div>
|
||||
</div>
|
||||
${closed}
|
||||
</div>`;
|
||||
}
|
||||
if (String(a.winner_id) === me) {
|
||||
return html`
|
||||
<div class="ac-lot ac-won">
|
||||
${head}
|
||||
<div class="ac-result ac-win">
|
||||
<span class="ac-ri"><ha-icon icon="mdi:trophy"></ha-icon></span>
|
||||
<div>${this._t("auction.you_won", { points: a.price })}<small>${this._t("auction.you_won_sub", { date: this._date(a.occurrence, false) })}</small></div>
|
||||
</div>
|
||||
<div class="ac-won-chore">
|
||||
<ha-icon icon="${a.icon || "mdi:clipboard-check-outline"}"></ha-icon>
|
||||
<span class="ac-grow ac-won-nm">${a.chore_name}</span>
|
||||
<span class="ac-tag ac-tag-won"><ha-icon icon="mdi:gavel"></ha-icon>${this._t("child.won_at_auction")}</span>
|
||||
<span class="ac-pts"><ha-icon icon="mdi:star"></ha-icon>${a.price}</span>
|
||||
</div>
|
||||
<div class="ac-lot-foot"><span class="ac-faint">${this._t("auction.in_your_chores", { date: this._date(a.occurrence, false) })}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
// Lowest bid wins and the earliest breaks a tie, so a losing bid equal to
|
||||
// the price lost on timing, not on the amount.
|
||||
const winner = this._childName(a.winner_id);
|
||||
const sub = a.my_bid == null ? this._t("auction.sibling_won_sub", { points: a.price })
|
||||
: Number(a.my_bid) === Number(a.price) ? this._t("auction.tied_sub", { points: a.price, name: winner })
|
||||
: this._t("auction.sibling_won_sub_bid", { points: a.price, bid: a.my_bid });
|
||||
return html`
|
||||
<div class="ac-lot ac-lost">
|
||||
${head}
|
||||
<div class="ac-result ac-lose">
|
||||
<span class="ac-ri ac-ri-av">${this._avatar(a.winner_id)}</span>
|
||||
<div>${this._t("auction.sibling_won", { name: winner })}<small>${sub}</small></div>
|
||||
</div>
|
||||
${closed}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderSheet(a) {
|
||||
const v = this._sheet.value;
|
||||
const valid = v >= a.min_points && v <= a.max_points;
|
||||
const others = Math.max(0, (a.bid_count || 0) - (a.my_bid != null ? 1 : 0));
|
||||
const close = () => { this._sheet = null; };
|
||||
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||||
return html`
|
||||
<div class="ac-scrim" @click=${e => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div class="ac-sheet" role="dialog" aria-modal="true" aria-label="${a.chore_name}">
|
||||
<div class="ac-grab"></div>
|
||||
<h3>${a.chore_name}</h3>
|
||||
<div class="ac-sheet-sub">${this._t("auction.sheet_sub", { date: this._date(a.occurrence), time: this._fmtDur(this._left(a)) })}</div>
|
||||
<div class="ac-bigbid">${v} <ha-icon icon="mdi:star"></ha-icon></div>
|
||||
<div class="ac-bidlbl">${this._t("auction.bid_label")}</div>
|
||||
<input type="range" class="ac-range" min="${a.min_points}" max="${a.max_points}" .value=${String(Math.max(a.min_points, v))}
|
||||
aria-label="${this._t("auction.bid_label")}"
|
||||
@input=${e => this._setBid(e.target.value, a)}>
|
||||
<div class="ac-rng-l"><span>${this._t("auction.range_low", { points: a.min_points })}</span><span>${this._t("auction.range_high", { points: a.max_points })}</span></div>
|
||||
<div class="ac-keypad">
|
||||
${keys.map(k => html`<button type="button" @click=${() => this._key(k, a)}>${k}</button>`)}
|
||||
<button type="button" class="ac-key-clear" @click=${() => this._key("c", a)}>${this._t("auction.clear")}</button>
|
||||
<button type="button" @click=${() => this._key("0", a)}>0</button>
|
||||
<button type="button" aria-label="${this._t("auction.backspace")}" @click=${() => this._key("bs", a)}><ha-icon class="tm-rtl-flip" icon="mdi:backspace-outline"></ha-icon></button>
|
||||
</div>
|
||||
<div class="ac-rules">
|
||||
<div><ha-icon icon="mdi:lock-outline"></ha-icon><span>${this._t("auction.rule_secret")}${others ? ` ${others === 1 ? this._t("auction.other_bids_one") : this._t("auction.other_bids", { count: others })}.` : ""}</span></div>
|
||||
<div><ha-icon icon="mdi:trophy-outline"></ha-icon><span>${this._t("auction.rule_lowest")}</span></div>
|
||||
${a.min_points > 1 ? html`<div><ha-icon icon="mdi:arrow-collapse-down"></ha-icon><span>${this._t("auction.rule_min", { points: a.min_points })}</span></div>` : ""}
|
||||
<div><ha-icon icon="mdi:check"></ha-icon><span>${this._t("auction.rule_win", { date: this._date(a.occurrence, false) })}</span></div>
|
||||
</div>
|
||||
<div class="ac-sheet-acts">
|
||||
<button type="button" class="ac-btn ac-ghost" @click=${close}>${this._t("auction.cancel")}</button>
|
||||
<button type="button" class="ac-btn ac-bid ac-seal" ?disabled=${!valid || this._busy} @click=${() => this._seal(a)}>
|
||||
<ha-icon icon="mdi:email-lock"></ha-icon>${this._t("auction.seal", { points: v })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host { display: block; --ac-accent: var(--tmd-accent, #5e35b1); --ac-auc: #7e57c2; }
|
||||
ha-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
||||
color: var(--tmd-text, var(--primary-text-color));
|
||||
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
|
||||
font-family: var(--tmd-font-body, inherit);
|
||||
}
|
||||
ha-card.ac-sheet-open { min-height: 700px; }
|
||||
.ac-hd {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 18px;
|
||||
background: var(--ac-accent);
|
||||
color: var(--tmd-hd-text, #fff);
|
||||
}
|
||||
:host([data-tm-design="playroom"]) .ac-hd {
|
||||
background: linear-gradient(135deg, var(--ac-accent), color-mix(in srgb, var(--ac-accent) 72%, #fff));
|
||||
}
|
||||
.ac-hd-gavel {
|
||||
width: 48px; height: 48px; flex: none; border-radius: 14px;
|
||||
display: grid; place-items: center;
|
||||
background: rgba(255, 255, 255, .18);
|
||||
}
|
||||
.ac-hd-gavel ha-icon { --mdc-icon-size: 28px; }
|
||||
.ac-hd-text { flex: 1; min-width: 0; }
|
||||
.ac-hd-title {
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
font-size: 1.3rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.ac-hd-sub { font-size: .8rem; opacity: .85; margin-top: 1px; }
|
||||
.ac-hd-pill {
|
||||
flex: none; white-space: nowrap;
|
||||
background: rgba(255, 255, 255, .2);
|
||||
padding: 4px 12px; border-radius: 16px;
|
||||
font-size: .9rem; font-weight: 500;
|
||||
}
|
||||
.ac-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.ac-empty {
|
||||
text-align: center; padding: 30px 12px;
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.ac-err { color: var(--tmd-bad, var(--error-color, #c62828)); padding: 12px; }
|
||||
.ac-sec {
|
||||
font-size: .8rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
margin: 4px 2px -4px;
|
||||
}
|
||||
.ac-grow { flex: 1; min-width: 0; }
|
||||
.ac-faint { font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
|
||||
.ac-lot {
|
||||
border-radius: var(--tmd-radius-sm, 18px);
|
||||
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
||||
border: 2px solid color-mix(in srgb, var(--ac-auc) 45%, var(--tmd-border, var(--divider-color, #e0e0e0)));
|
||||
overflow: hidden;
|
||||
}
|
||||
.ac-lot.ac-won { border-color: var(--tmd-gold, #f1c40f); }
|
||||
.ac-lot.ac-lost, .ac-lot.ac-nobid { border-color: var(--tmd-border, var(--divider-color, #e0e0e0)); opacity: .85; }
|
||||
.ac-lot-top { display: flex; gap: 12px; padding: 14px 14px 10px; align-items: flex-start; }
|
||||
.ac-lot-ic {
|
||||
width: 46px; height: 46px; flex: none; border-radius: 14px;
|
||||
display: grid; place-items: center; color: #fff;
|
||||
background: linear-gradient(135deg, var(--ac-auc), var(--ac-accent));
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, var(--ac-accent) 40%, transparent);
|
||||
}
|
||||
.ac-lot-ic ha-icon { --mdc-icon-size: 24px; }
|
||||
:host([data-tm-design="accessible"]) .ac-lot-ic { box-shadow: none; border: 2px solid var(--tmd-border, #111); }
|
||||
.ac-lot-t {
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
font-size: 1.12rem; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere;
|
||||
}
|
||||
.ac-lot-when {
|
||||
display: flex; align-items: center; gap: 6px; margin-top: 2px;
|
||||
font-size: .85rem; color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.ac-lot-when ha-icon { --mdc-icon-size: 14px; }
|
||||
.ac-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px; flex: none;
|
||||
font-size: .72rem; font-weight: 700;
|
||||
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
|
||||
background: var(--tmd-surface-2, rgba(127, 127, 127, .12));
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
border: 1px solid var(--tmd-border, transparent);
|
||||
}
|
||||
.ac-tag ha-icon { --mdc-icon-size: 12px; }
|
||||
.ac-tag-max, .ac-tag-won {
|
||||
background: color-mix(in srgb, var(--ac-auc) 16%, transparent);
|
||||
color: color-mix(in srgb, var(--ac-auc) 70%, var(--tmd-text, var(--primary-text-color)));
|
||||
}
|
||||
|
||||
.ac-gauge {
|
||||
margin: 0 14px; padding: 10px 12px; border-radius: 12px;
|
||||
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
|
||||
border: 1px solid var(--tmd-border, transparent);
|
||||
}
|
||||
.ac-gauge-row {
|
||||
display: flex; justify-content: space-between; gap: 8px;
|
||||
font-size: .75rem; font-weight: 600; letter-spacing: .02em;
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.ac-caps { text-transform: uppercase; }
|
||||
.ac-gauge-bar {
|
||||
height: 10px; border-radius: 5px; margin: 6px 0;
|
||||
background: linear-gradient(90deg, var(--tmd-good, #2ecc71), var(--tmd-gold, #f1c40f) 60%, var(--tmd-warn, #e67e22));
|
||||
}
|
||||
/* Low (green) sits at the reading start, beside the minimum label. */
|
||||
:host([dir="rtl"]) .ac-gauge-bar { transform: scaleX(-1); }
|
||||
.ac-gauge-hint { font-size: .78rem; text-align: center; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
|
||||
.ac-sealed {
|
||||
margin: 0 14px; padding: 12px;
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--ac-auc) 14%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, var(--ac-auc) 60%, transparent);
|
||||
}
|
||||
.ac-env { width: 44px; height: 40px; flex: none; display: grid; place-items: center; color: var(--ac-auc); }
|
||||
.ac-env ha-icon { --mdc-icon-size: 36px; }
|
||||
.ac-sealed small { display: block; font-size: .78rem; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
.ac-sealed b { font-size: 1.4rem; font-family: var(--tmd-font-display, inherit); }
|
||||
|
||||
.ac-lot-foot { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-wrap: wrap; }
|
||||
.ac-cdown {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-weight: 700; font-size: .9rem; font-variant-numeric: tabular-nums;
|
||||
color: color-mix(in srgb, var(--ac-auc) 75%, var(--tmd-text, var(--primary-text-color)));
|
||||
}
|
||||
.ac-cdown ha-icon { --mdc-icon-size: 16px; }
|
||||
.ac-bidcount {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: .8rem; color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.ac-bidcount ha-icon { --mdc-icon-size: 14px; }
|
||||
|
||||
.ac-result {
|
||||
margin: 0 14px; padding: 12px 14px; border-radius: 14px;
|
||||
display: flex; gap: 12px; align-items: center; font-weight: 600;
|
||||
}
|
||||
.ac-result small { display: block; font-weight: 500; font-size: .82rem; opacity: .85; margin-top: 2px; }
|
||||
.ac-win { background: linear-gradient(135deg, var(--tmd-gold, #f1c40f), #f39c12); color: #3b2a00; }
|
||||
.ac-lose, .ac-nob {
|
||||
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
|
||||
border: 1px solid var(--tmd-border, transparent);
|
||||
}
|
||||
.ac-nob { color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
.ac-ri {
|
||||
width: 42px; height: 42px; flex: none; border-radius: 50%;
|
||||
display: grid; place-items: center; background: rgba(255, 255, 255, .35);
|
||||
}
|
||||
.ac-ri-av { background: transparent; }
|
||||
.ac-won-chore {
|
||||
margin: 10px 14px 0; padding: 10px 12px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
border-radius: 16px;
|
||||
border: 3px solid var(--tmd-gold, #f1c40f);
|
||||
background: color-mix(in srgb, var(--tmd-gold, #f1c40f) 8%, transparent);
|
||||
}
|
||||
.ac-won-nm { font-weight: 700; }
|
||||
.ac-pts {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-weight: 700; color: var(--tmd-warn, #e67e22);
|
||||
}
|
||||
.ac-pts ha-icon { --mdc-icon-size: 16px; }
|
||||
.ac-av {
|
||||
width: var(--ac-s, 30px); height: var(--ac-s, 30px); flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--ac-ac, #9b59b6); color: #fff;
|
||||
display: inline-grid; place-items: center;
|
||||
font-weight: 700; font-size: calc(var(--ac-s, 30px) * .42); line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ac-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
border: 0; cursor: pointer;
|
||||
border-radius: var(--tmd-radius-sm, 10px);
|
||||
padding: 9px 16px; min-height: 36px;
|
||||
font: inherit; font-family: var(--tmd-font-display, inherit);
|
||||
font-size: .92rem; font-weight: 600; color: #fff; white-space: nowrap;
|
||||
}
|
||||
.ac-btn ha-icon { --mdc-icon-size: 17px; }
|
||||
.ac-btn[disabled] { opacity: .45; cursor: default; }
|
||||
.ac-sm { padding: 6px 12px; min-height: 30px; font-size: .82rem; }
|
||||
.ac-bid { background: linear-gradient(135deg, var(--ac-auc), var(--ac-accent)); }
|
||||
.ac-text { background: transparent; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
.ac-ghost {
|
||||
background: transparent; color: var(--tmd-text, var(--primary-text-color));
|
||||
border: 1px solid var(--tmd-border, var(--divider-color, #ccc));
|
||||
}
|
||||
|
||||
.ac-scrim {
|
||||
position: absolute; inset: 0; z-index: 5;
|
||||
display: flex; align-items: flex-end;
|
||||
background: rgba(0, 0, 0, .6);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.ac-sheet {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 10px 18px 20px;
|
||||
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
||||
color: var(--tmd-text, var(--primary-text-color));
|
||||
border-radius: 26px 26px 0 0;
|
||||
border: 1px solid var(--tmd-border, transparent);
|
||||
box-shadow: 0 -10px 40px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.ac-grab {
|
||||
width: 40px; height: 5px; border-radius: 3px; margin: 0 auto 10px;
|
||||
background: var(--tmd-border, var(--divider-color, #9aa3ad));
|
||||
}
|
||||
.ac-sheet h3 { margin: 0; font-size: 1.1rem; text-align: center; font-family: var(--tmd-font-display, inherit); }
|
||||
.ac-sheet-sub { text-align: center; font-size: .85rem; margin: 2px 0 8px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
.ac-bigbid {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
font-size: 3.2rem; font-weight: 800; line-height: 1;
|
||||
margin: 8px 0 2px; font-variant-numeric: tabular-nums;
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
}
|
||||
.ac-bigbid ha-icon { --mdc-icon-size: 38px; color: var(--tmd-gold, #f1c40f); }
|
||||
.ac-bidlbl { text-align: center; font-size: .8rem; margin-bottom: 8px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
.ac-range { width: 100%; height: 28px; accent-color: var(--ac-auc); margin: 0; }
|
||||
.ac-rng-l {
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: .72rem; color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.ac-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 12px 0; }
|
||||
.ac-keypad button {
|
||||
height: 44px; border-radius: 12px; cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
font: inherit; font-size: 1.2rem; font-weight: 600;
|
||||
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .12)));
|
||||
color: var(--tmd-text, var(--primary-text-color));
|
||||
border: 1px solid var(--tmd-border, transparent);
|
||||
}
|
||||
.ac-keypad .ac-key-clear { font-size: .9rem; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
.ac-rules {
|
||||
font-size: .8rem; line-height: 1.5;
|
||||
padding: 10px 12px; margin-bottom: 12px; border-radius: 12px;
|
||||
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.ac-rules div { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.ac-rules ha-icon { --mdc-icon-size: 14px; margin-top: 2px; flex: none; color: var(--ac-auc); }
|
||||
.ac-sheet-acts { display: flex; gap: 10px; }
|
||||
.ac-sheet-acts .ac-ghost { flex: 1; }
|
||||
.ac-sheet-acts .ac-seal { flex: 2; }
|
||||
`;
|
||||
// The token block is :host-scoped, so it only takes effect when included
|
||||
// in THIS card's styles — a document-level rule cannot cross into the
|
||||
// shadow root.
|
||||
const tokens = window.__taskmate_design && window.__taskmate_design.styles
|
||||
? window.__taskmate_design.styles() : null;
|
||||
return tokens ? [tokens, base] : base;
|
||||
}
|
||||
}
|
||||
|
||||
class TaskMateAuctionCardEditor extends LitElement {
|
||||
static get properties() {
|
||||
return { hass: { type: Object }, config: { type: Object } };
|
||||
}
|
||||
|
||||
setConfig(config) { this.config = config; }
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
_buildSchema() {
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|
||||
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
|
||||
const children = attrs.children || [];
|
||||
return [
|
||||
{ name: "entity", selector: { entity: { domain: "sensor" } } },
|
||||
{
|
||||
name: "child_id",
|
||||
selector: {
|
||||
select: { options: children.map(c => ({ value: c.id, label: c.name })), mode: "dropdown" },
|
||||
},
|
||||
},
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "card_design",
|
||||
selector: {
|
||||
select: {
|
||||
options: window.__taskmate_design
|
||||
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
||||
: [{ value: "global", label: "Use global default" }],
|
||||
mode: "dropdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => ({
|
||||
entity: this._t("common.editor.overview_entity"),
|
||||
child_id: this._t("auction.editor.child"),
|
||||
title: this._t("common.editor.card_title"),
|
||||
card_design: this._t("common.design.field_label"),
|
||||
}[entry.name] ?? entry.name);
|
||||
|
||||
_computeHelper = (entry) => ({
|
||||
entity: this._t("common.editor.overview_entity_helper"),
|
||||
child_id: this._t("auction.editor.child_helper"),
|
||||
}[entry.name] ?? "");
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || "",
|
||||
child_id: this.config.child_id || "",
|
||||
title: this.config.title || "",
|
||||
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", DEFAULT_ACCENT)}
|
||||
`;
|
||||
}
|
||||
|
||||
_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 || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
// "global" is the absence of a per-card override, not a value to store.
|
||||
if (value === "" || value === null || value === undefined || (key === "card_design" && value === "global")) {
|
||||
delete newConfig[key];
|
||||
} else {
|
||||
newConfig[key] = value;
|
||||
}
|
||||
}
|
||||
this._fire(newConfig);
|
||||
}
|
||||
|
||||
_updateConfig(key, value) {
|
||||
const newConfig = { ...this.config, [key]: value };
|
||||
if (value === null || value === "" || value === undefined) delete newConfig[key];
|
||||
this._fire(newConfig);
|
||||
}
|
||||
|
||||
_fire(config) {
|
||||
this.dispatchEvent(new CustomEvent("config-changed", {
|
||||
detail: { config }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-auction-card", TaskMateAuctionCard);
|
||||
customElements.define("taskmate-auction-card-editor", TaskMateAuctionCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-auction-card",
|
||||
name: "TaskMate Chore Auction",
|
||||
description: "Children bid the fewest points they'd do an unpopular chore for — lowest bid wins",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-auction-card", "overview"),
|
||||
});
|
||||
@@ -21,6 +21,9 @@ const css = LitElement.prototype.css;
|
||||
const DESIGN_HEADER = "#6c5ce7";
|
||||
|
||||
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
|
||||
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
|
||||
// identity until the design layer has loaded (it is also what stamps dir).
|
||||
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
||||
|
||||
class TaskMateBadgesCard extends LitElement {
|
||||
static get properties() {
|
||||
@@ -324,7 +327,7 @@ class TaskMateBadgesCard extends LitElement {
|
||||
.badge-bonus {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
background: linear-gradient(135deg, var(--t), color-mix(in srgb, var(--t), black 15%));
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
@@ -374,7 +377,7 @@ class TaskMateBadgesCard extends LitElement {
|
||||
.bd-bonus {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--tmd-good);
|
||||
@@ -506,8 +509,8 @@ class TaskMateBadgesCard extends LitElement {
|
||||
|
||||
const latestEarned = earned.length > 0 ? earned[0] : null;
|
||||
const countLabel = earned.length > 0 && latestEarned
|
||||
? this._t("badges.count_label_latest", { earned: earned.length, total: totalBadges, latest: latestEarned.name })
|
||||
: this._t("badges.count_label", { earned: earned.length, total: totalBadges });
|
||||
? _ltrNums(this._t("badges.count_label_latest", { earned: earned.length, total: totalBadges, latest: latestEarned.name }))
|
||||
: _ltrNums(this._t("badges.count_label", { earned: earned.length, total: totalBadges }));
|
||||
|
||||
const title = this.config.title || (childName ? this._t("badges.title_with_name", { name: childName }) : this._t("badges.default_title"));
|
||||
|
||||
@@ -561,7 +564,7 @@ class TaskMateBadgesCard extends LitElement {
|
||||
<ha-icon icon="${b.icon || 'mdi:medal'}"></ha-icon>
|
||||
</div>
|
||||
<div class="badge-tier">${this._tierLabel(b.tier)}</div>
|
||||
<div class="badge-name">${b.name}</div>
|
||||
<div class="badge-name">${_ltrNums(b.name)}</div>
|
||||
<div class="badge-meta">${this._t("badge.earned_date", { date: this._formatDate(b.earned_at) })}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -571,8 +574,8 @@ class TaskMateBadgesCard extends LitElement {
|
||||
// progress: either provided directly, or computed from closest_criterion
|
||||
const pct = (b.progress_pct != null) ? b.progress_pct
|
||||
: (b.closest_criterion ? Math.min(100, Math.round((b.closest_criterion.current / b.closest_criterion.target) * 100)) : 0);
|
||||
const progressLabel = b.progress_label
|
||||
|| (b.closest_criterion ? `${b.closest_criterion.current} / ${b.closest_criterion.target}` : null);
|
||||
const crit = b.closest_criterion;
|
||||
const progressLabel = _ltrNums(b.progress_label || (crit ? `${crit.current} / ${crit.target}` : null));
|
||||
|
||||
return html`
|
||||
<div class="badge locked tier-${b.tier}">
|
||||
@@ -581,7 +584,7 @@ class TaskMateBadgesCard extends LitElement {
|
||||
<ha-icon icon="${b.icon || 'mdi:medal-outline'}"></ha-icon>
|
||||
</div>
|
||||
<div class="badge-tier">${this._tierLabel(b.tier)}</div>
|
||||
<div class="badge-name">${b.name}</div>
|
||||
<div class="badge-name">${_ltrNums(b.name)}</div>
|
||||
${progressLabel ? html`
|
||||
<div class="badge-progress">
|
||||
<div class="badge-progress-fill" style="width:${pct}%"></div>
|
||||
@@ -626,7 +629,7 @@ TaskMateBadgesCard.prototype._designEarnedTile = function (b) {
|
||||
<div class="bd-tile earned" style="--bt:${tone}">
|
||||
${b.point_bonus > 0 ? html`<div class="bd-bonus">+${b.point_bonus}</div>` : ""}
|
||||
<div class="bd-ic earned" style="--bt:${tone}">${icon}</div>
|
||||
<div class="bd-name">${b.name}</div>
|
||||
<div class="bd-name">${_ltrNums(b.name)}</div>
|
||||
<div class="muted bd-date">${this._formatDate(b.earned_at)}</div>
|
||||
</div>`;
|
||||
};
|
||||
@@ -635,15 +638,15 @@ TaskMateBadgesCard.prototype._designLockedTile = function (b) {
|
||||
const tone = this._tierTone(b.tier);
|
||||
const pct = (b.progress_pct != null) ? b.progress_pct
|
||||
: (b.closest_criterion ? Math.min(100, Math.round((b.closest_criterion.current / b.closest_criterion.target) * 100)) : 0);
|
||||
const progressLabel = b.progress_label
|
||||
|| (b.closest_criterion ? `${b.closest_criterion.current} / ${b.closest_criterion.target}` : null);
|
||||
const crit = b.closest_criterion;
|
||||
const progressLabel = _ltrNums(b.progress_label || (crit ? `${crit.current} / ${crit.target}` : null));
|
||||
const a = b.icon || "mdi:medal-outline";
|
||||
const icon = a.startsWith("mdi:") ? html`<ha-icon icon="${a}"></ha-icon>` : a;
|
||||
return html`
|
||||
<div class="bd-tile locked" style="--bt:${tone}">
|
||||
${b.point_bonus > 0 ? html`<div class="bd-bonus" style="opacity:0.45">+${b.point_bonus}</div>` : ""}
|
||||
<div class="bd-ic locked">${icon}</div>
|
||||
<div class="bd-name">${b.name}</div>
|
||||
<div class="bd-name">${_ltrNums(b.name)}</div>
|
||||
${progressLabel ? html`
|
||||
<div class="bar" style="height:7px;margin-top:6px"><i style="width:${pct}%"></i></div>
|
||||
<div class="muted bd-prog-label">${progressLabel}</div>` : ""}
|
||||
@@ -654,7 +657,7 @@ TaskMateBadgesCard.prototype._renderDesigned = function (design, d) {
|
||||
const hd = _safeColor(this.config.header_color, DESIGN_HEADER);
|
||||
const ic = design === "console" ? "✦" : design === "cleanpro" ? "◈" : "🎖️";
|
||||
const sub = d.childName;
|
||||
const countPill = this._t("badges.count_label", { earned: d.earned.length, total: d.totalBadges });
|
||||
const countPill = _ltrNums(this._t("badges.count_label", { earned: d.earned.length, total: d.totalBadges }));
|
||||
|
||||
return html`
|
||||
<ha-card class="tmd" style="--hd:${hd}">
|
||||
@@ -666,7 +669,7 @@ TaskMateBadgesCard.prototype._renderDesigned = function (design, d) {
|
||||
<div class="tmd-bd">
|
||||
${this._designFilters(d.filter)}
|
||||
${(d.filteredEarned.length + d.filteredLocked.length) === 0
|
||||
? html`<div class="tmd-empty">${this._t("badges.count_label", { earned: 0, total: d.totalBadges })}</div>`
|
||||
? html`<div class="tmd-empty">${_ltrNums(this._t("badges.count_label", { earned: 0, total: d.totalBadges }))}</div>`
|
||||
: html`<div class="bd-grid">
|
||||
${d.filteredEarned.map((b) => this._designEarnedTile(b))}
|
||||
${d.filteredLocked.map((b) => this._designLockedTile(b))}
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* TaskMate Bounty Board Card (#931)
|
||||
*
|
||||
* One-off jobs a parent posts for a fixed number of points. The child this
|
||||
* card belongs to sees the bounties they may claim, claims one (it's theirs
|
||||
* for the claim lock), marks it done — with a photo when the bounty asks for
|
||||
* one — and it goes through the normal approval queue. A sibling's claim shows
|
||||
* dimmed with the time until it returns to the board; bounties the child can't
|
||||
* claim are hidden.
|
||||
*
|
||||
* Reads the `bounties` attribute of sensor.taskmate_bounties through the
|
||||
* attribute resolver, and `children` from the overview entity.
|
||||
*
|
||||
* Single layout, so it consumes the design tokens directly (like the routine
|
||||
* card) rather than carrying a second _renderDesigned() template. Every
|
||||
* --tmd-* reference has the card's original value as its fallback, so classic
|
||||
* — which defines no tokens — keeps the wireframe look. Layout classes carry a
|
||||
* bb- prefix: the shared design kit defines its own .btn / .bar / .av / .chip.
|
||||
*/
|
||||
|
||||
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);
|
||||
const DEFAULT_ACCENT = "#e67e22";
|
||||
const BOUNTIES_ENTITY = "sensor.taskmate_bounties";
|
||||
// Child avatar colours by position; a design's own palette wins when it has one.
|
||||
const CHILD_COLOURS = ["#ff6b9d", "#3498db", "#1abc9c", "#e67e22", "#9b59b6", "#2ecc71"];
|
||||
|
||||
class TaskMateBountyCard extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
_dialog: { type: Object },
|
||||
_busy: { type: Boolean },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._dialog = null;
|
||||
this._busy = false;
|
||||
this._tick = null;
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
if (!config || !config.entity) throw new Error("You need to define an entity");
|
||||
if (!config.child_id) throw new Error("You need to define a child_id");
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", child_id: "" };
|
||||
}
|
||||
|
||||
static getConfigElement() {
|
||||
return document.createElement("taskmate-bounty-card-editor");
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return 6;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Countdowns tick client-side from the server's timestamps. Re-render once
|
||||
// a second only while something on the board is actually counting down.
|
||||
this._tick = setInterval(() => {
|
||||
if (this._hasCountdown()) this.requestUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearInterval(this._tick);
|
||||
this._tick = null;
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass") && changedProps.size === 1) {
|
||||
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;
|
||||
}
|
||||
|
||||
_attrs() {
|
||||
return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|
||||
|| this.hass?.states?.[this.config.entity]?.attributes || {};
|
||||
}
|
||||
|
||||
_bounties() {
|
||||
const attrs = this._attrs();
|
||||
const list = attrs.bounties || this.hass?.states?.[BOUNTIES_ENTITY]?.attributes?.bounties;
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
_children() {
|
||||
return this._attrs().children || [];
|
||||
}
|
||||
|
||||
_child() {
|
||||
return this._children().find(c => String(c.id) === String(this.config.child_id));
|
||||
}
|
||||
|
||||
_childName(id) {
|
||||
const c = this._children().find(ch => String(ch.id) === String(id));
|
||||
return c ? c.name : "?";
|
||||
}
|
||||
|
||||
_eligible(b, childId) {
|
||||
return !Array.isArray(b.eligible) || b.eligible.length === 0 || b.eligible.map(String).includes(String(childId));
|
||||
}
|
||||
|
||||
_hasCountdown() {
|
||||
return this._bounties().some(b => b.status === "claimed" || (b.status === "open" && b.expires_at));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the design, stamp data-tm-design on the host (so the :host-scoped
|
||||
* token block applies inside this shadow root) and settle the header colour.
|
||||
* The colour is an inline host property: a <style> in the template is
|
||||
* ordered before adoptedStyleSheets and would lose to the :host default.
|
||||
*/
|
||||
_applyDesign() {
|
||||
const design = window.__taskmate_design
|
||||
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
|
||||
: "classic";
|
||||
const configured = this.config.header_color;
|
||||
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
|
||||
this.style.setProperty("--bb-accent", _safeColor(configured, DEFAULT_ACCENT));
|
||||
} else {
|
||||
this.style.removeProperty("--bb-accent");
|
||||
}
|
||||
return design;
|
||||
}
|
||||
|
||||
_fmtDur(ms) {
|
||||
const sec = Math.max(0, Math.round(ms / 1000));
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
if (d) return this._t("bounty.dur_days", { d, h });
|
||||
if (h) return this._t("bounty.dur_hours", { h, m: String(m).padStart(2, "0") });
|
||||
return this._t("bounty.dur_minutes", { m, s: String(s).padStart(2, "0") });
|
||||
}
|
||||
|
||||
_msUntil(iso, now) {
|
||||
const t = Date.parse(iso || "");
|
||||
return Number.isFinite(t) ? t - now : null;
|
||||
}
|
||||
|
||||
_avatar(childId, size = 22) {
|
||||
const children = this._children();
|
||||
const idx = Math.max(0, children.findIndex(c => String(c.id) === String(childId)));
|
||||
const name = this._childName(childId);
|
||||
const colour = `var(--tmd-c${(idx % 6) + 1}, ${CHILD_COLOURS[idx % CHILD_COLOURS.length]})`;
|
||||
return html`<span class="bb-av" style="--bb-ac:${colour};--bb-s:${size}px" title="${name}">${name.charAt(0).toUpperCase()}</span>`;
|
||||
}
|
||||
|
||||
// ── actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _call(service, data) {
|
||||
if (this._busy) return false;
|
||||
this._busy = true;
|
||||
try {
|
||||
await this.hass.callService("taskmate", service, data);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.dispatchEvent(new CustomEvent("hass-notification", {
|
||||
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
this._busy = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
async _claim(b) {
|
||||
const ok = await this._call("claim_bounty", { bounty_id: b.id, child_id: this.config.child_id });
|
||||
if (ok) this._dialog = null;
|
||||
}
|
||||
|
||||
async _giveBack(b) {
|
||||
await this._call("give_back_bounty", { bounty_id: b.id, child_id: this.config.child_id });
|
||||
}
|
||||
|
||||
async _done(b) {
|
||||
if (b.require_photo) {
|
||||
this._dialog = { kind: "photo", id: b.id };
|
||||
return;
|
||||
}
|
||||
const ok = await this._call("complete_bounty", { bounty_id: b.id, child_id: this.config.child_id });
|
||||
if (ok) this._dialog = { kind: "yay", id: b.id };
|
||||
}
|
||||
|
||||
async _undo(b) {
|
||||
await this._call("undo_chore", { completion_id: b.completion_id });
|
||||
}
|
||||
|
||||
_pickPhoto() {
|
||||
const input = this.renderRoot && this.renderRoot.querySelector("#bb-photo");
|
||||
if (input) input.click();
|
||||
}
|
||||
|
||||
/** Shrink the picked photo to a ≤1280px JPEG, upload it, then submit. */
|
||||
async _photoPicked(e, b) {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
this._dialog = { kind: "photo", id: b.id, uploading: true };
|
||||
try {
|
||||
const blob = await this._shrink(file);
|
||||
const photoUrl = await this._upload(blob);
|
||||
this._dialog = null;
|
||||
const ok = await this._call("complete_bounty", {
|
||||
bounty_id: b.id, child_id: this.config.child_id, photo_url: photoUrl,
|
||||
});
|
||||
if (ok) this._dialog = { kind: "yay", id: b.id };
|
||||
} catch (_err) {
|
||||
this._dialog = { kind: "photo", id: b.id, error: this._t("child.photo_upload_failed") };
|
||||
}
|
||||
}
|
||||
|
||||
_shrink(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const max = 1280;
|
||||
let { width, height } = img;
|
||||
if (width > max || height > max) {
|
||||
const scale = Math.min(max / width, max / height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error("no blob"))), "image/jpeg", 0.8);
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("bad image")); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async _upload(blob) {
|
||||
// Bearer-authenticated POST, refreshing a stale access token once on 401 —
|
||||
// the same upload the child card's photo chores use.
|
||||
const post = () => {
|
||||
const token = this.hass?.auth?.data?.access_token;
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, "evidence.jpg");
|
||||
return fetch("/api/taskmate/photo", {
|
||||
method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd,
|
||||
});
|
||||
};
|
||||
if (this.hass?.auth?.expired && this.hass.auth.refreshAccessToken) {
|
||||
try { await this.hass.auth.refreshAccessToken(); } catch (_e) { /* retried below */ }
|
||||
}
|
||||
let resp = await post();
|
||||
if (resp.status === 401 && this.hass?.auth?.refreshAccessToken) {
|
||||
await this.hass.auth.refreshAccessToken();
|
||||
resp = await post();
|
||||
}
|
||||
if (!resp.ok) throw new Error(`upload failed (${resp.status})`);
|
||||
const { photo_url: photoUrl } = await resp.json();
|
||||
if (!photoUrl) throw new Error("no photo_url");
|
||||
return photoUrl;
|
||||
}
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
this._applyDesign();
|
||||
const child = this._child();
|
||||
const title = this.config.title || this._t("bounty.title");
|
||||
if (!child) {
|
||||
return html`<ha-card>${this._header(title, null)}<div class="bb-empty">${this._t("bounty.no_child")}</div></ha-card>`;
|
||||
}
|
||||
const me = String(child.id);
|
||||
const now = Date.now();
|
||||
const all = this._bounties();
|
||||
const myClaim = all.find(b => b.status === "claimed" && String(b.claimed_by) === me);
|
||||
// Hidden unless the child may claim it — or it's theirs.
|
||||
const visible = all.filter(b => this._eligible(b, me) || String(b.claimed_by || "") === me);
|
||||
const active = visible.filter(b => b.status !== "completed");
|
||||
const recent = visible.filter(b => b.status === "completed");
|
||||
const openCount = visible.filter(b => b.status === "open").length;
|
||||
const dialogBounty = this._dialog && all.find(b => b.id === this._dialog.id);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
${this._header(title, openCount)}
|
||||
<div class="bb-content">
|
||||
${active.length
|
||||
? active.map(b => this._row(b, me, now, !!myClaim))
|
||||
: html`<div class="bb-empty">${this._t("bounty.empty")}</div>`}
|
||||
${recent.length ? html`
|
||||
<div class="bb-sec">${this._t("bounty.recent")}</div>
|
||||
${recent.map(b => this._row(b, me, now, !!myClaim))}
|
||||
` : ""}
|
||||
</div>
|
||||
${dialogBounty ? this._renderDialog(dialogBounty) : ""}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_header(title, openCount) {
|
||||
return html`
|
||||
<div class="bb-hd">
|
||||
<ha-icon class="bb-hd-icon" icon="mdi:flag-outline"></ha-icon>
|
||||
<div class="bb-hd-text">
|
||||
<div class="bb-hd-title">${title}</div>
|
||||
<div class="bb-hd-sub">${this._t("bounty.subtitle")}</div>
|
||||
</div>
|
||||
${openCount !== null ? html`<span class="bb-hd-pill">${this._t("bounty.open_count", { count: openCount })}</span>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_row(b, me, now, holdingClaim) {
|
||||
const mine = String(b.claimed_by || "") === me;
|
||||
const eligibleIds = Array.isArray(b.eligible) && b.eligible.length ? b.eligible : this._children().map(c => c.id);
|
||||
const expLeft = b.expires_at ? this._msUntil(b.expires_at, now) : null;
|
||||
const showMeta = b.status === "open" || b.status === "claimed";
|
||||
let cls = "";
|
||||
let body = "";
|
||||
|
||||
if (b.status === "open") {
|
||||
body = html`
|
||||
<div class="bb-act">
|
||||
<button class="bb-btn bb-claim" ?disabled=${holdingClaim || this._busy}
|
||||
@click=${() => { this._dialog = { kind: "claim", id: b.id }; }}>
|
||||
<ha-icon icon="mdi:hand-back-right-outline"></ha-icon>${this._t("bounty.claim")}
|
||||
</button>
|
||||
<span class="bb-hint">${holdingClaim
|
||||
? this._t("bounty.finish_first")
|
||||
: this._t("bounty.claim_hint", { hours: b.claim_hours })}</span>
|
||||
</div>`;
|
||||
} else if (b.status === "claimed" && mine) {
|
||||
cls = "mine";
|
||||
const left = this._msUntil(b.claim_until, now) ?? 0;
|
||||
const span = (Date.parse(b.claim_until || "") - Date.parse(b.claimed_at || "")) || (b.claim_hours * 3600e3);
|
||||
const frac = Math.max(0, Math.min(1, left / span));
|
||||
body = html`
|
||||
<div class="bb-claim-line"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("bounty.claimed_by_you", { time: this._fmtDur(left) })}</div>
|
||||
<div class="bb-lock"><i style="width:${(frac * 100).toFixed(1)}%"></i></div>
|
||||
<div class="bb-act">
|
||||
<button class="bb-btn bb-done" ?disabled=${this._busy} @click=${() => this._done(b)}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this._t("bounty.done")}
|
||||
</button>
|
||||
<button class="bb-btn bb-text" ?disabled=${this._busy} @click=${() => this._giveBack(b)}>${this._t("bounty.give_back")}</button>
|
||||
</div>`;
|
||||
} else if (b.status === "claimed") {
|
||||
cls = "locked";
|
||||
const left = this._msUntil(b.claim_until, now) ?? 0;
|
||||
body = html`
|
||||
<div class="bb-sib">${this._avatar(b.claimed_by, 20)}
|
||||
<span><b>${this._t("bounty.sibling_on_it", { name: this._childName(b.claimed_by) })}</b> — ${this._t("bounty.sibling_back_in", { time: this._fmtDur(left) })}</span>
|
||||
</div>`;
|
||||
} else if (b.status === "pending" && mine) {
|
||||
body = html`
|
||||
<div class="bb-act">
|
||||
<span class="bb-tag warn bb-wait"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("bounty.waiting", { points: b.points })}</span>
|
||||
${b.undo && b.completion_id ? html`
|
||||
<button class="bb-btn bb-text" ?disabled=${this._busy} @click=${() => this._undo(b)}>
|
||||
<ha-icon class="tm-rtl-flip" icon="mdi:undo-variant"></ha-icon>${this._t("bounty.undo")}
|
||||
</button>` : ""}
|
||||
</div>`;
|
||||
} else if (b.status === "pending") {
|
||||
cls = "locked";
|
||||
body = html`<div class="bb-sib">${this._avatar(b.claimed_by, 20)}<span>${this._t("bounty.sibling_checking", { name: this._childName(b.claimed_by) })}</span></div>`;
|
||||
} else if (b.status === "completed") {
|
||||
body = mine
|
||||
? html`<div class="bb-act"><span class="bb-tag good"><ha-icon icon="mdi:check"></ha-icon>${this._t("bounty.you_earned", { points: b.points_awarded ?? b.points })}</span></div>`
|
||||
: html`<div class="bb-sib">${this._avatar(b.claimed_by, 20)}<span>${this._t("bounty.done_by", { name: this._childName(b.claimed_by) })}</span></div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="bb-row ${cls}">
|
||||
<div class="bb-cost"><b>${b.points}</b><small>★ ${this._t("bounty.pts")}</small></div>
|
||||
<div class="bb-main">
|
||||
<div class="bb-title-row">
|
||||
<span class="bb-ic"><ha-icon icon="${b.icon || "mdi:flag-outline"}"></ha-icon></span>
|
||||
<div class="bb-title">${b.title}</div>
|
||||
</div>
|
||||
${b.description ? html`<div class="bb-desc">${b.description}</div>` : ""}
|
||||
<div class="bb-meta">
|
||||
${showMeta ? (expLeft !== null
|
||||
? html`<span class="bb-tag ${expLeft < 6 * 3600e3 ? "warn" : ""}"><ha-icon icon="mdi:clock-outline"></ha-icon>${this._t("bounty.left", { time: this._fmtDur(expLeft) })}</span>`
|
||||
: html`<span class="bb-tag"><ha-icon icon="mdi:clock-outline"></ha-icon>${this._t("bounty.no_expiry")}</span>`) : ""}
|
||||
<span class="bb-stack">${eligibleIds.map(id => this._avatar(id, 22))}</span>
|
||||
${b.require_photo ? html`<span class="bb-tag purple"><ha-icon icon="mdi:camera-outline"></ha-icon>${this._t("bounty.photo_tag")}</span>` : ""}
|
||||
</div>
|
||||
${body}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderDialog(b) {
|
||||
const d = this._dialog;
|
||||
const close = () => { this._dialog = null; };
|
||||
let inner = "";
|
||||
if (d.kind === "claim") {
|
||||
inner = html`
|
||||
<div class="bb-dlg" style="--bb-dlg:var(--bb-accent)">
|
||||
<div class="bb-big-ic"><ha-icon icon="${b.icon || "mdi:flag-outline"}"></ha-icon></div>
|
||||
<h3>${this._t("bounty.claim_title", { title: b.title })}</h3>
|
||||
<p>${this._t("bounty.claim_body", { hours: b.claim_hours })}</p>
|
||||
<div class="bb-dlg-acts">
|
||||
<button class="bb-btn bb-ghost" @click=${close}>${this._t("bounty.not_now")}</button>
|
||||
<button class="bb-btn bb-claim" ?disabled=${this._busy} @click=${() => this._claim(b)}>${this._t("bounty.claim_it", { points: b.points })}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} else if (d.kind === "photo") {
|
||||
inner = html`
|
||||
<div class="bb-dlg" style="--bb-dlg:#9b59b6">
|
||||
<h3>${this._t("bounty.photo_title")}</h3>
|
||||
<p>${this._t("bounty.photo_body", { title: b.title })}</p>
|
||||
<div class="bb-photo-ph"><ha-icon icon="mdi:camera-outline"></ha-icon></div>
|
||||
${d.error ? html`<p class="bb-err">${d.error}</p>` : ""}
|
||||
<input id="bb-photo" type="file" accept="image/*" capture="environment" hidden
|
||||
@change=${e => this._photoPicked(e, b)}>
|
||||
<div class="bb-dlg-acts">
|
||||
<button class="bb-btn bb-ghost" @click=${close}>${this._t("bounty.cancel")}</button>
|
||||
<button class="bb-btn bb-done" ?disabled=${!!d.uploading || this._busy} @click=${() => this._pickPhoto()}>
|
||||
<ha-icon icon="mdi:camera-outline"></ha-icon>${d.uploading ? this._t("bounty.uploading") : this._t("bounty.take_photo")}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} else if (d.kind === "yay") {
|
||||
inner = html`
|
||||
<div class="bb-dlg" style="--bb-dlg:var(--tmd-good, #2ecc71)">
|
||||
<div class="bb-big-ic"><ha-icon icon="mdi:check"></ha-icon></div>
|
||||
<h3>${this._t("bounty.yay_title")}</h3>
|
||||
<p>${this._t("bounty.yay_body", { title: b.title, points: b.points })}</p>
|
||||
<div class="bb-dlg-acts"><button class="bb-btn bb-done" @click=${close}>${this._t("bounty.ok")}</button></div>
|
||||
</div>`;
|
||||
}
|
||||
return html`<div class="bb-scrim" @click=${e => { if (e.target === e.currentTarget) close(); }}>${inner}</div>`;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host { display: block; --bb-accent: var(--tmd-accent, #e67e22); }
|
||||
ha-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
||||
color: var(--tmd-text, var(--primary-text-color));
|
||||
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
|
||||
font-family: var(--tmd-font-body, inherit);
|
||||
}
|
||||
.bb-hd {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 18px;
|
||||
background: var(--bb-accent);
|
||||
color: var(--tmd-hd-text, #fff);
|
||||
}
|
||||
:host([data-tm-design="playroom"]) .bb-hd {
|
||||
background: linear-gradient(135deg, var(--bb-accent), color-mix(in srgb, var(--bb-accent) 72%, #fff));
|
||||
}
|
||||
.bb-hd-icon { --mdc-icon-size: 30px; flex: none; opacity: .95; }
|
||||
.bb-hd-text { flex: 1; min-width: 0; }
|
||||
.bb-hd-title {
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
font-size: 1.3rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.bb-hd-sub { font-size: .8rem; opacity: .85; margin-top: 1px; }
|
||||
.bb-hd-pill {
|
||||
flex: none; white-space: nowrap;
|
||||
background: rgba(255, 255, 255, .2);
|
||||
padding: 4px 12px; border-radius: 16px;
|
||||
font-size: .9rem; font-weight: 500;
|
||||
}
|
||||
.bb-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.bb-empty {
|
||||
text-align: center; padding: 30px 12px;
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.bb-sec {
|
||||
font-size: .8rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
margin: 4px 2px -4px;
|
||||
}
|
||||
|
||||
.bb-row {
|
||||
display: flex; gap: 14px; align-items: flex-start;
|
||||
padding: 14px;
|
||||
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
||||
border: 1px solid var(--tmd-border, var(--divider-color, #e0e0e0));
|
||||
border-radius: var(--tmd-radius-sm, 14px);
|
||||
}
|
||||
.bb-row.mine {
|
||||
border: 2px solid var(--tmd-warn, #e67e22);
|
||||
background: color-mix(in srgb, var(--tmd-warn, #e67e22) 8%, var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff))));
|
||||
}
|
||||
.bb-row.locked { opacity: .62; }
|
||||
.bb-cost {
|
||||
flex: none; min-width: 66px; padding: 10px 6px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
border-radius: var(--tmd-radius-sm, 12px);
|
||||
background: linear-gradient(135deg, var(--tmd-gold, #f1c40f), color-mix(in srgb, var(--tmd-gold, #f39c12) 80%, #e67e22));
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(241, 196, 15, .25);
|
||||
}
|
||||
.bb-cost b {
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
font-size: 1.35rem; line-height: 1; text-shadow: 0 1px 2px rgba(0, 0, 0, .2);
|
||||
}
|
||||
.bb-cost small { font-size: .62rem; font-weight: 700; letter-spacing: .5px; margin-top: 3px; opacity: .95; }
|
||||
:host([data-tm-design="accessible"]) .bb-cost { color: #111; box-shadow: none; border: 2px solid var(--tmd-border, #111); }
|
||||
:host([data-tm-design="accessible"]) .bb-cost b { text-shadow: none; }
|
||||
.bb-main { flex: 1; min-width: 0; }
|
||||
.bb-title-row { display: flex; align-items: center; gap: 10px; }
|
||||
.bb-ic {
|
||||
flex: none; width: 30px; height: 30px; border-radius: 9px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
|
||||
color: var(--bb-accent);
|
||||
}
|
||||
.bb-ic ha-icon { --mdc-icon-size: 18px; }
|
||||
.bb-title {
|
||||
flex: 1; min-width: 0;
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
font-size: 1.1rem; font-weight: 700; line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.bb-desc { font-size: .88rem; color: var(--tmd-dim, var(--secondary-text-color)); margin-top: 4px; }
|
||||
.bb-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; }
|
||||
.bb-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: .72rem; font-weight: 700;
|
||||
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
|
||||
background: var(--tmd-surface-2, rgba(127, 127, 127, .12));
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
border: 1px solid var(--tmd-border, transparent);
|
||||
}
|
||||
.bb-tag ha-icon { --mdc-icon-size: 13px; }
|
||||
.bb-tag.warn { background: color-mix(in srgb, var(--tmd-warn, #f39c12) 16%, transparent); color: var(--tmd-warn, #d68910); }
|
||||
.bb-tag.good { background: color-mix(in srgb, var(--tmd-good, #2ecc71) 16%, transparent); color: var(--tmd-good, #239b56); font-size: .8rem; padding: 4px 10px; }
|
||||
.bb-tag.purple { background: rgba(155, 89, 182, .18); color: #8e44ad; }
|
||||
.bb-tag.bb-wait { font-size: .8rem; padding: 5px 10px; white-space: normal; border-radius: 10px; }
|
||||
:host([data-tm-dark]) .bb-tag.purple { color: #c39bd3; }
|
||||
.bb-stack { display: inline-flex; }
|
||||
.bb-stack .bb-av + .bb-av { margin-inline-start: -8px; }
|
||||
.bb-av {
|
||||
width: var(--bb-s, 22px); height: var(--bb-s, 22px); flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--bb-ac, #9b59b6); color: #fff;
|
||||
display: inline-grid; place-items: center;
|
||||
font-weight: 700; font-size: calc(var(--bb-s, 22px) * .45); line-height: 1;
|
||||
border: 2px solid var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bb-act { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
||||
.bb-hint { font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
.bb-claim-line {
|
||||
display: flex; align-items: center; gap: 6px; margin-top: 8px;
|
||||
font-size: .9rem; font-weight: 600; color: var(--tmd-warn, #d68910);
|
||||
}
|
||||
.bb-claim-line ha-icon { --mdc-icon-size: 16px; }
|
||||
.bb-lock {
|
||||
height: 6px; margin-top: 8px; overflow: hidden; border-radius: 3px;
|
||||
background: var(--tmd-surface-2, var(--divider-color, #e5e7eb));
|
||||
border: 1px solid var(--tmd-border, transparent);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bb-lock > i { display: block; height: 100%; background: var(--tmd-warn, #e67e22); transition: width .9s linear; }
|
||||
.bb-sib {
|
||||
display: flex; align-items: flex-start; gap: 8px; margin-top: 8px;
|
||||
font-size: .88rem; line-height: 1.35;
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.bb-sib b { color: var(--tmd-text, var(--primary-text-color)); }
|
||||
|
||||
.bb-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
border: 0; cursor: pointer;
|
||||
border-radius: var(--tmd-radius-sm, 10px);
|
||||
padding: 9px 16px; min-height: 36px;
|
||||
font: inherit; font-family: var(--tmd-font-display, inherit);
|
||||
font-size: .92rem; font-weight: 600; color: #fff;
|
||||
}
|
||||
.bb-btn ha-icon { --mdc-icon-size: 17px; }
|
||||
.bb-btn[disabled] { opacity: .45; cursor: default; }
|
||||
.bb-claim { background: var(--tmd-warn, #e67e22); padding: 6px 12px; font-size: .85rem; }
|
||||
:host([data-tm-design]:not([data-tm-design="classic"])) .bb-claim { color: #2b1700; }
|
||||
.bb-done { background: var(--tmd-good, linear-gradient(135deg, #2ecc71, #27ae60)); }
|
||||
:host([data-tm-design]:not([data-tm-design="classic"])) .bb-done { color: #06301f; }
|
||||
.bb-text { background: transparent; color: var(--tmd-dim, var(--secondary-text-color)); padding: 9px 8px; }
|
||||
.bb-ghost {
|
||||
background: transparent; color: var(--tmd-text, var(--primary-text-color));
|
||||
border: 1px solid var(--tmd-border, var(--divider-color, #ccc));
|
||||
}
|
||||
|
||||
.bb-scrim {
|
||||
position: absolute; inset: 0; z-index: 5;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px; background: rgba(0, 0, 0, .55);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.bb-dlg {
|
||||
width: 100%; max-width: 340px; box-sizing: border-box;
|
||||
padding: 22px; text-align: center;
|
||||
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
||||
color: var(--tmd-text, var(--primary-text-color));
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--tmd-border, transparent);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.bb-dlg h3 { margin: 0 0 6px; font-size: 1.2rem; font-family: var(--tmd-font-display, inherit); }
|
||||
.bb-dlg p { margin: 0 0 16px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
||||
.bb-big-ic {
|
||||
width: 64px; height: 64px; margin: 0 auto 10px; border-radius: 50%;
|
||||
display: grid; place-items: center;
|
||||
background: color-mix(in srgb, var(--bb-dlg, #e67e22) 22%, transparent);
|
||||
color: var(--bb-dlg, #e67e22);
|
||||
}
|
||||
.bb-big-ic ha-icon { --mdc-icon-size: 34px; }
|
||||
.bb-photo-ph {
|
||||
height: 130px; margin-bottom: 14px; border-radius: 14px;
|
||||
display: grid; place-items: center;
|
||||
background: repeating-linear-gradient(45deg, var(--tmd-surface-2, rgba(127, 127, 127, .14)) 0 12px, transparent 12px 24px);
|
||||
color: var(--tmd-dim, var(--secondary-text-color));
|
||||
}
|
||||
.bb-photo-ph ha-icon { --mdc-icon-size: 40px; }
|
||||
.bb-err { color: var(--tmd-bad, var(--error-color, #c62828)) !important; }
|
||||
.bb-dlg-acts { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
|
||||
`;
|
||||
// The token block is :host-scoped, so it only takes effect when included
|
||||
// in THIS card's styles — a document-level rule cannot cross into the
|
||||
// shadow root.
|
||||
const tokens = window.__taskmate_design && window.__taskmate_design.styles
|
||||
? window.__taskmate_design.styles() : null;
|
||||
return tokens ? [tokens, base] : base;
|
||||
}
|
||||
}
|
||||
|
||||
class TaskMateBountyCardEditor extends LitElement {
|
||||
static get properties() {
|
||||
return { hass: { type: Object }, config: { type: Object } };
|
||||
}
|
||||
|
||||
setConfig(config) { this.config = config; }
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
_buildSchema() {
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|
||||
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
|
||||
const children = attrs.children || [];
|
||||
return [
|
||||
{ name: "entity", selector: { entity: { domain: "sensor" } } },
|
||||
{
|
||||
name: "child_id",
|
||||
selector: {
|
||||
select: { options: children.map(c => ({ value: c.id, label: c.name })), mode: "dropdown" },
|
||||
},
|
||||
},
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "card_design",
|
||||
selector: {
|
||||
select: {
|
||||
options: window.__taskmate_design
|
||||
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
||||
: [{ value: "global", label: "Use global default" }],
|
||||
mode: "dropdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => ({
|
||||
entity: this._t("common.editor.overview_entity"),
|
||||
child_id: this._t("bounty.editor.child"),
|
||||
title: this._t("common.editor.card_title"),
|
||||
card_design: this._t("common.design.field_label"),
|
||||
}[entry.name] ?? entry.name);
|
||||
|
||||
_computeHelper = (entry) => ({
|
||||
entity: this._t("common.editor.overview_entity_helper"),
|
||||
child_id: this._t("bounty.editor.child_helper"),
|
||||
}[entry.name] ?? "");
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || "",
|
||||
child_id: this.config.child_id || "",
|
||||
title: this.config.title || "",
|
||||
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", DEFAULT_ACCENT)}
|
||||
`;
|
||||
}
|
||||
|
||||
_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 || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
// "global" is the absence of a per-card override, not a value to store.
|
||||
if (value === "" || value === null || value === undefined || (key === "card_design" && value === "global")) {
|
||||
delete newConfig[key];
|
||||
} else {
|
||||
newConfig[key] = value;
|
||||
}
|
||||
}
|
||||
this._fire(newConfig);
|
||||
}
|
||||
|
||||
_updateConfig(key, value) {
|
||||
const newConfig = { ...this.config, [key]: value };
|
||||
if (value === null || value === "" || value === undefined) delete newConfig[key];
|
||||
this._fire(newConfig);
|
||||
}
|
||||
|
||||
_fire(config) {
|
||||
this.dispatchEvent(new CustomEvent("config-changed", {
|
||||
detail: { config }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-bounty-card", TaskMateBountyCard);
|
||||
customElements.define("taskmate-bounty-card-editor", TaskMateBountyCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-bounty-card",
|
||||
name: "TaskMate Bounty Board",
|
||||
description: "One-off jobs any child can claim, finish and get approved",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-bounty-card", "overview"),
|
||||
});
|
||||
@@ -13,6 +13,9 @@ 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);
|
||||
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
|
||||
// identity until the design layer has loaded (it is also what stamps dir).
|
||||
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
||||
|
||||
const DAY_NAMES = [
|
||||
"monday", "tuesday", "wednesday", "thursday",
|
||||
@@ -185,10 +188,10 @@ class TaskMateCalendarCard extends LitElement {
|
||||
padding: 6px 10px;
|
||||
background: var(--card-background-color, white);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--cal-grey);
|
||||
border-inline-start: 4px solid var(--cal-grey);
|
||||
}
|
||||
.chore-row.approved { border-left-color: var(--cal-green); }
|
||||
.chore-row.pending { border-left-color: var(--cal-amber); }
|
||||
.chore-row.approved { border-inline-start-color: var(--cal-green); }
|
||||
.chore-row.pending { border-inline-start-color: var(--cal-amber); }
|
||||
.chore-row.rotating { opacity: 0.6; font-style: italic; }
|
||||
|
||||
.chore-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color); }
|
||||
@@ -261,7 +264,7 @@ class TaskMateCalendarCard extends LitElement {
|
||||
.cal-card-cn { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: 8px; padding: 10px 11px; }
|
||||
.cal-head-cn { margin-bottom: 7px; }
|
||||
.cal-name-cn { font-weight: 700; font-size: 12.5px; }
|
||||
.cal-count { margin-left: auto; font-size: 10px; }
|
||||
.cal-count { margin-inline-start: auto; font-size: 10px; }
|
||||
|
||||
/* Clean Pro */
|
||||
.cal-grid-cp { gap: 0; }
|
||||
@@ -310,6 +313,17 @@ class TaskMateCalendarCard extends LitElement {
|
||||
_isChoreScheduledOn(chore, dayDow, dayDate, todayKey, tz) {
|
||||
if (chore.enabled === false) return false;
|
||||
|
||||
// One occurrence moved or removed from the HA calendar (#977):
|
||||
// {scheduled date: new date, or "" when removed}. Mirrors the backend's
|
||||
// occurrence_override — a day something was moved onto is on, a day
|
||||
// something was moved away from or removed is off.
|
||||
const moved = chore.moved_occurrences;
|
||||
if (moved && typeof moved === "object") {
|
||||
const key = ymd(dayDate, tz);
|
||||
if (Object.values(moved).includes(key)) return true;
|
||||
if (Object.prototype.hasOwnProperty.call(moved, key)) return false;
|
||||
}
|
||||
|
||||
const scheduleMode = chore.schedule_mode || "specific_days";
|
||||
const createdDate = chore.created_date || "";
|
||||
|
||||
@@ -394,7 +408,19 @@ class TaskMateCalendarCard extends LitElement {
|
||||
return assignedTo.includes(childId);
|
||||
}
|
||||
|
||||
// What the chore pays ``childId`` on ``dayKey``: a won auction occurrence
|
||||
// pays the winning bid, days ahead included (#998).
|
||||
_dayPoints(chore, childId, dayKey) {
|
||||
const won = (chore.auction_wins || {})[dayKey];
|
||||
const price = (chore.auction_prices || {})[dayKey];
|
||||
if (won && String(won) === String(childId) && Number.isFinite(price)) return price;
|
||||
return chore.points;
|
||||
}
|
||||
|
||||
_rotationRenderMode(chore, childId, dayKey, todayKey) {
|
||||
// Won at auction (#982): that day's occurrence is the winner's alone.
|
||||
const won = (chore.auction_wins || {})[dayKey];
|
||||
if (won) return String(won) === String(childId) ? "active" : "hidden";
|
||||
const mode = chore.assignment_mode || "everyone";
|
||||
if (mode === "everyone") return "active";
|
||||
const current = chore.assignment_current_child_id || "";
|
||||
@@ -483,11 +509,11 @@ class TaskMateCalendarCard extends LitElement {
|
||||
</div>
|
||||
<div class="day-nav">
|
||||
<button @click=${() => this._shiftDay(-1)} title=${this._t("calendar.prev_day")}>
|
||||
<ha-icon icon="mdi:chevron-left"></ha-icon>
|
||||
<ha-icon class="tm-rtl-flip" icon="mdi:chevron-left"></ha-icon>
|
||||
</button>
|
||||
<span class="day-label">${isToday ? this._t("common.today") : dayLabel}</span>
|
||||
<button @click=${() => this._shiftDay(1)} title=${this._t("calendar.next_day")}>
|
||||
<ha-icon icon="mdi:chevron-right"></ha-icon>
|
||||
<ha-icon class="tm-rtl-flip" icon="mdi:chevron-right"></ha-icon>
|
||||
</button>
|
||||
${!isToday ? html`
|
||||
<button @click=${() => this._resetDay()} title=${this._t("common.today")}>
|
||||
@@ -556,7 +582,7 @@ class TaskMateCalendarCard extends LitElement {
|
||||
<ha-icon class="chore-icon ${state}" icon="${stateIcon}"></ha-icon>
|
||||
<span class="chore-name">${chore.name}</span>
|
||||
<span class="chore-points">
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>${chore.points}
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>${this._dayPoints(chore, child.id, day.key)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -564,7 +590,7 @@ class TaskMateCalendarCard extends LitElement {
|
||||
|
||||
const summary = dueCount === 0
|
||||
? this._t("calendar.no_chores_today")
|
||||
: this._t("calendar.child_summary", { done: doneCount, total: dueCount });
|
||||
: _ltrNums(this._t("calendar.child_summary", { done: doneCount, total: dueCount }));
|
||||
|
||||
return html`
|
||||
<div class="child-block">
|
||||
@@ -667,7 +693,7 @@ class TaskMateCalendarCard extends LitElement {
|
||||
|
||||
const sub = isToday ? this._t("common.today") : dayShort;
|
||||
const pill = design === "console" && totalPending
|
||||
? this._t("calendar.child_summary", { done: 0, total: totalPending }) : "";
|
||||
? _ltrNums(this._t("calendar.child_summary", { done: 0, total: totalPending })) : "";
|
||||
return wrap(sub, pill, body);
|
||||
}
|
||||
|
||||
@@ -779,7 +805,7 @@ class TaskMateCalendarCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,8 +151,8 @@
|
||||
.tmd-hd .ic{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;background:rgba(255,255,255,.22);font-size:17px;flex:none}
|
||||
.tmd-hd .tt{font-family:var(--tmd-font-display);font-weight:800;font-size:16px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.tmd-hd .tt small{display:block;font-family:var(--tmd-font-body);font-weight:600;font-size:11px;opacity:.85;margin-top:1px}
|
||||
.tmd-hd .pill{margin-left:auto;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.25);text-transform:capitalize}
|
||||
.tmd-hd .cnt{margin-left:auto;min-width:22px;height:22px;padding:0 6px;border-radius:999px;background:var(--tmd-bad);color:#fff;font-size:12px;font-weight:800;display:grid;place-items:center}
|
||||
.tmd-hd .pill{margin-inline-start:auto;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.25);text-transform:capitalize}
|
||||
.tmd-hd .cnt{margin-inline-start:auto;min-width:22px;height:22px;padding:0 6px;border-radius:999px;background:var(--tmd-bad);color:#fff;font-size:12px;font-weight:800;display:grid;place-items:center}
|
||||
.tmd-bd{padding:15px}
|
||||
.tmd-empty{text-align:center;padding:26px 12px;color:var(--tmd-dim);font-size:.9rem}
|
||||
.av{width:var(--av,42px);height:var(--av,42px);border-radius:50%;flex:none;display:grid;place-items:center;font-family:var(--tmd-font-display);font-weight:800;color:#fff;font-size:calc(var(--av,42px)*.4);line-height:1;text-align:center;background:var(--ac,var(--tmd-accent));box-shadow:0 2px 6px rgba(0,0,0,.18);overflow:hidden}
|
||||
@@ -233,7 +233,7 @@
|
||||
:host([data-tm-design="graphite"]) .tmd-checklist{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
|
||||
:host([data-tm-design="graphite"]) .tmd-check{position:relative;padding:9px 12px;min-height:46px;box-sizing:border-box}
|
||||
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check{border-top:0}
|
||||
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check::before{content:"";position:absolute;top:0;left:46px;right:0;height:1px;background:var(--tmd-border)}
|
||||
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check::before{content:"";position:absolute;top:0;inset-inline-start:46px;inset-inline-end:0;height:1px;background:var(--tmd-border)}
|
||||
:host([data-tm-design="graphite"]) .tmd-check .c-num{width:22px;height:22px;border-radius:50%;
|
||||
background:transparent;border:1.5px solid var(--tmd-border);color:transparent;font-size:0}
|
||||
:host([data-tm-design="graphite"]) .tmd-check.done .c-num{background:var(--tmd-good);border-color:var(--tmd-good);color:#fff;font-size:13px}
|
||||
@@ -306,7 +306,7 @@
|
||||
:host([data-tm-design="graphite"]) .lb-cp-score{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
|
||||
:host([data-tm-design="graphite"]) .lb-cp-unit{color:var(--tmd-dim);font-weight:400}
|
||||
:host([data-tm-design="graphite"]) .lb-cp-row.win{background:transparent}
|
||||
:host([data-tm-design="graphite"]) .lb-cp-meta ha-icon{--mdc-icon-size:14px;vertical-align:-2px;margin-right:3px;color:var(--tmd-dim)}
|
||||
:host([data-tm-design="graphite"]) .lb-cp-meta ha-icon{--mdc-icon-size:14px;vertical-align:-2px;margin-inline-end:3px;color:var(--tmd-dim)}
|
||||
|
||||
/* Overview: hairline-separated stats, then a grouped per-child list. */
|
||||
:host([data-tm-design="graphite"]) .ov-kids{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
|
||||
@@ -317,7 +317,13 @@
|
||||
:host([data-tm-design="graphite"]) .ov-today .val,
|
||||
:host([data-tm-design="graphite"]) .pts{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
|
||||
:host([data-tm-design="graphite"]) .nm,
|
||||
:host([data-tm-design="graphite"]) .ttl{font-weight:500;letter-spacing:-.18px}`;
|
||||
:host([data-tm-design="graphite"]) .ttl{font-weight:500;letter-spacing:-.18px}
|
||||
|
||||
/* Right-to-left (#979). apply() stamps dir on the host; an icon that points
|
||||
along the reading direction (chevron, undo, backspace) carries .tm-rtl-flip
|
||||
so it turns round with the layout. Text arrows (→ ↩) swap to their mirrored
|
||||
character instead, which leaves LTR markup untouched. Not design-scoped. */
|
||||
:host([dir="rtl"]) .tm-rtl-flip,[dir="rtl"] .tm-rtl-flip{transform:scaleX(-1)}`;
|
||||
|
||||
if (!document.getElementById("taskmate-design-fonts")) {
|
||||
const styleEl = document.createElement("style");
|
||||
@@ -410,16 +416,60 @@
|
||||
return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text direction of HA's current language (#979): "rtl" or "ltr". HA marks
|
||||
* RTL languages in its translation metadata (the same isRTL flag its own
|
||||
* computeRTL reads); for a language it has no metadata for, follow the
|
||||
* direction HA already gave the page.
|
||||
*/
|
||||
let _lastDir = "ltr";
|
||||
function direction(hass) {
|
||||
const lang = hass && hass.language;
|
||||
const meta = hass && hass.translationMetadata && hass.translationMetadata.translations;
|
||||
const root = typeof document !== "undefined" && document.documentElement;
|
||||
if (lang && meta && meta[lang]) _lastDir = meta[lang].isRTL ? "rtl" : "ltr";
|
||||
else _lastDir = root && root.dir === "rtl" ? "rtl" : "ltr";
|
||||
return _lastDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep number runs reading left to right inside right-to-left text (#995).
|
||||
* "3 / 1", "1,200 / 5,000" and "3-day" are one unit each, but the Unicode
|
||||
* bidi algorithm lays their parts out right to left in an RTL paragraph, so
|
||||
* "3 / 1" shows as "1 / 3" and "3-Day Streak" as "Day Streak-3". Each run is
|
||||
* wrapped in a left-to-right isolate, and a label holding one, or opening
|
||||
* with a number ("2 of 5 done", which otherwise reads "of 5 done 2"), is
|
||||
* wrapped in a first-strong isolate so it keeps its own reading order (an
|
||||
* English label stays English-ordered, a translated RTL one stays RTL). The
|
||||
* isolates (U+2066/2068 … U+2069) are invisible, but splitting a text run
|
||||
* still nudges glyph positioning by a sub-pixel, so they are only added
|
||||
* while the page is right-to-left: the direction last resolved by
|
||||
* direction(), which every card calls through apply() at the top of
|
||||
* render() and the panel calls in its hass setter (HA has one language per
|
||||
* page). In LTR, and for text with no number run that doesn't open with a
|
||||
* number, the value comes back untouched. Works on plain strings: Lit text,
|
||||
* the panel's HTML strings and translated messages alike.
|
||||
*/
|
||||
const _NUM = String.raw`\p{Nd}+(?:[.,\u066B\u066C]\p{Nd}+)*`;
|
||||
const _NUM_RUN = new RegExp(String.raw`${_NUM}(?:\s*\/\s*${_NUM})+|(?<![\p{L}-])${_NUM}-\p{L}+`, "gu");
|
||||
function ltrNums(text) {
|
||||
if (text === null || text === undefined || _lastDir !== "rtl") return text;
|
||||
const s = String(text);
|
||||
const out = s.replace(_NUM_RUN, (run) => "\u2066" + run + "\u2069");
|
||||
return out === s && !/^\p{Nd}/u.test(s) ? s : "\u2068" + out + "\u2069";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the design AND stamp the host element: data-tm-design + (in dark
|
||||
* mode) data-tm-dark. Cards call this once at the top of render() and branch
|
||||
* on the returned id. Returns "classic" when no design applies.
|
||||
* mode) data-tm-dark + dir. Cards call this once at the top of render() and
|
||||
* branch on the returned id. Returns "classic" when no design applies.
|
||||
*/
|
||||
function apply(el, hass, config, entity) {
|
||||
const design = resolve(hass, config, entity);
|
||||
if (el) {
|
||||
el.setAttribute("data-tm-design", design);
|
||||
el.toggleAttribute("data-tm-dark", design !== "classic" && isDark(hass, el));
|
||||
el.setAttribute("dir", direction(hass));
|
||||
}
|
||||
// The host keeps the real id (its CSS keys off it); the card gets the
|
||||
// layout id so aliased styles reuse an existing render path.
|
||||
@@ -487,7 +537,7 @@
|
||||
`;
|
||||
}
|
||||
|
||||
window.__taskmate_design = { IDS, resolve, isDark, apply, active, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
|
||||
window.__taskmate_design = { IDS, resolve, isDark, direction, ltrNums, apply, active, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
|
||||
|
||||
/**
|
||||
* The single place that decides what a chore looks like (#750).
|
||||
@@ -541,6 +591,29 @@
|
||||
return { config: Object.assign({}, stub, { type: "custom:" + type, entity: entityId }) };
|
||||
};
|
||||
|
||||
// ── Birthday "first open today" gate (#924) ───────────────────────────────
|
||||
// True the first time it's asked about a child on a given day on this device,
|
||||
// false after that — so the birthday confetti plays once per day, not on every
|
||||
// hass update or page load. Shared by every card that celebrates, so a child
|
||||
// card and a points card on the same dashboard don't both go off. Keyed on
|
||||
// HA's time zone so the day rolls over with the backend, not the browser.
|
||||
window.__taskmate_birthday_once = window.__taskmate_birthday_once || function (hass, childId) {
|
||||
if (!childId) return false;
|
||||
let day;
|
||||
try {
|
||||
day = new Date().toLocaleDateString("en-CA", { timeZone: hass?.config?.time_zone || undefined });
|
||||
} catch (e) {
|
||||
day = new Date().toLocaleDateString("en-CA");
|
||||
}
|
||||
const KEY = "taskmate_celebrated_day";
|
||||
let seen;
|
||||
try { seen = JSON.parse(window.localStorage.getItem(KEY) || "{}") || {}; } catch (e) { seen = {}; }
|
||||
if (seen[childId] === day) return false;
|
||||
seen[childId] = day;
|
||||
try { window.localStorage.setItem(KEY, JSON.stringify(seen)); } catch (e) { /* private mode: plays each load */ }
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── Chore-photo lightbox ───────────────────────────────────────────────────
|
||||
// A single shared, design-AGNOSTIC image viewer for evidence photos. It is
|
||||
// mounted on document.body (NOT inside any card's shadow root), so it paints
|
||||
@@ -582,7 +655,7 @@
|
||||
"background:#111;box-shadow:0 18px 60px rgba(0,0,0,.6);}" +
|
||||
".tm-lightbox .tm-lb-cap{color:#f4f6fb;font-size:14px;font-weight:600;text-align:center;" +
|
||||
"max-width:92vw;line-height:1.4;text-shadow:0 1px 2px rgba(0,0,0,.6);}" +
|
||||
".tm-lightbox .tm-lb-close{position:absolute;top:14px;right:16px;width:42px;height:42px;border:none;" +
|
||||
".tm-lightbox .tm-lb-close{position:absolute;top:14px;inset-inline-end:16px;width:42px;height:42px;border:none;" +
|
||||
"border-radius:50%;background:rgba(255,255,255,.14);color:#fff;font-size:22px;line-height:1;" +
|
||||
"cursor:pointer;transition:background .15s;}" +
|
||||
".tm-lightbox .tm-lb-close:hover{background:rgba(255,255,255,.28);}" +
|
||||
|
||||
@@ -18,6 +18,16 @@ 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);
|
||||
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
|
||||
// identity until the design layer has loaded (it is also what stamps dir).
|
||||
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
||||
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
|
||||
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
|
||||
const _ltrRatio = (a, b) => {
|
||||
const s = `${a} / ${b}`;
|
||||
const r = _ltrNums(s);
|
||||
return r === s ? html`${a} / ${b}` : r;
|
||||
};
|
||||
const DEFAULT_ACCENT = "#16a085";
|
||||
|
||||
class TaskMateFamilyGoalCard extends LitElement {
|
||||
@@ -93,7 +103,7 @@ class TaskMateFamilyGoalCard extends LitElement {
|
||||
<div class="fg-body">
|
||||
<div class="fg-bar"><div class="fg-fill ${achieved ? "done" : ""}" style="width:${pct}%"></div></div>
|
||||
<div class="fg-stats">
|
||||
<span>${progress} / ${target} ${pointsName}</span>
|
||||
<span>${_ltrRatio(progress, target)} ${pointsName}</span>
|
||||
<span>${pct}%</span>
|
||||
</div>
|
||||
${achieved
|
||||
|
||||
@@ -214,7 +214,9 @@ class TaskMateGraphCard extends LitElement {
|
||||
.tmg-plot-pl { background: var(--tmd-surface-2); border-radius: 16px; padding: 12px 10px 6px; }
|
||||
.tmg-plot-cn { background: #0b1424; border: 1px solid var(--tmd-border); border-radius: 8px; padding: 12px 10px 6px; }
|
||||
.tmg-plot-cp { padding: 2px 2px 0; }
|
||||
.tmg-xaxis { justify-content: space-between; font-size: 10.5px; font-weight: 600; margin-top: 4px; }
|
||||
/* The plot is an SVG drawn left to right in every language, so its date
|
||||
labels must not mirror under RTL (#979) or they'd sit under the wrong day. */
|
||||
.tmg-xaxis { justify-content: space-between; font-size: 10.5px; font-weight: 600; margin-top: 4px; direction: ltr; }
|
||||
.tmg-plot-cn .tmg-xaxis { font-size: 9.5px; }
|
||||
.tmg-legend { gap: 14px; justify-content: center; margin-top: 11px; font-size: 12px; font-weight: 700; flex-wrap: wrap; }
|
||||
.tmg-plot-cp ~ .tmg-legend { justify-content: flex-start; font-weight: 600; }
|
||||
@@ -942,7 +944,7 @@ class TaskMateGraphCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ export function createIncentiveCard(P) {
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
left: 50%; /* rtl-ok: centred with translate(-50%), symmetric */
|
||||
transform: translateX(-50%) translateY(0);
|
||||
background: #333;
|
||||
color: white;
|
||||
@@ -365,7 +365,7 @@ export function createIncentiveCard(P) {
|
||||
.d-tabs { display: flex; gap: 8px; margin-bottom: 13px; align-items: center;
|
||||
overflow-x: auto; scrollbar-width: none; }
|
||||
.d-tabs::-webkit-scrollbar { display: none; }
|
||||
.d-tab { display: flex; align-items: center; gap: 6px; padding: 4px 12px 4px 4px;
|
||||
.d-tab { display: flex; align-items: center; gap: 6px; padding-block: 4px; padding-inline: 4px 12px;
|
||||
border-radius: 999px; font-weight: 800; font-size: 13px; cursor: pointer;
|
||||
white-space: nowrap; color: var(--tmd-text);
|
||||
background: color-mix(in srgb, var(--ac, var(--tmd-accent)) 16%, transparent);
|
||||
@@ -980,7 +980,7 @@ export function createIncentiveCard(P) {
|
||||
<span class="tt">${this.config.title || this._tp('default_title')}${
|
||||
bonuses.length ? html`<small>${bonuses.length}</small>` : ""}</span>
|
||||
${this._canManage() ? html`
|
||||
<button class="btn ghost sm" style="margin-left:auto"
|
||||
<button class="btn ghost sm" style="margin-inline-start:auto"
|
||||
title="${this._tp('manage_title')}"
|
||||
@click=${this._toggleEditMode}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
@@ -1054,7 +1054,7 @@ export function createIncentiveCard(P) {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,7 +141,7 @@ class TaskMateLeaderboardCard extends LitElement {
|
||||
.stat-chip ha-icon { --mdc-icon-size: 13px; }
|
||||
|
||||
.rank-score {
|
||||
text-align: right; flex-shrink: 0;
|
||||
text-align: end; flex-shrink: 0;
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class TaskMateLeaderboardCard extends LitElement {
|
||||
}
|
||||
|
||||
.tie-line .tie-label {
|
||||
position: absolute; top: 50%; left: 50%;
|
||||
position: absolute; top: 50%; left: 50%; /* rtl-ok: centred with translate(-50%), symmetric */
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
padding: 0 6px;
|
||||
@@ -264,7 +264,7 @@ class TaskMateLeaderboardCard extends LitElement {
|
||||
.lb-cn-mid { flex: 1; min-width: 0; }
|
||||
.lb-cn-name { font-weight: 700; }
|
||||
.lb-cn-meta { font-size: 10px; gap: 8px; margin-top: 5px; }
|
||||
.lb-cn-right { text-align: right; }
|
||||
.lb-cn-right { text-align: end; }
|
||||
.lb-cn-score { font-size: 23px; }
|
||||
.lb-cn-score.win { color: var(--tmd-accent); }
|
||||
.lb-cn-unit { font-size: 10px; }
|
||||
@@ -278,7 +278,7 @@ class TaskMateLeaderboardCard extends LitElement {
|
||||
.lb-cp-mid { flex: 1; min-width: 0; }
|
||||
.lb-cp-name { font-weight: 600; }
|
||||
.lb-cp-meta { font-size: 11.5px; gap: 10px; margin-top: 2px; }
|
||||
.lb-cp-right { text-align: right; }
|
||||
.lb-cp-right { text-align: end; }
|
||||
.lb-cp-score { font-size: 19px; }
|
||||
.lb-cp-unit { font-size: 10.5px; }
|
||||
`;
|
||||
@@ -787,7 +787,7 @@ class TaskMateLeaderboardCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ 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);
|
||||
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
|
||||
// identity until the design layer has loaded (it is also what stamps dir).
|
||||
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
||||
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
|
||||
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
|
||||
const _ltrRatio = (a, b) => {
|
||||
const s = `${a} / ${b}`;
|
||||
const r = _ltrNums(s);
|
||||
return r === s ? html`${a} / ${b}` : r;
|
||||
};
|
||||
|
||||
class TaskMateOverviewCard extends LitElement {
|
||||
static get properties() {
|
||||
@@ -219,7 +229,7 @@ class TaskMateOverviewCard extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.progress-label.complete { color: var(--ov-green); }
|
||||
@@ -327,11 +337,11 @@ class TaskMateOverviewCard extends LitElement {
|
||||
@media (max-width: 480px) { .ov-kids { grid-template-columns: repeat(2, 1fr); } }
|
||||
.ov-kid { text-align: center; padding: 11px; border-radius: 16px; background: var(--tmd-surface-2); position: relative; }
|
||||
.ov-kid.tm-clickable { cursor: pointer; }
|
||||
.ov-kid-flags { position: absolute; top: 6px; right: 7px; display: flex; gap: 4px; }
|
||||
.ov-kid-flags { position: absolute; top: 6px; inset-inline-end: 7px; display: flex; gap: 4px; }
|
||||
.ov-kid-flag { font-size: 9.5px; font-weight: 800; padding: 1px 5px; border-radius: 999px; line-height: 1.5; }
|
||||
.ov-kid-flag.pend { background: color-mix(in srgb, var(--tmd-warn) 22%, transparent); color: var(--tmd-warn); }
|
||||
.ov-kid-flag.wait { background: color-mix(in srgb, var(--tmd-bad) 20%, transparent); color: var(--tmd-bad); }
|
||||
.ov-behalf { margin-top: 10px; text-align: left; background: var(--tmd-surface-2); border-radius: var(--tmd-radius-sm); padding: 9px 11px; }
|
||||
.ov-behalf { margin-top: 10px; text-align: start; background: var(--tmd-surface-2); border-radius: var(--tmd-radius-sm); padding: 9px 11px; }
|
||||
.ov-behalf-hdr { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--tmd-dim); margin-bottom: 6px; }
|
||||
.ov-behalf-done { font-size: 12px; font-style: italic; color: var(--tmd-dim); padding: 3px 0; }
|
||||
.ov-behalf-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
|
||||
@@ -573,7 +583,9 @@ class TaskMateOverviewCard extends LitElement {
|
||||
const at = Array.isArray(c.assigned_to) ? c.assigned_to.map(String) : [];
|
||||
const assigned = at.length === 0 || at.includes(String(child.id));
|
||||
if (!assigned) return false;
|
||||
if (c.schedule_mode !== 'recurring') {
|
||||
// Calendar move/removal (#977) settles today outright.
|
||||
if (c.occ_today === false) return false;
|
||||
if (c.schedule_mode !== 'recurring' && c.occ_today !== true) {
|
||||
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
|
||||
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
|
||||
}
|
||||
@@ -686,7 +698,7 @@ class TaskMateOverviewCard extends LitElement {
|
||||
<div class="divide"></div>
|
||||
<div class="ov-today cp">
|
||||
<span class="lbl">${this._t("overview.designed.todays_progress")}</span>
|
||||
<span class="val num">${this._t("overview.designed.progress_detail", { done, total, pct })}</span>
|
||||
<span class="val num">${_ltrNums(this._t("overview.designed.progress_detail", { done, total, pct }))}</span>
|
||||
</div>`;
|
||||
}
|
||||
const lbl = design === "console"
|
||||
@@ -695,7 +707,7 @@ class TaskMateOverviewCard extends LitElement {
|
||||
return html`
|
||||
<div class="ov-today ${cls}">
|
||||
<span class="lbl">${lbl}</span>
|
||||
<span class="val">${done} / ${total}</span>
|
||||
<span class="val">${_ltrRatio(done, total)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -716,8 +728,11 @@ class TaskMateOverviewCard extends LitElement {
|
||||
const assigned = at.length === 0 || at.includes(String(child.id));
|
||||
if (!assigned) return false;
|
||||
|
||||
// Calendar move/removal (#977) settles today outright.
|
||||
if (c.occ_today === false) return false;
|
||||
|
||||
// Mode A: due days check
|
||||
if (c.schedule_mode !== 'recurring') {
|
||||
if (c.schedule_mode !== 'recurring' && c.occ_today !== true) {
|
||||
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
|
||||
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
|
||||
}
|
||||
@@ -871,7 +886,7 @@ class TaskMateOverviewCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,9 @@ const tmSafePhotoUrl = (u) =>
|
||||
const tmClaimList = (v) => (Array.isArray(v) ? v : []);
|
||||
|
||||
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
|
||||
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
|
||||
// identity until the design layer has loaded (it is also what stamps dir).
|
||||
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
||||
|
||||
class TaskMateParentDashboardCard extends LitElement {
|
||||
static get properties() {
|
||||
@@ -270,7 +273,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
.progress-label {
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap; min-width: 32px; text-align: right;
|
||||
white-space: nowrap; min-width: 32px; text-align: end;
|
||||
}
|
||||
|
||||
/* ── Approval items ── */
|
||||
@@ -552,18 +555,21 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
setConfig(config) {
|
||||
if (!config.entity) throw new Error("Please define an entity");
|
||||
this.config = {
|
||||
title: "Parent Dashboard",
|
||||
title: "",
|
||||
quick_points_amount: 5,
|
||||
show_claims: true,
|
||||
header_color: '#c0392b',
|
||||
...config,
|
||||
};
|
||||
// Dashboards saved before #1010 stored the English default title; treat it
|
||||
// as unset so the translated default is shown instead.
|
||||
if (this.config.title === "Parent Dashboard") this.config.title = "";
|
||||
}
|
||||
|
||||
getCardSize() { return 6; }
|
||||
static getConfigElement() { return document.createElement("taskmate-parent-dashboard-card-editor"); }
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", title: "Parent Dashboard" };
|
||||
return { entity: "sensor.taskmate_overview" };
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -681,10 +687,12 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
const assigned = at.length === 0 || at.includes(child.id);
|
||||
if (!assigned) return false;
|
||||
const perChild = availability[c.id];
|
||||
// Calendar move/removal (#977) settles today outright.
|
||||
if (c.occ_today === false) return false;
|
||||
if (c.schedule_mode === 'one_shot') {
|
||||
if (perChild && perChild[child.id] === false) return false;
|
||||
}
|
||||
if (c.schedule_mode === 'specific_days') {
|
||||
if (c.schedule_mode === 'specific_days' && c.occ_today !== true) {
|
||||
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
|
||||
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
|
||||
}
|
||||
@@ -856,7 +864,7 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
${this._av(child.name, child.avatar, tone, 42)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="pd-name">${child.name}</div>
|
||||
<div class="muted pd-sub">${this._t('dashboard.chores_done', { done: approved, total }, `${approved} / ${total} chores done`)}</div>
|
||||
<div class="muted pd-sub">${_ltrNums(this._t('dashboard.chores_done', { done: approved, total }, `${approved} / ${total} chores done`))}</div>
|
||||
</div>
|
||||
<div class="big pd-pts">${child.points}⭐</div>
|
||||
</div>
|
||||
@@ -1029,12 +1037,14 @@ class TaskMateParentDashboardCard extends LitElement {
|
||||
const assigned = at.length === 0 || at.includes(child.id);
|
||||
if (!assigned) return false;
|
||||
const perChild = availability[c.id];
|
||||
// Calendar move/removal (#977) settles today outright.
|
||||
if (c.occ_today === false) return false;
|
||||
// One-shot: use is_available check (same as recurring)
|
||||
if (c.schedule_mode === 'one_shot') {
|
||||
if (perChild && perChild[child.id] === false) return false;
|
||||
}
|
||||
// Mode A: due days check
|
||||
if (c.schedule_mode === 'specific_days') {
|
||||
if (c.schedule_mode === 'specific_days' && c.occ_today !== true) {
|
||||
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
|
||||
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
|
||||
}
|
||||
@@ -1445,7 +1455,7 @@ class TaskMateParentDashboardCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ class TaskMatePointsCard extends LitElement {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--secondary-text-color);
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
@@ -291,8 +291,8 @@ class TaskMatePointsCard extends LitElement {
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
@@ -478,7 +478,7 @@ class TaskMatePointsCard extends LitElement {
|
||||
.notification {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
left: 50%; /* rtl-ok: centred with translate(-50%), symmetric */
|
||||
transform: translateX(-50%);
|
||||
max-width: calc(100vw - 24px);
|
||||
white-space: normal;
|
||||
@@ -609,7 +609,7 @@ class TaskMatePointsCard extends LitElement {
|
||||
.pc-mid { flex: 1; min-width: 0; }
|
||||
.pc-name { font-weight: 800; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pc-pts { font-family: var(--tmd-font-display); font-weight: 800; font-size: 22px; line-height: 1; color: var(--tmd-accent); }
|
||||
.pc-pts span { font-size: 12px; margin-left: 2px; }
|
||||
.pc-pts span { font-size: 12px; margin-inline-start: 2px; }
|
||||
.pc-sub { font-size: 11px; }
|
||||
|
||||
/* Console list variant */
|
||||
@@ -622,7 +622,7 @@ class TaskMatePointsCard extends LitElement {
|
||||
.cp-row { background: transparent; border-radius: 0; padding: 11px 2px; }
|
||||
.cp-row + .cp-row { border-top: 1px solid var(--tmd-border); }
|
||||
.cp-name { flex: 1; min-width: 0; font-weight: 600; }
|
||||
.cp-num { width: 46px; text-align: right; font-size: 16px; }
|
||||
.cp-num { width: 46px; text-align: end; font-size: 16px; }
|
||||
|
||||
.pc-quick { gap: 7px; flex-wrap: wrap; }
|
||||
.pc-reason { margin-top: 12px; cursor: pointer; }
|
||||
@@ -673,9 +673,12 @@ class TaskMatePointsCard extends LitElement {
|
||||
throw new Error("Please define an entity (taskmate overview sensor)");
|
||||
}
|
||||
this.config = {
|
||||
title: "Manage Points",
|
||||
title: "",
|
||||
...config,
|
||||
};
|
||||
// Dashboards saved before #1010 stored the English default title; treat it
|
||||
// as unset so the translated default is shown instead.
|
||||
if (this.config.title === "Manage Points") this.config.title = "";
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
@@ -689,7 +692,6 @@ class TaskMatePointsCard extends LitElement {
|
||||
static getStubConfig() {
|
||||
return {
|
||||
entity: "sensor.taskmate_overview",
|
||||
title: "Manage Points",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1235,7 +1237,7 @@ class TaskMatePointsCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
_animated: { type: Object },
|
||||
_confetti: { type: Array },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +104,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._animated = {};
|
||||
this._confetti = [];
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
@@ -228,7 +230,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
content: "\u2B50";
|
||||
font-size: 7rem;
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
inset-inline-end: -12px;
|
||||
bottom: -18px;
|
||||
opacity: 0.06;
|
||||
line-height: 1;
|
||||
@@ -254,7 +256,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
font-size: 2rem;
|
||||
animation: star-spin 3s linear infinite;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes star-spin {
|
||||
@@ -329,7 +331,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
.child-tile .rank-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -351,7 +353,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
.child-tile .tile-star {
|
||||
font-size: 1rem;
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
margin-inline-end: 2px;
|
||||
display: inline-block;
|
||||
animation: star-spin 4s linear infinite;
|
||||
}
|
||||
@@ -383,8 +385,8 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
height: 4px;
|
||||
border-radius: 0 0 18px 18px;
|
||||
}
|
||||
@@ -414,7 +416,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
content: "\u{1F31F}";
|
||||
font-size: 6rem;
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
inset-inline-end: -10px;
|
||||
bottom: -14px;
|
||||
opacity: 0.07;
|
||||
pointer-events: none;
|
||||
@@ -474,7 +476,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
font-size: 0.85rem;
|
||||
animation: star-spin 5s linear infinite;
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
margin-inline-end: 2px;
|
||||
}
|
||||
.cumul-bar-wrap {
|
||||
width: 100%;
|
||||
@@ -503,16 +505,45 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
}
|
||||
.empty-state ha-icon { --mdc-icon-size: 40px; opacity: 0.35; display: block; margin: 0 auto 10px; }
|
||||
|
||||
/* ── Birthday (#924) ── */
|
||||
.birthday-banner {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 18px;
|
||||
background: linear-gradient(135deg, #ffd1e8, #e3c8ff); color: #5b1f6b;
|
||||
}
|
||||
.birthday-banner ha-icon { --mdc-icon-size: 28px; flex-shrink: 0; }
|
||||
.bday-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.birthday-banner .bday-title { font-weight: 800; font-size: 1rem; }
|
||||
.birthday-banner .bday-sub { font-size: 0.85rem; font-weight: 600; opacity: 0.85; }
|
||||
.tmd-birthday {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; margin-bottom: 11px;
|
||||
background: color-mix(in srgb, var(--tmd-accent) 16%, transparent); color: var(--tmd-text);
|
||||
border: 1px solid color-mix(in srgb, var(--tmd-accent) 40%, transparent);
|
||||
border-radius: var(--tmd-radius-sm);
|
||||
}
|
||||
.tmd-birthday ha-icon { --mdc-icon-size: 26px; color: var(--tmd-accent); flex-shrink: 0; }
|
||||
.tmd-birthday .bday-title { font-weight: 800; font-size: 14px; }
|
||||
.tmd-birthday .bday-sub { font-size: 12px; font-weight: 600; color: var(--tmd-dim); }
|
||||
.confetti-container {
|
||||
position: fixed; top: 0; inset-inline-start: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; z-index: 10000; overflow: hidden;
|
||||
}
|
||||
.confetti { position: absolute; width: 10px; height: 10px; animation: confetti-fall 3s linear forwards; }
|
||||
@keyframes confetti-fall {
|
||||
0% { transform: translateY(-100px) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .confetti-container { display: none; } }
|
||||
|
||||
/* Shared .tmd kit + design tokens are provided by taskmate-design.js styles(). */
|
||||
|
||||
/* Points Display — Playroom */
|
||||
.pl-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 11px; }
|
||||
.pl-tile { background: var(--tmd-surface-2); border-radius: 18px; padding: 22px 12px 15px; text-align: center;
|
||||
position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.pl-medal { position: absolute; top: 9px; left: 11px; font-size: 16px; line-height: 1; }
|
||||
.pl-medal { position: absolute; top: 9px; inset-inline-start: 11px; font-size: 16px; line-height: 1; }
|
||||
.pl-pts { font-family: var(--tmd-font-display); font-weight: 800; font-size: 1.85rem; line-height: 1;
|
||||
color: var(--tmd-accent); }
|
||||
.pl-pts span { font-size: 0.9rem; margin-left: 2px; }
|
||||
.pl-pts span { font-size: 0.9rem; margin-inline-start: 2px; }
|
||||
.pl-name { font-weight: 800; font-size: 0.95rem; }
|
||||
.pl-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
||||
|
||||
@@ -523,7 +554,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
.cn-rank { font-size: 20px; width: 34px; color: var(--tmd-dim); }
|
||||
.cn-mid { flex: 1; min-width: 0; }
|
||||
.cn-name { font-weight: 700; margin-bottom: 6px; }
|
||||
.cn-right { text-align: right; }
|
||||
.cn-right { text-align: end; }
|
||||
.cn-pts { font-size: 22px; }
|
||||
.cn-sub { font-size: 10px; }
|
||||
|
||||
@@ -747,7 +778,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
<div class="tile-weekly">${this._t("points_display.weekly_plus", { count: weekly })}</div>` : ""}
|
||||
${this.config.show_streak ? html`
|
||||
<div class="streak-chip">\u{1F525} ${streak}</div>` : ""}
|
||||
${isTop ? html`<div style="position:absolute;bottom:0;left:0;right:0;height:4px;background:${colour};border-radius:0 0 18px 18px;"></div>` : ""}
|
||||
${isTop ? html`<div style="position:absolute;bottom:0;inset-inline-start:0;inset-inline-end:0;height:4px;background:${colour};border-radius:0 0 18px 18px;"></div>` : ""}
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
@@ -814,6 +845,64 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Birthday mode (#924) ───────────────────────────────────────────────
|
||||
`child.birthday` is only on the sensor on the day. The banner helper is
|
||||
called from BOTH render paths; confetti fires from updated(), which runs
|
||||
for every design. Same confetti as the child card's chore celebration. */
|
||||
|
||||
_birthdayChildren() {
|
||||
const mode = this.config.mode || "single";
|
||||
const pool = mode === "single" ? [this._singleChild()].filter(Boolean) : this._allChildren();
|
||||
return pool.filter(c => c && c.birthday);
|
||||
}
|
||||
|
||||
_renderBirthdayBanners(designed) {
|
||||
return this._birthdayChildren().map(child => {
|
||||
const b = child.birthday;
|
||||
const greeting = b.age != null
|
||||
? this._t("birthday.banner_age", { name: child.name, age: b.age })
|
||||
: this._t("birthday.banner", { name: child.name });
|
||||
const mult = Number(b.multiplier) || 1;
|
||||
const sub = mult > 1 ? this._t("birthday.multiplier", { multiplier: mult }) : "";
|
||||
return html`
|
||||
<div class="${designed ? "tmd-birthday" : "birthday-banner"}" role="status">
|
||||
<ha-icon icon="mdi:cake-variant"></ha-icon>
|
||||
<div class="bday-text">
|
||||
<span class="bday-title">${greeting}</span>
|
||||
${sub ? html`<span class="bday-sub">${sub}</span>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
super.updated(changed);
|
||||
const once = window.__taskmate_birthday_once;
|
||||
if (!once || !this.hass || !this.config) return;
|
||||
// Evaluate every child (no short-circuit) so each is marked seen today.
|
||||
const fire = this._birthdayChildren().map(c => once(this.hass, c.id)).some(Boolean);
|
||||
if (fire) this._spawnConfetti();
|
||||
}
|
||||
|
||||
_spawnConfetti() {
|
||||
const pieces = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
pieces.push({ x: Math.random() * 100, delay: Math.random() * 0.5, size: Math.random() * 8 + 6, round: Math.random() > 0.5 });
|
||||
}
|
||||
this._confetti = pieces;
|
||||
setTimeout(() => { this._confetti = []; }, 3500);
|
||||
}
|
||||
|
||||
_renderConfetti() {
|
||||
if (!this._confetti.length) return "";
|
||||
const colors = ["#ff6b9d", "#9b59b6", "#3498db", "#2ecc71", "#f1c40f", "#e67e22"];
|
||||
return html`
|
||||
<div class="confetti-container">
|
||||
${this._confetti.map((p, i) => html`
|
||||
<div class="confetti" style="inset-inline-start:${p.x}%;animation-delay:${p.delay}s;background:${colors[i % colors.length]};border-radius:${p.round ? "50%" : "0"};width:${p.size}px;height:${p.size}px;"></div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Main render ────────────────────────────────────────────────────── */
|
||||
|
||||
render() {
|
||||
@@ -847,7 +936,9 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
</div>
|
||||
<div class="mode-badge">${modeLabel}</div>
|
||||
</div>
|
||||
${this._renderBirthdayBanners(false)}
|
||||
<div class="card-body">${body}</div>
|
||||
${this._renderConfetti()}
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
@@ -913,7 +1004,7 @@ class TaskMatePointsDisplayCard extends LitElement {
|
||||
design === "console" ? this._designConsole(rows) :
|
||||
this._designCleanpro(rows, total);
|
||||
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">${header}<div class="tmd-bd">${body}</div></ha-card>`;
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">${header}<div class="tmd-bd">${this._renderBirthdayBanners(true)}${body}</div>${this._renderConfetti()}</ha-card>`;
|
||||
}
|
||||
|
||||
_designPlayroom(rows) {
|
||||
@@ -1051,7 +1142,7 @@ class TaskMatePointsDisplayCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -180,7 +180,7 @@ class TaskMateReorderCard extends LitElement {
|
||||
}
|
||||
|
||||
.time-category-header .count {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
font-size: 0.85em;
|
||||
color: var(--secondary-text-color);
|
||||
background: var(--card-background-color);
|
||||
@@ -192,7 +192,7 @@ class TaskMateReorderCard extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-left: 8px;
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
|
||||
.chore-item {
|
||||
@@ -441,10 +441,10 @@ class TaskMateReorderCard extends LitElement {
|
||||
NOTE: no ha-card { padding } rule — body padding comes from .tmd-bd.
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.d-childchip { display: flex; align-items: center; gap: 5px; margin-left: auto;
|
||||
.d-childchip { display: flex; align-items: center; gap: 5px; margin-inline-start: auto;
|
||||
font-weight: 800; font-size: 12px; color: #fff;
|
||||
background: rgba(255,255,255,.25); border-radius: 999px;
|
||||
padding: 3px 10px 3px 3px; white-space: nowrap; }
|
||||
padding-block: 3px; padding-inline: 3px 10px; white-space: nowrap; }
|
||||
:host([data-tm-design="console"]) .d-childchip { border-radius: 6px; font-weight: 700; font-size: 11px;
|
||||
background: var(--tmd-surface-2);
|
||||
border: 1px solid color-mix(in srgb, var(--ac, var(--tmd-accent)) 55%, transparent);
|
||||
@@ -520,9 +520,12 @@ class TaskMateReorderCard extends LitElement {
|
||||
throw new Error("Please define a child_id");
|
||||
}
|
||||
this.config = {
|
||||
title: "Reorder Chores",
|
||||
title: "",
|
||||
...config,
|
||||
};
|
||||
// Dashboards saved before #1010 stored the English default title; treat it
|
||||
// as unset so the translated default is shown instead.
|
||||
if (this.config.title === "Reorder Chores") this.config.title = "";
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
@@ -537,7 +540,6 @@ class TaskMateReorderCard extends LitElement {
|
||||
return {
|
||||
entity: "sensor.taskmate_overview",
|
||||
child_id: "",
|
||||
title: "Reorder Chores",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1199,7 +1201,7 @@ class TaskMateReorderCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ 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);
|
||||
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
|
||||
// identity until the design layer has loaded (it is also what stamps dir).
|
||||
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
||||
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
|
||||
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
|
||||
const _ltrRatio = (a, b) => {
|
||||
const s = `${a} / ${b}`;
|
||||
const r = _ltrNums(s);
|
||||
return r === s ? html`${a} / ${b}` : r;
|
||||
};
|
||||
|
||||
class TaskMateRewardProgressCard extends LitElement {
|
||||
static get properties() {
|
||||
@@ -158,7 +168,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
}
|
||||
|
||||
.child-progress-cost {
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -207,7 +217,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
.big-progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: -100%;
|
||||
top: 0; inset-inline-start: -100%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||
@@ -215,8 +225,8 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
0% { inset-inline-start: -100%; }
|
||||
100% { inset-inline-start: 200%; }
|
||||
}
|
||||
|
||||
.big-progress-fill.affordable {
|
||||
@@ -367,7 +377,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
gap: 6px;
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 20px;
|
||||
padding: 4px 10px 4px 6px;
|
||||
padding-block: 4px; padding-inline: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color);
|
||||
@@ -413,7 +423,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
: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-desc { font-size: 13px; margin-top: 4px; max-width: 280px; margin-inline-start: auto; margin-inline-end: 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; }
|
||||
@@ -430,7 +440,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
.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-stat-r { text-align: end; }
|
||||
.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; }
|
||||
@@ -440,7 +450,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
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-pts span { font-size: 12px; margin-inline-start: 1px; }
|
||||
.rp-kid-name { font-size: 12px; font-weight: 800; }
|
||||
.rp-kid-bar { width: 100%; height: 7px; margin-top: 3px; }
|
||||
`;
|
||||
@@ -612,7 +622,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
<div class="big-progress-fill ${cls}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<div class="progress-stat-row">
|
||||
<span class="progress-have">${have} / ${cost} ${pointsName}</span>
|
||||
<span class="progress-have">${_ltrRatio(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>`}
|
||||
@@ -678,7 +688,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
<div class="big-progress-fill ${cls}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<div class="progress-stat-row">
|
||||
<span class="progress-have">${totalHave} / ${cost} ${pointsName}</span>
|
||||
<span class="progress-have">${_ltrRatio(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>`}
|
||||
@@ -816,7 +826,7 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
? 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="num rp-frac">${_ltrRatio(have, cost)}</div>
|
||||
<div class="muted rp-need">${this._t('reward_progress.more_needed', { amount: remaining })}</div>
|
||||
</div>
|
||||
</div>`
|
||||
@@ -865,7 +875,7 @@ class TaskMateRewardProgressCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,16 @@ const css = LitElement.prototype.css;
|
||||
const tmClaimList = (v) => (Array.isArray(v) ? v : []);
|
||||
|
||||
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
|
||||
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
|
||||
// identity until the design layer has loaded (it is also what stamps dir).
|
||||
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
||||
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
|
||||
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
|
||||
const _ltrRatio = (a, b) => {
|
||||
const s = `${a} / ${b}`;
|
||||
const r = _ltrNums(s);
|
||||
return r === s ? html`${a} / ${b}` : r;
|
||||
};
|
||||
|
||||
class TaskMateRewardsCard extends LitElement {
|
||||
static get properties() {
|
||||
@@ -287,7 +297,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
inset-inline-start: -100%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
@@ -300,8 +310,8 @@ class TaskMateRewardsCard extends LitElement {
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
0% { inset-inline-start: -100%; }
|
||||
100% { inset-inline-start: 200%; }
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
@@ -310,7 +320,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* Jackpot reward styles */
|
||||
@@ -325,8 +335,8 @@ class TaskMateRewardsCard extends LitElement {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
@@ -393,7 +403,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
inset-inline-start: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
@@ -407,21 +417,21 @@ class TaskMateRewardsCard extends LitElement {
|
||||
|
||||
/* 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; }
|
||||
0% { inset-inline-start: -100%; }
|
||||
2.5% { inset-inline-start: 200%; }
|
||||
5% { inset-inline-start: -100%; }
|
||||
7.5% { inset-inline-start: 200%; }
|
||||
10% { inset-inline-start: -100%; }
|
||||
12.5% { inset-inline-start: 200%; }
|
||||
15%, 100% { inset-inline-start: -100%; opacity: 0; }
|
||||
}
|
||||
|
||||
.jackpot-segment:first-child {
|
||||
border-radius: 9px 0 0 9px;
|
||||
border-start-start-radius: 9px; border-start-end-radius: 0; border-end-end-radius: 0; border-end-start-radius: 9px;
|
||||
}
|
||||
|
||||
.jackpot-segment:last-child {
|
||||
border-radius: 0 9px 9px 0;
|
||||
border-start-start-radius: 0; border-start-end-radius: 9px; border-end-end-radius: 9px; border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.jackpot-segment:only-child {
|
||||
@@ -546,7 +556,21 @@ class TaskMateRewardsCard extends LitElement {
|
||||
/* Pending approval state */
|
||||
.reward-row.pending-approval {
|
||||
opacity: 0.6;
|
||||
border-left: 3px solid #e67e22;
|
||||
border-inline-start: 3px solid #e67e22;
|
||||
}
|
||||
|
||||
/* Reject reason (#976) */
|
||||
.reject-note {
|
||||
margin-top: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
background: rgba(244, 67, 54, 0.12);
|
||||
color: var(--error-color, #d32f2f);
|
||||
}
|
||||
|
||||
.pending-label {
|
||||
@@ -747,14 +771,14 @@ class TaskMateRewardsCard extends LitElement {
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
color: var(--text-primary);
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.spendable-banner .spendable-of {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
/* Pool Mode controls — render BELOW the progress bar, full width */
|
||||
@@ -801,7 +825,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.deposit-custom input {
|
||||
@@ -941,7 +965,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
.dialog-points-row {
|
||||
display: flex;
|
||||
@@ -995,6 +1019,12 @@ class TaskMateRewardsCard extends LitElement {
|
||||
.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-reject {
|
||||
margin: 2px 0 6px; padding: 4px 8px; border-radius: 8px;
|
||||
font-size: 12px; font-weight: 700; line-height: 1.3;
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
background: color-mix(in srgb, var(--tmd-bad, #e74c3c) 14%, transparent); color: var(--tmd-bad, #e74c3c);
|
||||
}
|
||||
.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); }
|
||||
@@ -1010,7 +1040,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
.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-pool-btns .deposit-custom { margin-inline-start: auto; gap: 6px; }
|
||||
.rw-deposit-input {
|
||||
width: 70px;
|
||||
min-width: 0;
|
||||
@@ -1047,6 +1077,9 @@ class TaskMateRewardsCard extends LitElement {
|
||||
header_color: '#e67e22',
|
||||
...config,
|
||||
};
|
||||
// Dashboards saved before #1010 stored the English default title; treat it
|
||||
// as unset so the translated default is shown instead.
|
||||
if (this.config.title === "Rewards") this.config.title = "";
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
@@ -1060,7 +1093,6 @@ class TaskMateRewardsCard extends LitElement {
|
||||
static getStubConfig() {
|
||||
return {
|
||||
entity: "sensor.taskmate_overview",
|
||||
title: "Rewards",
|
||||
child_id: null,
|
||||
show_child_badges: true,
|
||||
enable_pool_mode: false,
|
||||
@@ -1287,8 +1319,10 @@ class TaskMateRewardsCard extends LitElement {
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||||
<span class="spendable-label">${this._t('rewards.spendable_balance')}</span>
|
||||
<span class="spendable-value">
|
||||
${spendable}
|
||||
${detail}
|
||||
${spendable !== gross && this.getAttribute("dir") === "rtl"
|
||||
// "12 / 40" spans two elements, so isolate it with markup (#995).
|
||||
? html`<bdi dir="ltr">${spendable} ${detail}</bdi>`
|
||||
: html`${spendable} ${detail}`}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -1437,7 +1471,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
<ha-icon icon="mdi:clock-outline"></ha-icon>
|
||||
${this._t('rewards.awaiting_approval')}
|
||||
</div>
|
||||
` : ''}
|
||||
` : this._renderRejectNote(reward, childId ? relevantChild : null, 'reject-note')}
|
||||
|
||||
${showChildBadges && !isJackpot
|
||||
? html`
|
||||
@@ -1472,6 +1506,17 @@ class TaskMateRewardsCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject reasons (#976): why a parent turned down this child's last claim.
|
||||
* Only with a child in context — the reason is personal. Called from the
|
||||
* classic row AND _designRewardRow, so every design shows it.
|
||||
*/
|
||||
_renderRejectNote(reward, child, cls) {
|
||||
const rej = ((child && child.rejections) || []).find(r => r.kind === "reward" && r.id === reward.id);
|
||||
if (!rej || !rej.reason) return '';
|
||||
return html`<div class="${cls}" role="note">↩️ ${this._t('rewards.rejected_note', { reason: rej.reason })}</div>`;
|
||||
}
|
||||
|
||||
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
|
||||
_timeLockLabel(reward) {
|
||||
const summary = window.__taskmate_time_lock_label
|
||||
@@ -2060,7 +2105,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
? html`<span class="muted rw-status">⏳ ${this._t('rewards.awaiting_approval')}</span>`
|
||||
: d.canAfford && d.showClaim
|
||||
? html`<span class="rw-status rw-status-good">${this._t('reward_progress.ready_to_claim')}</span>`
|
||||
: html`<span class="muted rw-status">${d.currentStars} / ${d.displayCost}</span>`;
|
||||
: html`<span class="muted rw-status">${_ltrRatio(d.currentStars, d.displayCost)}</span>`;
|
||||
|
||||
return html`
|
||||
<div class="rw-card ${d.isJackpot ? 'rw-jackpot' : ''} ${d.isUnavailable || d.isTimeLocked ? 'rw-unavail' : ''}"
|
||||
@@ -2076,6 +2121,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
</div>
|
||||
${costBadge}
|
||||
</div>
|
||||
${d.hasPendingClaim ? '' : this._renderRejectNote(reward, d.childId ? d.relevantChild : null, 'rw-reject')}
|
||||
${bar}
|
||||
${d.isJackpot ? html`
|
||||
<div class="row rw-pool-foot">
|
||||
@@ -2231,7 +2277,7 @@ class TaskMateRewardsCardEditor extends LitElement {
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.colour-helper {
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
@@ -69,6 +69,72 @@ class TaskMateRoutineCard extends LitElement {
|
||||
return { entity: "sensor.taskmate_overview", child_id: "", time_category: "morning" };
|
||||
}
|
||||
|
||||
updated(changedProps) {
|
||||
super.updated?.(changedProps);
|
||||
this._scheduleUndoExpiry();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearTimeout(this._undoExpiryTimer);
|
||||
this._undoExpiryTimer = null;
|
||||
}
|
||||
|
||||
// ── Child undo (#918) ───────────────────────────────────────────────────
|
||||
// A step finished in this run can be taken back while the server says so:
|
||||
// child_undo_pending (not yet reviewed) or child_undo_until (auto-approved,
|
||||
// until that instant). Neither is set while the global window is 0.
|
||||
|
||||
_undoableCompletion(chore, now = Date.now()) {
|
||||
const child = this._child();
|
||||
if (!child || !chore) return null;
|
||||
const latest = (this._attrs().todays_completions || [])
|
||||
.filter(c => String(c.child_id) === String(child.id)
|
||||
&& String(c.chore_id) === String(chore.id) && !c.bonus_subtask_id)
|
||||
.sort((a, b) => new Date(b.completed_at || 0) - new Date(a.completed_at || 0))[0];
|
||||
if (!latest || !(latest.completion_id || latest.id)) return null;
|
||||
if (latest.child_undo_pending === true) return latest;
|
||||
const until = Date.parse(latest.child_undo_until || "");
|
||||
return Number.isFinite(until) && until > now ? latest : null;
|
||||
}
|
||||
|
||||
/** Re-render when the soonest undo window closes, so its button goes away. */
|
||||
_scheduleUndoExpiry() {
|
||||
clearTimeout(this._undoExpiryTimer);
|
||||
this._undoExpiryTimer = null;
|
||||
const child = this._child?.();
|
||||
if (!child) return;
|
||||
const now = Date.now();
|
||||
const deadlines = (this._attrs().todays_completions || [])
|
||||
.filter(c => String(c.child_id) === String(child.id))
|
||||
.map(c => Date.parse(c.child_undo_until || ""))
|
||||
.filter(t => Number.isFinite(t) && t > now);
|
||||
if (!deadlines.length) return;
|
||||
this._undoExpiryTimer = setTimeout(() => {
|
||||
this._undoExpiryTimer = null;
|
||||
this.requestUpdate();
|
||||
}, Math.min(Math.min(...deadlines) - now + 50, 2 ** 31 - 1));
|
||||
}
|
||||
|
||||
async _undo(chore, completion) {
|
||||
if (this._busy) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
// The service takes the child from the stored completion itself.
|
||||
await this.hass.callService("taskmate", "undo_chore", {
|
||||
completion_id: completion.completion_id || completion.id,
|
||||
});
|
||||
this._runCompleted.delete(String(chore.id));
|
||||
} catch (err) {
|
||||
this.dispatchEvent(new CustomEvent("hass-notification", {
|
||||
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
|
||||
}));
|
||||
} finally {
|
||||
this._busy = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
@@ -294,7 +360,17 @@ class TaskMateRoutineCard extends LitElement {
|
||||
${done
|
||||
? html`<button class="rt-btn rt-done is-done" disabled>
|
||||
${done.pending ? this._t("routine.sent_for_checking") : this._t("routine.completed")}
|
||||
</button>`
|
||||
</button>
|
||||
${(() => {
|
||||
const undoable = this._undoableCompletion(chore);
|
||||
return undoable
|
||||
? html`<button class="rt-btn rt-undo" ?disabled=${this._busy}
|
||||
@click=${() => this._undo(chore, undoable)}>
|
||||
<ha-icon class="tm-rtl-flip" icon="mdi:undo-variant"></ha-icon>
|
||||
${this._t("child.undo_named", { name: chore.name })}
|
||||
</button>`
|
||||
: "";
|
||||
})()}`
|
||||
: html`<button class="rt-btn rt-done" ?disabled=${this._busy}
|
||||
@click=${() => this._complete(chore)}>
|
||||
${this._t("routine.done")}
|
||||
@@ -508,6 +584,15 @@ class TaskMateRoutineCard extends LitElement {
|
||||
background: var(--tmd-dim, var(--disabled-text-color, #9ca3af));
|
||||
}
|
||||
:host([data-tm-design]:not([data-tm-design="classic"])) .rt-done.is-done { color: #fff; }
|
||||
.rt-undo {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
background: transparent;
|
||||
color: var(--tmd-text, var(--primary-text-color));
|
||||
border: 2px solid var(--routine-accent, var(--tmd-accent, #7c3aed));
|
||||
padding: 12px; font-size: 15px; min-height: 44px;
|
||||
}
|
||||
.rt-undo ha-icon { --mdc-icon-size: 20px; }
|
||||
.rt-undo:disabled { opacity: 0.5; }
|
||||
.rt-row { display: flex; gap: 10px; }
|
||||
.rt-skip {
|
||||
background: var(--tmd-surface-2, var(--divider-color, #e5e7eb));
|
||||
|
||||
@@ -132,6 +132,18 @@ class TaskMateStreakCard extends LitElement {
|
||||
|
||||
.streak-emoji { font-size: 1.4rem; }
|
||||
|
||||
/* Streak freeze tokens (#925) */
|
||||
.streak-freeze {
|
||||
margin-inline-start: auto;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: rgba(52, 152, 219, 0.14);
|
||||
color: #1f6fa8;
|
||||
}
|
||||
|
||||
/* Streak bar - visual days */
|
||||
.streak-days {
|
||||
display: flex;
|
||||
@@ -247,6 +259,9 @@ class TaskMateStreakCard extends LitElement {
|
||||
.sk-seg-cp { flex: 1; height: 9px; border-radius: 3px; background: var(--tmd-surface-2); }
|
||||
.sk-seg-cp.on { background: var(--tmd-good); }
|
||||
.sk-badges-cp { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 9px; }
|
||||
|
||||
/* Streak freeze tokens (#925), every designed layout */
|
||||
.sk-freeze { white-space: nowrap; flex: none; }
|
||||
`;
|
||||
const tokens = window.__taskmate_design && window.__taskmate_design.styles
|
||||
? window.__taskmate_design.styles() : null;
|
||||
@@ -343,6 +358,7 @@ class TaskMateStreakCard extends LitElement {
|
||||
<span class="streak-number ${streakClass}">${streak}</span>
|
||||
<span class="streak-label">${this._t('streak.day_streak')}</span>
|
||||
<span class="streak-emoji">${streakEmoji}</span>
|
||||
${this._freezeChip(child, "streak-freeze")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,6 +386,20 @@ class TaskMateStreakCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streak-freeze tokens (#925): "❄️ N". The sensor only sends
|
||||
* streak_freezes while the feature is on, so an absent field means
|
||||
* "not in use" and renders nothing — a 0 still shows, so a child can
|
||||
* see they have none left. Used by the classic tile AND every designed
|
||||
* layout (the two-render-paths rule).
|
||||
*/
|
||||
_freezeChip(child, cls) {
|
||||
const n = child.streak_freezes;
|
||||
if (typeof n !== "number") return "";
|
||||
return html`<span class="${cls}" title="${this._t('streak.freezes_tooltip')}"
|
||||
aria-label="${this._t('streak.freezes_aria', { count: n })}">❄️ ${n}</span>`;
|
||||
}
|
||||
|
||||
_calculateStreak(completions) {
|
||||
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
@@ -577,6 +607,7 @@ class TaskMateStreakCard extends LitElement {
|
||||
<div class="muted sk-sub">${this._t('streak.day_streak')}</div>
|
||||
</div>
|
||||
<div class="big sk-count">${r.streak} ${this._streakEmoji(r.streak)}</div>
|
||||
${this._freezeChip(r.child, "chip soft sk-freeze")}
|
||||
</div>
|
||||
<div class="sk-dots">
|
||||
${r.dayDots.map((d) => html`<i class="sk-dot ${d.cssClass !== "inactive" ? "on" : ""}" title="${d.label}"></i>`)}
|
||||
@@ -598,6 +629,7 @@ class TaskMateStreakCard extends LitElement {
|
||||
<div class="muted sk-combo-label">${this._t('streak.achievements')}</div>
|
||||
</div>
|
||||
<div class="num sk-combo">x${r.streak} ${this._streakEmoji(r.streak)}</div>
|
||||
${this._freezeChip(r.child, "chip sk-freeze")}
|
||||
</div>
|
||||
<div class="sk-segs">
|
||||
${r.dayDots.map((d) => html`<i class="sk-seg ${d.cssClass !== "inactive" ? "on" : ""}"></i>`)}
|
||||
@@ -618,6 +650,7 @@ class TaskMateStreakCard extends LitElement {
|
||||
${this._av(r.child, r.tone, 34)}
|
||||
<div style="flex:1;min-width:0"><div style="font-weight:600">${r.child.name}</div></div>
|
||||
<div class="num" style="color:${tone(r.streak)}">${this._streakEmoji(r.streak)} ${this._t('common.d_streak', { count: r.streak })}</div>
|
||||
${this._freezeChip(r.child, "chip sk-freeze")}
|
||||
</div>
|
||||
<div class="sk-segs-cp">
|
||||
${r.dayDots.map((d) => html`<i class="sk-seg-cp ${d.cssClass !== "inactive" ? "on" : ""}"></i>`)}
|
||||
@@ -654,7 +687,7 @@ class TaskMateStreakCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -752,7 +752,7 @@ class TaskMateWeeklyCardEditor extends LitElement {
|
||||
.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-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-inline-start: auto; }
|
||||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
/**
|
||||
* TaskMate Wishlist Card (#932)
|
||||
*
|
||||
* One child's wishlist: things they want, what they have saved towards each,
|
||||
* and what the family has pledged. The child adds a wish (it waits for a
|
||||
* grown-up), moves points in, takes their own savings back out, and asks to
|
||||
* redeem once it's funded — which becomes an ordinary reward claim for the
|
||||
* parent. Pledges are entered by a parent in the admin panel.
|
||||
*
|
||||
* Reads the overview sensor (children, points name/icon) plus the child's own
|
||||
* wishlist sensor, found by its `wishlist_child_id` attribute. Pictures are
|
||||
* stored images, signed per viewer through auth/sign_path before use.
|
||||
*
|
||||
* Single layout, so it consumes the design tokens directly (like the family
|
||||
* goal card) rather than carrying a second _renderDesigned() template. Every
|
||||
* --tmd-* reference has the card's original value as its fallback, so classic
|
||||
* — which defines no tokens — renders as designed. Layout classes carry a
|
||||
* wl- prefix because the shared design kit defines its own .btn / .bar.
|
||||
*/
|
||||
|
||||
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);
|
||||
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
|
||||
// identity until the design layer has loaded (it is also what stamps dir).
|
||||
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
||||
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
|
||||
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
|
||||
const _ltrRatio = (a, b) => {
|
||||
const s = `${a} / ${b}`;
|
||||
const r = _ltrNums(s);
|
||||
return r === s ? html`${a} / ${b}` : r;
|
||||
};
|
||||
const DEFAULT_ACCENT = "#e91e63";
|
||||
const IMAGE_PREFIX = "/api/taskmate/image/";
|
||||
const TARGET_STEP = 10;
|
||||
const MAX_TARGET = 100000;
|
||||
|
||||
/**
|
||||
* A wish link is typed by a child. Only an absolute http(s) URL is ever
|
||||
* rendered as a link — never javascript:, data: or a relative path — even
|
||||
* though the backend already refuses anything else.
|
||||
*/
|
||||
function safeLink(value) {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
return { href: url.href, host: url.hostname.replace(/^www\./, "") };
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TaskMateWishlistCard extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
_sheet: { state: true },
|
||||
_signed: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._sheet = null;
|
||||
this._signed = {};
|
||||
this._inflight = new Set();
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
if (!config || !config.entity) {
|
||||
throw new Error("A TaskMate overview entity is required");
|
||||
}
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview" };
|
||||
}
|
||||
|
||||
static getConfigElement() {
|
||||
return document.createElement("taskmate-wishlist-card-editor");
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return 5;
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
const old = changedProps.get("hass");
|
||||
const wlId = this._wishlistEntityId();
|
||||
if (old && wlId && old.states && this.hass.states[wlId] !== old.states[wlId]) return true;
|
||||
return window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(old, this.hass, this.config && this.config.entity)
|
||||
: true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active design, stamp data-tm-design on the host, and set the
|
||||
* accent. The accent is an inline host property, not a template <style>: a
|
||||
* shadow tree's <style> is ordered before adoptedStyleSheets, so it would
|
||||
* lose to the :host default in static styles().
|
||||
*/
|
||||
_applyDesign() {
|
||||
if (window.__taskmate_design) {
|
||||
window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity);
|
||||
}
|
||||
const configured = this.config.header_color;
|
||||
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
|
||||
this.style.setProperty("--wl-accent", _safeColor(configured, DEFAULT_ACCENT));
|
||||
} else {
|
||||
this.style.removeProperty("--wl-accent");
|
||||
}
|
||||
}
|
||||
|
||||
// ── data ────────────────────────────────────────────────────────────────
|
||||
/** The child's own wishlist sensor, found by the child id it carries. */
|
||||
_wishlistEntityId() {
|
||||
const childId = this.config && this.config.child_id;
|
||||
if (!childId || !this.hass || !this.hass.states) return null;
|
||||
const cached = this._wlEntityId;
|
||||
const cachedState = cached && this.hass.states[cached];
|
||||
if (cachedState && cachedState.attributes && cachedState.attributes.wishlist_child_id === childId) return cached;
|
||||
this._wlEntityId = null;
|
||||
for (const [id, st] of Object.entries(this.hass.states)) {
|
||||
if (id.startsWith("sensor.") && st && st.attributes && st.attributes.wishlist_child_id === childId) {
|
||||
this._wlEntityId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this._wlEntityId;
|
||||
}
|
||||
|
||||
_wishlist() {
|
||||
const id = this._wishlistEntityId();
|
||||
const st = id && this.hass.states[id];
|
||||
const attrs = (st && st.attributes) || {};
|
||||
return {
|
||||
wishes: Array.isArray(attrs.wishes) ? attrs.wishes : [],
|
||||
max: Number(attrs.wish_max_open) || 5,
|
||||
};
|
||||
}
|
||||
|
||||
_pledged(w) {
|
||||
return Number(w.pledged) || 0;
|
||||
}
|
||||
|
||||
_remaining(w) {
|
||||
return Math.max(0, (Number(w.target) || 0) - (Number(w.saved) || 0) - this._pledged(w));
|
||||
}
|
||||
|
||||
_funded(w) {
|
||||
return (Number(w.target) || 0) > 0 && this._remaining(w) === 0;
|
||||
}
|
||||
|
||||
// ── pictures ────────────────────────────────────────────────────────────
|
||||
async _ensureSigned(wishes) {
|
||||
for (const w of wishes) {
|
||||
const url = w.image_url;
|
||||
// Only our own stored images are signed; nothing else is ever handed to
|
||||
// auth/sign_path or rendered as a picture.
|
||||
if (typeof url !== "string" || !url.startsWith(IMAGE_PREFIX)) continue;
|
||||
if (this._signed[url] || this._inflight.has(url)) continue;
|
||||
this._inflight.add(url);
|
||||
try {
|
||||
const res = await this.hass.callWS({ type: "auth/sign_path", path: url, expires: 3600 });
|
||||
if (res && res.path) this._signed = { ...this._signed, [url]: res.path };
|
||||
} catch (e) {
|
||||
console.warn("taskmate: sign_path failed", e);
|
||||
} finally {
|
||||
this._inflight.delete(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated() {
|
||||
if (this.hass && this.config) this._ensureSigned(this._wishlist().wishes);
|
||||
}
|
||||
|
||||
// ── actions ─────────────────────────────────────────────────────────────
|
||||
async _call(service, data) {
|
||||
this._sheet = this._sheet ? { ...this._sheet, busy: true, error: "" } : null;
|
||||
try {
|
||||
await this.hass.callService("taskmate", service, { child_id: this.config.child_id, ...data });
|
||||
this._closeSheet();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = (err && err.message) || String(err);
|
||||
if (this._sheet) {
|
||||
this._sheet = { ...this._sheet, busy: false, error: this._t("wishlist.error", { error: message }) };
|
||||
} else {
|
||||
this._flash = this._t("wishlist.error", { error: message });
|
||||
this.requestUpdate();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_openSheet(sheet) {
|
||||
this._flash = "";
|
||||
this._sheet = { busy: false, error: "", ...sheet };
|
||||
}
|
||||
|
||||
_closeSheet() {
|
||||
if (this._sheet && this._sheet.previewUrl) URL.revokeObjectURL(this._sheet.previewUrl);
|
||||
this._sheet = null;
|
||||
}
|
||||
|
||||
_openAdd() {
|
||||
this._openSheet({ kind: "add", name: "", target: 50, link: "" });
|
||||
}
|
||||
|
||||
_openMove(w, spendable) {
|
||||
const max = Math.min(spendable, this._remaining(w));
|
||||
this._openSheet({ kind: "move", id: w.id, amount: Math.min(max, 50) });
|
||||
}
|
||||
|
||||
_openTake(w) {
|
||||
this._openSheet({ kind: "take", id: w.id, amount: Number(w.saved) || 0 });
|
||||
}
|
||||
|
||||
_setSheet(patch) {
|
||||
this._sheet = { ...this._sheet, ...patch, error: "" };
|
||||
}
|
||||
|
||||
_stepTarget(delta) {
|
||||
const next = Math.min(MAX_TARGET, Math.max(1, (Number(this._sheet.target) || 0) + delta));
|
||||
this._setSheet({ target: next });
|
||||
}
|
||||
|
||||
_pickPicture() {
|
||||
const input = this.renderRoot && this.renderRoot.querySelector("#wl-file");
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
||||
/** Downscale to a small JPEG in the browser: the server keeps it as-is. */
|
||||
_onPicture(e) {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = "";
|
||||
if (!file || !this._sheet) return;
|
||||
if (!file.type || !file.type.startsWith("image/")) {
|
||||
this._sheet = { ...this._sheet, error: this._t("wishlist.picture_not_image") };
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const max = 512;
|
||||
let { width, height } = img;
|
||||
if (width > max || height > max) {
|
||||
const scale = Math.min(max / width, max / height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!this._sheet) return;
|
||||
if (!blob) {
|
||||
this._sheet = { ...this._sheet, error: this._t("wishlist.picture_failed") };
|
||||
return;
|
||||
}
|
||||
if (this._sheet.previewUrl) URL.revokeObjectURL(this._sheet.previewUrl);
|
||||
this._sheet = { ...this._sheet, blob, previewUrl: URL.createObjectURL(blob), error: "" };
|
||||
}, "image/jpeg", 0.82);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
if (this._sheet) this._sheet = { ...this._sheet, error: this._t("wishlist.picture_failed") };
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
/** Post the picture to the photo endpoint (open to children); the wish takes it over. */
|
||||
async _uploadPicture(blob) {
|
||||
const post = () => {
|
||||
const token = this.hass && this.hass.auth && this.hass.auth.data && this.hass.auth.data.access_token;
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, "wish.jpg");
|
||||
return fetch("/api/taskmate/photo", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
};
|
||||
if (this.hass.auth && this.hass.auth.expired && this.hass.auth.refreshAccessToken) {
|
||||
try { await this.hass.auth.refreshAccessToken(); } catch (_e) { /* surfaced below */ }
|
||||
}
|
||||
let resp = await post();
|
||||
if (resp.status === 401 && this.hass.auth && this.hass.auth.refreshAccessToken) {
|
||||
await this.hass.auth.refreshAccessToken();
|
||||
resp = await post();
|
||||
}
|
||||
if (!resp.ok) throw new Error(`upload failed (${resp.status})`);
|
||||
const body = await resp.json();
|
||||
if (!body || !body.photo_url) throw new Error("no photo_url");
|
||||
return body.photo_url;
|
||||
}
|
||||
|
||||
async _submitAdd() {
|
||||
const s = this._sheet;
|
||||
const name = (s.name || "").trim();
|
||||
if (!name) return;
|
||||
const link = (s.link || "").trim();
|
||||
if (link && !safeLink(link)) {
|
||||
this._sheet = { ...s, error: this._t("wishlist.link_invalid") };
|
||||
return;
|
||||
}
|
||||
const data = { name, target: Number(s.target) || 1 };
|
||||
if (link) data.link = link;
|
||||
if (s.blob) {
|
||||
this._sheet = { ...s, busy: true, error: "" };
|
||||
try {
|
||||
data.photo_url = await this._uploadPicture(s.blob);
|
||||
} catch (_e) {
|
||||
this._sheet = { ...this._sheet, busy: false, error: this._t("wishlist.picture_failed") };
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this._call("add_wish", data);
|
||||
}
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
this._applyDesign();
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
if (!entity) {
|
||||
return html`<ha-card><div class="wl-empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
|
||||
}
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
const children = Array.isArray(attrs.children) ? attrs.children : [];
|
||||
const child = children.find((c) => c.id === this.config.child_id);
|
||||
if (!this.config.child_id || !child) {
|
||||
return html`<ha-card><div class="wl-empty"><ha-icon icon="mdi:heart-outline"></ha-icon><div>${this._t("wishlist.pick_child")}</div></div></ha-card>`;
|
||||
}
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
const spendable = Number(child.spendable_balance != null ? child.spendable_balance : child.points) || 0;
|
||||
const { wishes, max } = this._wishlist();
|
||||
const open = wishes.filter((w) => w.status === "pending" || w.status === "active" || w.status === "redeem_requested");
|
||||
const reserved = wishes
|
||||
.filter((w) => w.status === "active" || w.status === "redeem_requested")
|
||||
.reduce((sum, w) => sum + (Number(w.saved) || 0), 0);
|
||||
const atLimit = open.length >= max;
|
||||
// Declined wishes sink to the bottom; the rest keep the order they were made in.
|
||||
const ordered = [...wishes.filter((w) => w.status !== "declined"), ...wishes.filter((w) => w.status === "declined")];
|
||||
const title = this.config.title || this._t("wishlist.title", { name: child.name });
|
||||
|
||||
return html`
|
||||
<ha-card class="${this._sheet ? "wl-has-sheet" : ""}">
|
||||
<div class="wl-header">
|
||||
<div class="wl-hd-left">
|
||||
<ha-icon class="wl-hd-icon" icon="mdi:heart"></ha-icon>
|
||||
<div class="wl-hd-text">
|
||||
<div class="wl-hd-title">${title}</div>
|
||||
<div class="wl-hd-sub">${this._t("wishlist.subtitle")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="wl-hd-pill" ?disabled=${atLimit}
|
||||
title="${atLimit ? this._t("wishlist.limit_reached", { max }) : this._t("wishlist.add")}"
|
||||
@click=${() => this._openAdd()}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>${this._t("wishlist.add")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="wl-balance">
|
||||
<ha-icon icon="mdi:piggy-bank-outline"></ha-icon>
|
||||
<span>${this._t("wishlist.can_spend")}</span>
|
||||
<b class="wl-amount"><ha-icon icon="${pointsIcon}"></ha-icon>${spendable}</b>
|
||||
<span class="wl-reserved">${this._t("wishlist.saved_in_wishes", { points: reserved })}</span>
|
||||
</div>
|
||||
${this._flash ? html`<div class="wl-flash" role="alert">${this._flash}</div>` : ""}
|
||||
<div class="wl-content">
|
||||
${ordered.length === 0
|
||||
? html`<div class="wl-empty wl-empty-list"><ha-icon icon="mdi:gift-outline"></ha-icon><div>${this._t("wishlist.empty")}</div></div>`
|
||||
: ordered.map((w) => this._renderWish(w, spendable))}
|
||||
</div>
|
||||
${this._sheet ? this._renderSheet(wishes, spendable, pointsIcon) : ""}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderThumb(w) {
|
||||
const src = this._signed[w.image_url];
|
||||
return src
|
||||
? html`<img class="wl-thumb" src="${src}" alt="" loading="lazy">`
|
||||
: html`<div class="wl-thumb wl-thumb-ph"><ha-icon icon="mdi:gift"></ha-icon></div>`;
|
||||
}
|
||||
|
||||
_renderWish(w, spendable) {
|
||||
const target = Number(w.target) || 0;
|
||||
const saved = Number(w.saved) || 0;
|
||||
const pledged = this._pledged(w);
|
||||
const remaining = this._remaining(w);
|
||||
const funded = this._funded(w);
|
||||
const link = safeLink(w.link);
|
||||
const showProgress = w.status === "active" || w.status === "redeem_requested";
|
||||
const cls = w.status === "pending" ? "pending"
|
||||
: w.status === "declined" ? "declined"
|
||||
: (funded || w.status === "redeem_requested") ? "funded" : "";
|
||||
const pct = (n) => (target > 0 ? Math.max(0, Math.min(100, (n / target) * 100)) : 0);
|
||||
|
||||
return html`
|
||||
<div class="wl-wish ${cls}" data-wish="${w.id}">
|
||||
<div class="wl-wish-top">
|
||||
${this._renderThumb(w)}
|
||||
<div class="wl-grow">
|
||||
<div class="wl-wish-name">${w.name}</div>
|
||||
<div class="wl-wish-meta">
|
||||
<span class="wl-tag"><ha-icon icon="mdi:flag-checkered"></ha-icon>${this._t("wishlist.target_tag", { points: target })}</span>
|
||||
${link ? html`<a class="wl-link" href="${link.href}" target="_blank" rel="noopener noreferrer"
|
||||
title="${this._t("wishlist.open_link")}"><ha-icon icon="mdi:link-variant"></ha-icon>${link.host}</a>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${showProgress ? html`
|
||||
<div class="wl-split" role="img" aria-label="${this._t("wishlist.progress_label", { total: Math.min(target, saved + pledged), target })}">
|
||||
<i class="wl-me" style="width:${pct(saved)}%"></i>
|
||||
<i class="wl-fam" style="width:${pct(Math.min(pledged, Math.max(0, target - saved)))}%"></i>
|
||||
</div>
|
||||
<div class="wl-legend">
|
||||
<span><span class="wl-sw wl-sw-me"></span>${this._t("wishlist.legend_me", { points: saved })}</span>
|
||||
<span><span class="wl-sw wl-sw-fam"></span>${this._t("wishlist.legend_family", { points: pledged })}</span>
|
||||
<span class="wl-tot">${_ltrRatio(Math.min(target, saved + pledged), target)}</span>
|
||||
</div>
|
||||
${Array.isArray(w.pledges) && w.pledges.length ? html`
|
||||
<div class="wl-pledges">
|
||||
${w.pledges.map((p) => html`<span class="wl-pl"><ha-icon icon="mdi:heart"></ha-icon>${p.name} +${p.points}</span>`)}
|
||||
</div>` : ""}
|
||||
` : ""}
|
||||
<div class="wl-acts">${this._renderActions(w, spendable, remaining, funded)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderActions(w, spendable, remaining, funded) {
|
||||
if (w.status === "pending") {
|
||||
return html`
|
||||
<span class="wl-status warn"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("wishlist.status_pending")}</span>
|
||||
<button class="wl-btn text" @click=${() => this._call("withdraw_wish", { wish_id: w.id })}>${this._t("wishlist.withdraw")}</button>`;
|
||||
}
|
||||
if (w.status === "declined") {
|
||||
return html`
|
||||
<span class="wl-status bad"><ha-icon icon="mdi:close-circle-outline"></ha-icon>${this._t("wishlist.status_declined")}</span>
|
||||
${w.decline_reason ? html`<span class="wl-reason">${w.decline_reason}</span>` : ""}
|
||||
<button class="wl-btn text" @click=${() => this._call("withdraw_wish", { wish_id: w.id })}>${this._t("wishlist.dismiss")}</button>`;
|
||||
}
|
||||
if (w.status === "redeem_requested") {
|
||||
return html`<span class="wl-status good"><ha-icon icon="mdi:gift"></ha-icon>${this._t("wishlist.status_redeem_requested")}</span>`;
|
||||
}
|
||||
const takeBack = (Number(w.saved) || 0) > 0
|
||||
? html`<button class="wl-btn text" @click=${() => this._openTake(w)}>${this._t("wishlist.take_back")}</button>`
|
||||
: "";
|
||||
if (funded) {
|
||||
return html`
|
||||
<div class="wl-funded"><ha-icon icon="mdi:trophy"></ha-icon>${this._t("wishlist.fully_funded")}</div>
|
||||
<button class="wl-btn green" @click=${() => this._openSheet({ kind: "redeem", id: w.id })}>
|
||||
<ha-icon icon="mdi:gift"></ha-icon>${this._t("wishlist.ask_redeem")}</button>
|
||||
${takeBack}`;
|
||||
}
|
||||
return html`
|
||||
<button class="wl-btn accent" ?disabled=${spendable < 1} @click=${() => this._openMove(w, spendable)}>
|
||||
<ha-icon icon="mdi:piggy-bank-outline"></ha-icon>${this._t("wishlist.move_points")}</button>
|
||||
<span class="wl-to-go">${this._t("wishlist.to_go", { points: remaining })}</span>
|
||||
<span class="wl-grow"></span>
|
||||
${takeBack}`;
|
||||
}
|
||||
|
||||
_renderSheet(wishes, spendable, pointsIcon) {
|
||||
const s = this._sheet;
|
||||
const wish = s.id ? wishes.find((w) => w.id === s.id) : null;
|
||||
let body = "";
|
||||
if (s.kind === "add") body = this._renderAddSheet(s);
|
||||
else if (!wish) return "";
|
||||
else if (s.kind === "move") body = this._renderAmountSheet(s, wish, Math.min(spendable, this._remaining(wish)), pointsIcon, "move", spendable);
|
||||
else if (s.kind === "take") body = this._renderAmountSheet(s, wish, Number(wish.saved) || 0, pointsIcon, "take", spendable);
|
||||
else if (s.kind === "redeem") body = this._renderRedeemSheet(s, wish);
|
||||
return html`
|
||||
<div class="wl-scrim" @click=${(e) => { if (e.target === e.currentTarget && !s.busy) this._closeSheet(); }}>
|
||||
<div class="wl-sheet" role="dialog" aria-modal="true">
|
||||
<div class="wl-grab"></div>
|
||||
${body}
|
||||
${s.error ? html`<div class="wl-error" role="alert">${s.error}</div>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderAddSheet(s) {
|
||||
return html`
|
||||
<h3><ha-icon icon="mdi:star-shooting-outline"></ha-icon>${this._t("wishlist.add_title")}</h3>
|
||||
<label class="wl-field">
|
||||
<span class="wl-label">${this._t("wishlist.field_name")}</span>
|
||||
<input class="wl-input" type="text" maxlength="60" .value=${s.name}
|
||||
@input=${(e) => this._setSheet({ name: e.target.value })}>
|
||||
</label>
|
||||
<div class="wl-field">
|
||||
<span class="wl-label">${this._t("wishlist.field_target")}</span>
|
||||
<div class="wl-row">
|
||||
<div class="wl-stepper">
|
||||
<button aria-label="−" @click=${() => this._stepTarget(-TARGET_STEP)}>−</button>
|
||||
<input type="number" min="1" max="${MAX_TARGET}" .value=${String(s.target)}
|
||||
@input=${(e) => this._setSheet({ target: Math.max(1, Math.min(MAX_TARGET, parseInt(e.target.value, 10) || 1)) })}>
|
||||
<button aria-label="+" @click=${() => this._stepTarget(TARGET_STEP)}>+</button>
|
||||
</div>
|
||||
<span class="wl-hint">${this._t("wishlist.target_hint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="wl-field">
|
||||
<span class="wl-label">${this._t("wishlist.field_link")} <span class="wl-hint">${this._t("wishlist.optional")}</span></span>
|
||||
<input class="wl-input" type="url" maxlength="500" placeholder="https://…" .value=${s.link}
|
||||
@input=${(e) => this._setSheet({ link: e.target.value })}>
|
||||
</label>
|
||||
<div class="wl-field">
|
||||
<span class="wl-label">${this._t("wishlist.field_picture")} <span class="wl-hint">${this._t("wishlist.optional")}</span></span>
|
||||
<input id="wl-file" type="file" accept="image/*" hidden @change=${(e) => this._onPicture(e)}>
|
||||
<button class="wl-drop" @click=${() => this._pickPicture()}>
|
||||
${s.previewUrl
|
||||
? html`<img src="${s.previewUrl}" alt=""><span>${this._t("wishlist.picture_change")}</span>`
|
||||
: html`<ha-icon icon="mdi:image-plus"></ha-icon><span>${this._t("wishlist.picture_pick")}</span>`}
|
||||
</button>
|
||||
</div>
|
||||
<div class="wl-sheet-acts">
|
||||
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("common.cancel")}</button>
|
||||
<button class="wl-btn accent" ?disabled=${s.busy || !(s.name || "").trim()} @click=${() => this._submitAdd()}>${this._t("wishlist.send")}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderAmountSheet(s, wish, max, pointsIcon, mode, spendable) {
|
||||
const amount = Math.max(0, Math.min(Number(s.amount) || 0, max));
|
||||
const isMove = mode === "move";
|
||||
const chips = [10, 25, 50].filter((n) => n < max);
|
||||
const service = isMove ? "move_points_to_wish" : "take_points_from_wish";
|
||||
return html`
|
||||
<h3><ha-icon class=${isMove ? "" : "tm-rtl-flip"} icon="${isMove ? "mdi:piggy-bank-outline" : "mdi:undo-variant"}"></ha-icon>${this._t(isMove ? "wishlist.move_title" : "wishlist.take_title", { name: wish.name })}</h3>
|
||||
<div class="wl-big-amt">${amount}<ha-icon icon="${pointsIcon}"></ha-icon></div>
|
||||
<input class="wl-range" type="range" min="0" max="${max}" step="1" .value=${String(amount)}
|
||||
@input=${(e) => this._setSheet({ amount: Number(e.target.value) })}>
|
||||
<div class="wl-range-info">
|
||||
<span>0</span>
|
||||
<span>${isMove
|
||||
? this._t("wishlist.move_info", { balance: spendable, needed: this._remaining(wish) })
|
||||
: this._t("wishlist.take_info", { saved: Number(wish.saved) || 0 })}</span>
|
||||
<span>${max}</span>
|
||||
</div>
|
||||
<div class="wl-chips">
|
||||
${chips.map((n) => html`<button class="wl-chip ${amount === n ? "on" : ""}" @click=${() => this._setSheet({ amount: n })}>+${n}</button>`)}
|
||||
<button class="wl-chip ${amount === max ? "on" : ""}" @click=${() => this._setSheet({ amount: max })}>
|
||||
${this._t(isMove ? "wishlist.all_i_need" : "wishlist.take_all", { points: max })}</button>
|
||||
</div>
|
||||
<div class="wl-sheet-acts">
|
||||
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("common.cancel")}</button>
|
||||
<button class="wl-btn accent" ?disabled=${s.busy || amount < 1}
|
||||
@click=${() => this._call(service, { wish_id: wish.id, points: amount })}>
|
||||
${this._t(isMove ? "wishlist.move_confirm" : "wishlist.take_confirm", { points: amount })}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderRedeemSheet(s, wish) {
|
||||
return html`
|
||||
<div class="wl-redeem">
|
||||
<div class="wl-big-ic"><ha-icon icon="mdi:gift"></ha-icon></div>
|
||||
<h3>${this._t("wishlist.redeem_title", { name: wish.name })}</h3>
|
||||
<p>${this._t("wishlist.redeem_body")}</p>
|
||||
<div class="wl-sheet-acts center">
|
||||
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("wishlist.not_yet")}</button>
|
||||
<button class="wl-btn green" ?disabled=${s.busy}
|
||||
@click=${() => this._call("request_wish_redeem", { wish_id: wish.id })}>${this._t("wishlist.yes_please")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host {
|
||||
display: block;
|
||||
--wl-accent: var(--tmd-accent, #e91e63);
|
||||
--wl-good: var(--tmd-good, #2ecc71);
|
||||
--wl-gold: var(--tmd-gold, #f1c40f);
|
||||
--wl-text: var(--tmd-text, var(--primary-text-color));
|
||||
--wl-dim: var(--tmd-dim, var(--secondary-text-color));
|
||||
--wl-surface: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
||||
--wl-surface-2: var(--tmd-surface-2, var(--secondary-background-color, #f5f5f5));
|
||||
--wl-border: var(--tmd-border, var(--divider-color, #e0e0e0));
|
||||
--wl-me: var(--wl-accent);
|
||||
--wl-link: var(--tmd-accent, var(--primary-color, #03a9f4));
|
||||
}
|
||||
/* Graphite keeps colour for meaning only: its accent is a neutral that
|
||||
all but vanishes on its own dark surfaces, so the child's share, links
|
||||
and the main button follow the text colour instead (as its kit does). */
|
||||
:host([data-tm-design="graphite"]) {
|
||||
--wl-me: var(--tmd-text);
|
||||
--wl-link: var(--tmd-text);
|
||||
}
|
||||
:host([data-tm-design="graphite"]) .wl-btn.accent { background: var(--tmd-text); color: var(--tmd-surface); }
|
||||
ha-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--wl-surface);
|
||||
color: var(--wl-text);
|
||||
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
|
||||
font-family: var(--tmd-font-body, inherit);
|
||||
}
|
||||
ha-card.wl-has-sheet { min-height: 560px; }
|
||||
button { font-family: inherit; }
|
||||
ha-icon { --mdc-icon-size: 18px; }
|
||||
|
||||
.wl-header {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 14px 18px; background: var(--wl-accent); color: var(--tmd-hd-text, #fff);
|
||||
}
|
||||
.wl-hd-left { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
|
||||
.wl-hd-icon { --mdc-icon-size: 30px; flex: none; opacity: .95; }
|
||||
.wl-hd-text { min-width: 0; }
|
||||
.wl-hd-title {
|
||||
font-size: 1.3rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
}
|
||||
.wl-hd-sub { font-size: .8rem; opacity: .85; margin-top: 1px; }
|
||||
.wl-hd-pill {
|
||||
display: inline-flex; align-items: center; gap: 5px; flex: none; cursor: pointer;
|
||||
background: rgba(255, 255, 255, .2); color: inherit; border: 0;
|
||||
padding: 5px 12px; border-radius: 16px; font-size: .9rem; font-weight: 500; white-space: nowrap;
|
||||
}
|
||||
.wl-hd-pill[disabled] { opacity: .5; cursor: default; }
|
||||
|
||||
.wl-balance {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 10px 16px; font-size: .92rem;
|
||||
background: color-mix(in srgb, var(--wl-accent) 10%, var(--wl-surface));
|
||||
border-bottom: 1px solid var(--wl-border);
|
||||
}
|
||||
.wl-balance > ha-icon { color: var(--wl-accent); }
|
||||
.wl-amount { display: inline-flex; align-items: center; gap: 3px; font-size: 1.05rem; color: var(--wl-gold); }
|
||||
.wl-reserved { margin-inline-start: auto; font-size: .78rem; color: var(--wl-dim); }
|
||||
.wl-flash { margin: 10px 16px 0; font-size: .85rem; color: var(--tmd-bad, #e74c3c); }
|
||||
|
||||
.wl-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.wl-empty {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
|
||||
padding: 28px 16px; color: var(--wl-dim);
|
||||
}
|
||||
.wl-empty ha-icon { --mdc-icon-size: 32px; }
|
||||
.wl-empty-list { padding: 18px 8px; }
|
||||
|
||||
.wl-wish {
|
||||
background: var(--wl-surface-2); border: 1px solid var(--wl-border);
|
||||
border-radius: var(--tmd-radius-sm, 14px); overflow: hidden;
|
||||
}
|
||||
.wl-wish.pending { border-style: dashed; opacity: .88; }
|
||||
.wl-wish.declined { opacity: .8; }
|
||||
.wl-wish.funded {
|
||||
border: 2px solid var(--wl-good);
|
||||
background: color-mix(in srgb, var(--wl-good) 8%, var(--wl-surface-2));
|
||||
}
|
||||
.wl-wish-top { display: flex; gap: 12px; padding: 12px; }
|
||||
.wl-thumb {
|
||||
width: 72px; height: 72px; flex: none; border-radius: var(--tmd-radius-sm, 12px);
|
||||
object-fit: cover; display: block;
|
||||
}
|
||||
.wl-thumb-ph {
|
||||
display: grid; place-items: center; color: rgba(255, 255, 255, .92);
|
||||
background: linear-gradient(135deg, var(--wl-accent), color-mix(in srgb, var(--wl-accent) 45%, #3498db));
|
||||
}
|
||||
.wl-thumb-ph ha-icon { --mdc-icon-size: 34px; }
|
||||
.wl-grow { flex: 1; min-width: 0; }
|
||||
.wl-wish-name {
|
||||
font-size: 1.1rem; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere;
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
}
|
||||
.wl-wish-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; align-items: center; }
|
||||
.wl-tag, .wl-link {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: .75rem; font-weight: 600;
|
||||
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.wl-tag { background: color-mix(in srgb, var(--wl-text) 8%, transparent); color: var(--wl-dim); }
|
||||
.wl-tag ha-icon, .wl-link ha-icon { --mdc-icon-size: 13px; }
|
||||
.wl-link {
|
||||
color: var(--wl-link); text-decoration: none;
|
||||
background: color-mix(in srgb, var(--wl-link) 14%, transparent);
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wl-split {
|
||||
margin: 0 12px; height: 16px; border-radius: 8px; overflow: hidden; display: flex;
|
||||
background: color-mix(in srgb, var(--wl-text) 12%, transparent);
|
||||
border: 2px solid color-mix(in srgb, var(--wl-text) 8%, transparent);
|
||||
}
|
||||
.wl-split i { display: block; height: 100%; }
|
||||
.wl-me { background: var(--wl-me); }
|
||||
.wl-fam { background: repeating-linear-gradient(135deg, var(--wl-gold) 0 6px, #f39c12 6px 12px); }
|
||||
.wl-legend {
|
||||
display: flex; gap: 12px; flex-wrap: wrap; align-items: center;
|
||||
padding: 6px 12px 0; font-size: .8rem; color: var(--wl-dim);
|
||||
}
|
||||
.wl-sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; margin-inline-end: 4px; vertical-align: -1px; }
|
||||
.wl-sw-me { background: var(--wl-me); }
|
||||
.wl-sw-fam { background: var(--wl-gold); }
|
||||
.wl-tot { margin-inline-start: auto; color: var(--wl-text); font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.wl-pledges { padding: 8px 12px 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.wl-pl {
|
||||
display: inline-flex; align-items: center; gap: 5px; font-size: .78rem; border-radius: 999px; padding: 3px 9px;
|
||||
background: color-mix(in srgb, var(--wl-gold) 16%, transparent); color: var(--wl-text);
|
||||
}
|
||||
.wl-pl ha-icon { --mdc-icon-size: 13px; color: #ff6b9d; }
|
||||
|
||||
.wl-acts { display: flex; gap: 8px; padding: 10px 12px 12px; flex-wrap: wrap; align-items: center; }
|
||||
.wl-status {
|
||||
display: inline-flex; align-items: center; gap: 5px; font-size: .8rem; font-weight: 700;
|
||||
padding: 4px 10px; border-radius: 10px; line-height: 1.3;
|
||||
}
|
||||
.wl-status.warn { background: color-mix(in srgb, var(--tmd-warn, #f39c12) 18%, transparent); color: var(--tmd-warn, #d68910); }
|
||||
.wl-status.good { background: color-mix(in srgb, var(--wl-good) 18%, transparent); color: var(--wl-good); }
|
||||
.wl-status.bad { background: color-mix(in srgb, var(--tmd-bad, #e74c3c) 16%, transparent); color: var(--tmd-bad, #e74c3c); }
|
||||
.wl-reason { font-size: .82rem; color: var(--wl-dim); font-style: italic; overflow-wrap: anywhere; }
|
||||
.wl-funded {
|
||||
display: flex; align-items: center; gap: 6px; flex: 1 0 auto;
|
||||
font-weight: 700; color: var(--wl-good); white-space: nowrap;
|
||||
}
|
||||
.wl-funded ha-icon { --mdc-icon-size: 20px; }
|
||||
.wl-to-go { font-size: .8rem; color: var(--wl-dim); }
|
||||
|
||||
.wl-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer;
|
||||
border: 0; border-radius: var(--tmd-radius-sm, 10px); padding: 8px 14px;
|
||||
font-size: .88rem; font-weight: 600; color: #fff; background: var(--wl-accent);
|
||||
}
|
||||
.wl-btn.accent { background: var(--wl-accent); color: var(--tmd-hd-text, #fff); }
|
||||
.wl-btn.green { background: var(--wl-good); color: #fff; }
|
||||
.wl-btn.ghost { background: transparent; color: var(--wl-text); border: 1px solid var(--wl-border); }
|
||||
.wl-btn.text { background: transparent; color: var(--wl-dim); padding: 8px 6px; }
|
||||
.wl-btn[disabled] { opacity: .45; cursor: default; }
|
||||
|
||||
.wl-scrim {
|
||||
position: absolute; inset: 0; z-index: 5; display: flex; align-items: flex-end;
|
||||
background: rgba(0, 0, 0, .5);
|
||||
}
|
||||
.wl-sheet {
|
||||
width: 100%; box-sizing: border-box; max-height: 96%; overflow-y: auto;
|
||||
background: var(--wl-surface); color: var(--wl-text);
|
||||
border-radius: 18px 18px 0 0; padding: 8px 16px 16px;
|
||||
box-shadow: 0 -10px 30px rgba(0, 0, 0, .35);
|
||||
border-top: 1px solid var(--wl-border);
|
||||
}
|
||||
.wl-grab { width: 40px; height: 4px; border-radius: 2px; background: var(--wl-border); margin: 4px auto 10px; }
|
||||
.wl-sheet h3 {
|
||||
margin: 0 0 12px; font-size: 1.15rem; display: flex; align-items: center; gap: 8px;
|
||||
font-family: var(--tmd-font-display, inherit);
|
||||
}
|
||||
.wl-sheet h3 ha-icon { --mdc-icon-size: 22px; color: var(--wl-accent); }
|
||||
.wl-field { display: block; margin-bottom: 14px; }
|
||||
.wl-label { display: block; font-size: .8rem; font-weight: 600; margin-bottom: 6px; }
|
||||
.wl-hint { font-size: .75rem; color: var(--wl-dim); font-weight: 400; }
|
||||
.wl-input {
|
||||
width: 100%; box-sizing: border-box; padding: 10px 12px; font: inherit; font-size: .95rem;
|
||||
color: var(--wl-text); background: var(--wl-surface-2);
|
||||
border: 1px solid var(--wl-border); border-radius: var(--tmd-radius-sm, 8px);
|
||||
}
|
||||
.wl-input:focus { outline: 2px solid var(--wl-accent); outline-offset: 1px; }
|
||||
.wl-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.wl-stepper {
|
||||
display: inline-flex; align-items: center; overflow: hidden;
|
||||
border: 1px solid var(--wl-border); border-radius: var(--tmd-radius-sm, 8px);
|
||||
}
|
||||
.wl-stepper button {
|
||||
width: 36px; height: 36px; border: 0; cursor: pointer; font-size: 18px;
|
||||
background: var(--wl-surface-2); color: var(--wl-text);
|
||||
}
|
||||
.wl-stepper input {
|
||||
width: 72px; height: 36px; border: 0; text-align: center; font: inherit; font-weight: 700;
|
||||
background: var(--wl-surface); color: var(--wl-text); -moz-appearance: textfield;
|
||||
}
|
||||
.wl-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||
.wl-drop {
|
||||
width: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer;
|
||||
border: 2px dashed var(--wl-border); border-radius: var(--tmd-radius-sm, 12px);
|
||||
padding: 14px; background: transparent; color: var(--wl-dim); font-size: .85rem;
|
||||
}
|
||||
.wl-drop ha-icon { --mdc-icon-size: 28px; }
|
||||
.wl-drop img { width: 96px; height: 96px; object-fit: cover; border-radius: 10px; }
|
||||
.wl-sheet-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
|
||||
.wl-sheet-acts.center { justify-content: center; }
|
||||
.wl-big-amt {
|
||||
font-size: 44px; font-weight: 800; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
color: var(--wl-gold); margin: 4px 0; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wl-big-amt ha-icon { --mdc-icon-size: 34px; }
|
||||
.wl-range { width: 100%; accent-color: var(--wl-accent); }
|
||||
.wl-range-info {
|
||||
display: flex; justify-content: space-between; gap: 8px; font-size: .75rem; color: var(--wl-dim);
|
||||
margin: 4px 0 12px; text-align: center;
|
||||
}
|
||||
.wl-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||
.wl-chip {
|
||||
cursor: pointer; font: inherit; font-size: .8rem; font-weight: 600; padding: 5px 12px; border-radius: 999px;
|
||||
background: var(--wl-surface-2); color: var(--wl-text); border: 1px solid var(--wl-border);
|
||||
}
|
||||
.wl-chip.on { border-color: var(--wl-accent); color: var(--wl-accent); }
|
||||
.wl-redeem { text-align: center; padding-top: 4px; }
|
||||
.wl-redeem h3 { justify-content: center; }
|
||||
.wl-redeem p { margin: 0 0 16px; color: var(--wl-dim); }
|
||||
.wl-big-ic {
|
||||
width: 64px; height: 64px; border-radius: 50%; display: grid; place-items: center; margin: 0 auto 10px;
|
||||
background: color-mix(in srgb, var(--wl-good) 20%, transparent); color: var(--wl-good);
|
||||
}
|
||||
.wl-big-ic ha-icon { --mdc-icon-size: 34px; }
|
||||
.wl-error { margin-top: 10px; font-size: .85rem; color: var(--tmd-bad, #e74c3c); }
|
||||
`;
|
||||
const tokens = window.__taskmate_design && window.__taskmate_design.styles
|
||||
? window.__taskmate_design.styles() : null;
|
||||
return tokens ? [tokens, base] : base;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card editor ────────────────────────────────────────────────────────────
|
||||
class TaskMateWishlistCardEditor 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; }
|
||||
`;
|
||||
}
|
||||
|
||||
setConfig(config) { this.config = config; }
|
||||
|
||||
_buildSchema() {
|
||||
const entity = this.config && this.config.entity ? this.hass && this.hass.states && this.hass.states[this.config.entity] : null;
|
||||
const attrs = window.__taskmate_attrs && this.config && this.config.entity
|
||||
? window.__taskmate_attrs(this.hass, this.config.entity)
|
||||
: (entity && entity.attributes) || {};
|
||||
const children = Array.isArray(attrs.children) ? attrs.children : [];
|
||||
return [
|
||||
{ name: "entity", selector: { entity: { domain: "sensor" } } },
|
||||
{
|
||||
name: "child_id",
|
||||
selector: {
|
||||
select: { options: children.map((c) => ({ value: c.id, label: c.name })), mode: "dropdown" },
|
||||
},
|
||||
},
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "card_design",
|
||||
selector: {
|
||||
select: {
|
||||
options: window.__taskmate_design
|
||||
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
||||
: [{ value: "global", label: "Use global default" }],
|
||||
mode: "dropdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => {
|
||||
const labels = {
|
||||
entity: this._t("common.editor.overview_entity"),
|
||||
child_id: this._t("wishlist.editor.child"),
|
||||
title: this._t("common.editor.card_title"),
|
||||
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"),
|
||||
child_id: this._t("wishlist.editor.child_helper"),
|
||||
title: this._t("wishlist.editor.title_helper"),
|
||||
};
|
||||
return helpers[entry.name] ?? "";
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || "",
|
||||
child_id: this.config.child_id || "",
|
||||
title: this.config.title || "",
|
||||
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", DEFAULT_ACCENT)}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderColourPicker(key, defaultValue) {
|
||||
const d = window.__taskmate_design;
|
||||
if (!d || !d.colourPicker) return html``;
|
||||
return d.colourPicker({
|
||||
defaultValue,
|
||||
current: this.config[key] || defaultValue,
|
||||
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 || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
if (value === "" || value === null || value === undefined || (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,
|
||||
}));
|
||||
}
|
||||
|
||||
_updateConfig(key, value) {
|
||||
const newConfig = { ...this.config, [key]: value };
|
||||
if (value === null || value === "" || value === undefined) delete newConfig[key];
|
||||
this.dispatchEvent(new CustomEvent("config-changed", {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-wishlist-card", TaskMateWishlistCard);
|
||||
customElements.define("taskmate-wishlist-card-editor", TaskMateWishlistCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-wishlist-card",
|
||||
name: "TaskMate Wishlist",
|
||||
description: "A child's wishlist: save towards things they want, with family pledges",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-wishlist-card", "overview"),
|
||||
});
|
||||
Reference in New Issue
Block a user