187 files

This commit is contained in:
Home Assistant Version Control
2026-10-09 11:57:31 +00:00
parent 28fa34d78a
commit 1e1b12d52b
187 changed files with 40899 additions and 32654 deletions
@@ -52,6 +52,7 @@ const SCHEDULE_MODES = [
const RECURRENCES = [
{ v: "every_2_days", lk: "panel.recurrence_every_2_days" },
{ v: "every_n_days", lk: "panel.recurrence_every_n_days" },
{ v: "weekly", lk: "panel.recurrence_weekly" },
{ v: "every_2_weeks", lk: "panel.recurrence_every_2_weeks" },
{ v: "monthly", lk: "panel.recurrence_monthly" },
@@ -59,6 +60,18 @@ const RECURRENCES = [
{ v: "every_6_months", lk: "panel.recurrence_every_6_months" },
];
// Custom "every N days" recurrences (#1038) are stored as every_<N>_days; the
// editor shows them as the "every_n_days" option plus a number field. Bounds
// match recurrence_interval_days() in const.py.
const RECURRENCE_INTERVAL_MIN = 2;
const RECURRENCE_INTERVAL_MAX = 365;
function recurrenceIntervalDays(recurrence) {
const m = /^every_(\d{1,3})_days$/.exec(recurrence || "");
if (!m) return null;
const n = Number(m[1]);
return n >= RECURRENCE_INTERVAL_MIN && n <= RECURRENCE_INTERVAL_MAX ? n : null;
}
const FIRST_OCCURRENCE = [
{ v: "available_immediately", lk: "panel.first_occ_available_immediately" },
{ v: "wait_for_first_occurrence", lk: "panel.first_occ_wait" },
@@ -363,7 +376,7 @@ class TaskMatePanel extends HTMLElement {
// tests/test_panel_undo_deny_list.py pins all three copies together.
static get _UNDO_DENY_PREFIXES() {
return [
"Weekend bonus", "Streak milestone bonus", "Perfect week bonus",
"Weekend bonus", "Streak milestone bonus", "Perfect week bonus", "Weekly target bonus",
"Allocated to pool:", "Pool refund", "Points decay",
"Savings interest", "Badge", "Streak freeze",
"Wish savings", "Wish refund", "Wish pledge", "Wish redeemed",
@@ -2182,10 +2195,11 @@ class TaskMatePanel extends HTMLElement {
assigned_to: [], requires_approval: true,
time_category: "anytime", completion_sound: "coin", daily_limit: 1,
weekly_target: 0,
weekly_target_bonus: 0,
difficulty: "medium",
claim_allowance_minutes: 0,
schedule_mode: "specific_days",
due_days: [], recurrence: "weekly", recurrence_day: "", recurrence_start: "",
due_days: [], recurrence: "weekly", recurrence_interval: 3, recurrence_day: "", recurrence_start: "",
first_occurrence_mode: "available_immediately",
assignment_mode: "everyone", assignment_rotation_anchor: "",
manual_start_child_id: "",
@@ -2229,6 +2243,7 @@ class TaskMatePanel extends HTMLElement {
weather_temp_max: c.weather_temp_max ?? "",
weather_wind_max: c.weather_wind_max ?? "",
manual_start_child_id: "",
...this._recurrenceDraft(c.recurrence),
} });
} else {
this._openDialog({ kind: "chore", mode: "add", data: blank });
@@ -2423,6 +2438,16 @@ class TaskMatePanel extends HTMLElement {
this._showToast("err", this._t("panel.chore_team_conflict"));
return;
}
let recurrence = d.recurrence || "weekly";
if (recurrence === "every_n_days") {
const n = Number(d.recurrence_interval);
if (!Number.isInteger(n) || n < RECURRENCE_INTERVAL_MIN || n > RECURRENCE_INTERVAL_MAX) {
this._showToast("err", this._t("panel.chore_recurrence_interval_invalid",
{ min: RECURRENCE_INTERVAL_MIN, max: RECURRENCE_INTERVAL_MAX }));
return;
}
recurrence = `every_${n}_days`;
}
const base = {
name: d.name.trim(),
description: d.description || "",
@@ -2441,9 +2466,10 @@ class TaskMatePanel extends HTMLElement {
difficulty: d.difficulty || "medium",
daily_limit: Number(d.daily_limit) || 1,
weekly_target: Math.max(0, Number(d.weekly_target) || 0),
weekly_target_bonus: Math.max(0, Number(d.weekly_target_bonus) || 0),
schedule_mode: d.schedule_mode || "specific_days",
due_days: d.due_days || [],
recurrence: d.recurrence || "weekly",
recurrence,
recurrence_day: d.recurrence_day || "",
recurrence_start: d.recurrence_start || "",
first_occurrence_mode: d.first_occurrence_mode || "available_immediately",
@@ -6474,7 +6500,7 @@ class TaskMatePanel extends HTMLElement {
? `<strong>${this._esc(childById[curChild].name)}</strong>`
: isRotation ? `<span class="tm-text-muted">—</span>` : "";
const schedLabel = c.schedule_mode === "recurring"
? this._labelOf(RECURRENCES, c.recurrence) + (c.recurrence_day && c.recurrence_day !== "any_day" ? ` · ${this._labelOf(DAYS, c.recurrence_day)}` : "")
? this._recurrenceLabel(c.recurrence) + (c.recurrence_day && c.recurrence_day !== "any_day" ? ` · ${this._labelOf(DAYS, c.recurrence_day)}` : "")
: c.schedule_mode === "one_shot"
? this._t("panel.common_one_shot")
: ((c.due_days || []).length === 0 ? this._t("panel.common_daily") : (c.due_days || []).map(d => this._labelOf(DAYS, d)).join(" · "));
@@ -7076,7 +7102,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-meta">${this._esc(name(b.claimed_by))}${comp ? ` · ${this._timeAgo(comp.completed_at)}` : ""}</div>`;
} else if (b.status === "completed") {
status = `<span class="tm-pill tm-pill-success">${this._t("panel.bounty_status_completed")}</span>
<div class="tm-meta">${this._t("panel.bounty_completed_meta", { name: this._esc(name(b.claimed_by)), points: this._num(b.points_awarded || b.points) })}</div>`;
<div class="tm-meta">${this._t("panel.bounty_completed_meta", { name: this._esc(name(b.claimed_by)), points: this._num(b.points_awarded || b.points), points_name: this._esc(this._pointsName()) })}</div>`;
} else if (b.status === "expired") {
status = `<span class="tm-pill tm-pill-muted">${this._t("panel.bounty_status_expired")}</span>
<div class="tm-meta">${this._t(b.lapse_count > 0 ? "panel.bounty_expired_lapsed_meta" : b.claim_count > 0 ? "panel.bounty_expired_unfinished_meta" : "panel.bounty_expired_meta", { date: b.closed_at ? this._esc(new Date(b.closed_at).toLocaleDateString([], { day: "numeric", month: "short" })) : "" })}</div>`;
@@ -7383,7 +7409,7 @@ class TaskMatePanel extends HTMLElement {
const child = childById[b.child_id];
return `<span class="tm-auc-chip" title="${this._esc(child ? child.name : "?")}">
${child ? this._childAvatar(child) : ""}
${show ? `<b>${this._num(b.points)} ★</b>` : `<span class="tm-text-muted">${this._t("panel.auction_sealed")}</span>`}
${show ? `<b>${this._num(b.points)} ${this._pointsIconHtml()}</b>` : `<span class="tm-text-muted">${this._t("panel.auction_sealed")}</span>`}
${i === 0 && a.status === "open" && show ? `<span class="tm-meta">· ${this._t("panel.auction_leading")}</span>` : ""}
</span>`;
}).join("");
@@ -7394,9 +7420,9 @@ class TaskMatePanel extends HTMLElement {
const chore = `
<div class="tm-name-main tm-bounty-cell">
<span class="tm-avatar tm-auc-ic">${this._mdi(a.icon || "mdi:gavel")}</span>
<div><strong>${this._esc(a.chore_name)}</strong><div class="tm-meta">${this._esc(this._t("panel.auction_normally", { date: this._auctionDate(a.occurrence), points: this._num(a.normal_points) }))}</div></div>
<div><strong>${this._esc(a.chore_name)}</strong><div class="tm-meta">${this._esc(this._t("panel.auction_normally", { date: this._auctionDate(a.occurrence), points: this._num(a.normal_points), points_name: this._pointsName() }))}</div></div>
</div>`;
const max = `<strong class="tm-numeric tm-auc-gold">${this._num(a.max_points)} ★</strong>${a.min_points > 1 ? `<div class="tm-meta">${this._t("panel.auction_min_meta", { points: this._num(a.min_points) })}</div>` : ""}`;
const max = `<strong class="tm-numeric tm-auc-gold">${this._num(a.max_points)} ${this._pointsIconHtml()}</strong>${a.min_points > 1 ? `<div class="tm-meta">${this._t("panel.auction_min_meta", { points: this._num(a.min_points), points_name: this._esc(this._pointsName()) })}</div>` : ""}`;
if (a.status === "open") {
const left = Date.parse(a.closes_at || "") - Date.now();
const who = (a.eligible_child_ids || []).map(cid => childById[cid]).filter(Boolean)
@@ -7424,7 +7450,7 @@ class TaskMatePanel extends HTMLElement {
result = `<span class="tm-pill tm-pill-muted">${this._t("panel.auction_status_cancelled")}</span>`;
status = `<span class="tm-pill tm-pill-muted">${this._t("panel.auction_status_normal")}</span>`;
} else if (a.winner_id) {
result = `<div class="tm-auc-win">${winner ? this._childAvatar(winner) : ""}<div><strong>${this._esc(winner ? winner.name : "?")}</strong> ${this._t("panel.auction_won_at", { points: `<strong class="tm-auc-gold">${this._num(a.price)} ★</strong>` })}
result = `<div class="tm-auc-win">${winner ? this._childAvatar(winner) : ""}<div><strong>${this._esc(winner ? winner.name : "?")}</strong> ${this._t("panel.auction_won_at", { points: `<strong class="tm-auc-gold">${this._num(a.price)} ${this._pointsIconHtml()}</strong>` })}
<div class="tm-meta">${this._esc(this._t("panel.auction_assigned_for", { date: this._auctionDate(a.occurrence) }))}</div></div></div>`;
const st = a.chore_status || "todo";
status = st === "done"
@@ -7572,10 +7598,10 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-auc-steprow">
<div class="tm-auc-stepper">
<button type="button" data-act="auction-max-step" data-id="-1" aria-label="−">−</button>
<span>${this._num(d.max_points)} ★</span>
<span>${this._num(d.max_points)} ${this._pointsIconHtml()}</span>
<button type="button" data-act="auction-max-step" data-id="1" aria-label="+">+</button>
</div>
<span class="tm-meta">${this._t("panel.auction_normally_worth", { points: normal })}</span>
<span class="tm-meta">${this._t("panel.auction_normally_worth", { points: normal, points_name: this._esc(this._pointsName()) })}</span>
</div>
<div class="tm-chip-row" style="margin-top:8px">
${multiples.map(([m, n]) => chip("auction-max-set", n, Number(d.max_points) === n, `${m}× · ${n}`)).join("")}
@@ -8148,7 +8174,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-tpl-icon"><ha-icon icon="${this._esc(tpl.icon || "mdi:clipboard-list")}"></ha-icon></div>
<div class="tm-manage-tpl-info">
<div class="tm-manage-tpl-name">${this._esc(tpl.name)}${locked ? ' <span class="tm-text-vfaint" style="font-size:12px">🔒</span>' : ""}</div>
<div class="tm-meta">${this._t("panel.template_pts_total", {count, points: pts})}</div>
<div class="tm-meta">${this._t("panel.template_pts_total", {count, points: pts, points_name: this._esc(this._pointsName())})}</div>
</div>
<div class="tm-row-actions">
${locked ? "" : `<button type="button" class="tm-btn tm-btn-sm" data-act="tpl-edit" data-id="${this._esc(tpl.id)}">${this._t("panel.btn_edit")}</button>`}
@@ -8190,7 +8216,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-tpl-icon"><ha-icon icon="${this._esc(tpl.icon || "mdi:clipboard-list")}"></ha-icon></div>
<div style="flex:1">
<div class="tm-tpl-picker-name">${this._esc(tpl.name)}</div>
<div class="tm-meta">${this._t("panel.template_pts_total", {count, points: pts})}</div>
<div class="tm-meta">${this._t("panel.template_pts_total", {count, points: pts, points_name: this._esc(this._pointsName())})}</div>
</div>
<span class="tm-pill ${tpl.builtin ? "tm-pill-accent" : "tm-pill-success"}">${tpl.builtin ? this._t("panel.template_builtin") : this._t("panel.template_custom")}</span>
</div>
@@ -8217,7 +8243,7 @@ class TaskMatePanel extends HTMLElement {
${chores.map((c, i) => this._renderTemplateChoreCard(c, i)).join("")}
${chores.length > 0 ? `
<div class="tm-tpl-confirm-bar">
<div>${this._t("panel.template_confirm_bar", {count: chores.length, points: totalPts})}</div>
<div>${this._t("panel.template_confirm_bar", {count: chores.length, points: totalPts, points_name: this._esc(this._pointsName())})}</div>
<button type="button" class="tm-btn tm-btn-raised" data-act="tpl-apply">${this._t("panel.template_create_btn", {count: chores.length})}</button>
</div>
` : `<div class="tm-card tm-empty"><p>${this._t("panel.empty_template_all_removed")}</p></div>`}
@@ -8233,7 +8259,7 @@ class TaskMatePanel extends HTMLElement {
<span class="tm-tpl-expand ${expanded ? "open" : ""}">▶</span>
<span class="tm-tpl-preview-name">${this._esc(chore.name)}</span>
<span class="tm-tpl-preview-summary">
<span>${this._t("panel.pts_display", {count: chore.points || 0})}</span>
<span>${this._t("panel.pts_display", {count: chore.points || 0, points_name: this._esc(this._pointsName())})}</span>
<span>${schedLabel}</span>
<span>${this._esc(this._timeCategoryLabel(chore.time_category))}</span>
</span>
@@ -8298,7 +8324,7 @@ class TaskMatePanel extends HTMLElement {
<label class="tm-tpl-check-row">
<input type="checkbox" data-tpl-chore-check="${this._esc(c.id)}">
<span>${this._esc(c.name)}</span>
<span class="tm-text-muted" style="margin-inline-start:auto">${this._t("panel.pts_display", {count: c.points})}</span>
<span class="tm-text-muted" style="margin-inline-start:auto">${this._t("panel.pts_display", {count: c.points, points_name: this._esc(this._pointsName())})}</span>
</label>
`).join("")}
</div>
@@ -8639,8 +8665,8 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-label">${this._t("panel.settings_surprise_params_label")}<small>${this._t("panel.settings_surprise_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_surprise_chance")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="surprise_bonus_chance" value="${this._num(s.surprise_bonus_chance, 15)}"></label>
<label>${this._t("panel.settings_surprise_min")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${this._num(s.surprise_bonus_min, 5)}"></label>
<label>${this._t("panel.settings_surprise_max")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${this._num(s.surprise_bonus_max, 20)}"></label>
<label>${this._t("panel.settings_surprise_min", { points_name: this._esc(this._pointsName()) })}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${this._num(s.surprise_bonus_min, 5)}"></label>
<label>${this._t("panel.settings_surprise_max", { points_name: this._esc(this._pointsName()) })}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${this._num(s.surprise_bonus_max, 20)}"></label>
</div>
</div>
<div class="tm-setting-row tm-setting-stack">
@@ -8701,7 +8727,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_spendcap_params_label")}<small>${this._t("panel.settings_spendcap_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_spendcap_amount")}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${this._num(s.spend_cap_amount, 0)}"></label>
<label>${this._t("panel.settings_spendcap_amount", { points_name: this._esc(this._pointsName()) })}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${this._num(s.spend_cap_amount, 0)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="spend_cap_period">
<option value="weekly" ${s.spend_cap_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -9339,7 +9365,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-tpl-preview-card" style="margin-bottom:8px">
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px">
<span style="font-weight:600;flex:1">${this._esc(c.name || this._t("panel.template_unnamed"))}</span>
<span class="tm-meta">${this._t("panel.pts_display", {count: c.points || 0})}</span>
<span class="tm-meta">${this._t("panel.pts_display", {count: c.points || 0, points_name: this._esc(this._pointsName())})}</span>
<button type="button" class="tm-tpl-remove" data-act="tpl-dialog-remove-chore" data-idx="${i}">✕</button>
</div>
<div style="padding:0 14px 12px;border-top:1px solid var(--tm-border-soft)">
@@ -9514,13 +9540,20 @@ class TaskMatePanel extends HTMLElement {
`).join("")}
</div>
</div>
${this._field(this._t("panel.chore_weekly_target_label"), "weekly_target", d.weekly_target || 0, "number",
this._t("panel.chore_weekly_target_hint"))}
<div class="tm-field-row">
${this._field(this._t("panel.chore_weekly_target_label"), "weekly_target", d.weekly_target || 0, "number",
this._t("panel.chore_weekly_target_hint"))}
${this._field(this._t("panel.chore_weekly_target_bonus_label"), "weekly_target_bonus", d.weekly_target_bonus || 0, "number",
this._t("panel.chore_weekly_target_bonus_hint"))}
</div>
` : "",
showRecurring ? `
<div class="tm-field-row">
${this._select(this._t("panel.chore_recurrence_label"), "recurrence", d.recurrence, RECURRENCES)}
${this._field(this._t("panel.chore_recurrence_day_label"), "recurrence_day", d.recurrence_day, "text", this._t("panel.chore_recurrence_day_hint"))}
${this._select(this._t("panel.chore_recurrence_label"), "recurrence", d.recurrence, RECURRENCES, "", true)}
${d.recurrence === "every_n_days"
? this._field(this._t("panel.chore_recurrence_interval_label"), "recurrence_interval", d.recurrence_interval, "number",
this._t("panel.chore_recurrence_interval_hint", { min: RECURRENCE_INTERVAL_MIN, max: RECURRENCE_INTERVAL_MAX }))
: this._field(this._t("panel.chore_recurrence_day_label"), "recurrence_day", d.recurrence_day, "text", this._t("panel.chore_recurrence_day_hint"))}
</div>
<div class="tm-field-row">
${this._dateField(this._t("panel.chore_recurrence_start_label"), "recurrence_start", d.recurrence_start, this._t("panel.chore_recurrence_start_hint"))}
@@ -10630,6 +10663,18 @@ class TaskMatePanel extends HTMLElement {
return `<span class="tm-id-badge"><code>${this._esc(id)}</code><button type="button" class="tm-id-copy" data-act="copy-id" data-id="${this._esc(id)}" title="${this._t("panel.toast_copied")}"><ha-icon icon="mdi:content-copy"></ha-icon></button></span>`;
}
/** Editor fields for a stored recurrence: every_<N>_days opens as the custom option. */
_recurrenceDraft(recurrence) {
const n = recurrenceIntervalDays(recurrence);
return n && n !== 2 ? { recurrence: "every_n_days", recurrence_interval: n } : {};
}
_recurrenceLabel(recurrence) {
const n = recurrenceIntervalDays(recurrence);
if (n && n !== 2) return this._t("panel.recurrence_every_count_days", { count: n });
return this._labelOf(RECURRENCES, recurrence);
}
_labelOf(arr, val) {
const item = arr.find(x => x.v === val);
if (!item) return val || "";
@@ -10698,6 +10743,15 @@ class TaskMatePanel extends HTMLElement {
return `<ha-icon icon="${this._esc(name || "mdi:account-circle")}"></ha-icon>`;
}
_pointsName() {
return this._state.settings?.points_name || this._t("common.points");
}
/** The configured points icon, sized to sit inline after a number. */
_pointsIconHtml() {
return `<ha-icon class="tm-pts-ic" icon="${this._esc(this._state.settings?.points_icon || "mdi:star")}"></ha-icon>`;
}
_styles() {
return `<style>
/* Fill the panel area without depending on a percentage-height chain.
@@ -11161,6 +11215,7 @@ class TaskMatePanel extends HTMLElement {
.tm-auc-ic { width: 34px; height: 34px; border-radius: 10px; color: #fff; background: linear-gradient(135deg, #7e57c2, #5e35b1); }
.tm-auc-ic ha-icon { --mdc-icon-size: 18px; }
.tm-auc-gold { color: var(--tm-gold, #d4ac0d); }
.tm-pts-ic { --mdc-icon-size: 1.1em; vertical-align: -0.15em; }
.tm-auc-head { flex: 1; min-width: 0; }
.tm-auc-chips .tm-chip-btn { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }
.tm-auc-chips .tm-chip-btn > ha-icon { --mdc-icon-size: 15px; }