278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
+393 -49
View File
@@ -122,6 +122,8 @@ const WEATHER_CONDITIONS = [
{ v: "exceptional", icon: "mdi:alert-outline" },
];
// Built-in sounds, mirroring COMPLETION_SOUND_OPTIONS in const.py. User-uploaded
// sounds are appended per render by _soundOptions() (#856).
const COMPLETION_SOUNDS = [
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
@@ -506,6 +508,15 @@ class TaskMatePanel extends HTMLElement {
if (act === "save-gift") { this._doGiftPoints(); return; }
if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; }
if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; }
if (act === "sound-preview-field") {
const sel = t.closest(".tm-sound-row")?.querySelector('select[data-field="completion_sound"]');
if (sel) this._previewSound(sel.value);
return;
}
if (act === "sound-preview") { this._previewSound(t.dataset.value); return; }
if (act === "sound-upload") { this._pickCustomSound(); return; }
if (act === "sound-rename") { this._renameCustomSound(t.dataset.file, t.dataset.name); return; }
if (act === "sound-delete") { this._deleteCustomSound(t.dataset.file, t.dataset.name); return; }
if (act === "chore-image-pick") { this._pickChoreImage(); return; }
if (act === "chore-image-remove") {
// Clears the dialog value only — the file is deleted on save by the
@@ -605,6 +616,7 @@ class TaskMatePanel extends HTMLElement {
if (act === "delete-reward") { this._confirmDelete("reward", t.dataset.id); return; }
if (act === "save-reward") { this._doSaveReward(); return; }
if (act === "toggle-reward-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; }
if (act === "toggle-reward-day") { this._toggleArrayField("available_days", Number(t.dataset.day)); return; }
// Quests (chore chains)
if (act === "add-quest") { this._openQuestDialog(null); return; }
@@ -1449,6 +1461,100 @@ class TaskMatePanel extends HTMLElement {
}
}
// --- Custom completion sounds (#856) ------------------------------------
_pickCustomSound() {
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/mpeg,audio/ogg,audio/wav,audio/mp4,.mp3,.ogg,.wav,.m4a";
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (file) this._uploadCustomSound(file);
});
input.click();
}
/**
* Upload the file, then register it under a display name.
*
* Two steps because the bytes go over HTTP (multipart) while the registry
* entry is a WebSocket command — same split as chore images. The default
* name is the filename without its extension, which is nearly always what
* the user wants.
*/
async _uploadCustomSound(file) {
if (this._soundBusy) return;
this._soundBusy = true;
this._render();
try {
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", file, file.name || "sound");
return fetch("/api/taskmate/sound", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
};
// The cached access token can go stale mid-session, so refresh and retry
// once on a 401 before giving up. Mirrors the chore-image upload.
if (this._hass && 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 && this._hass.auth
&& this._hass.auth.refreshAccessToken) {
await this._hass.auth.refreshAccessToken();
resp = await post();
}
if (resp.status === 413) { this._showToast("err", this._t("panel.sound_too_large")); return; }
if (resp.status === 400) { this._showToast("err", this._t("panel.sound_bad_type")); return; }
if (resp.status === 507) { this._showToast("err", this._t("panel.sound_storage_full")); return; }
if (!resp.ok) { this._showToast("err", this._t("panel.sound_upload_failed")); return; }
const body = await resp.json();
const name = (file.name || "").replace(/\.[^.]+$/, "").slice(0, 40) || this._t("panel.sound_default_name");
const { ok, err } = await this._callWS({
type: "taskmate/add_custom_sound", file: body.file, name,
});
if (!ok) { this._showToast("err", this._t("panel.sound_upload_failed", { error: err })); return; }
this._showToast("ok", this._t("panel.sound_uploaded"));
await this._fetchState();
} catch (err) {
this._showToast("err", this._t("panel.sound_upload_failed"));
} finally {
this._soundBusy = false;
this._render();
}
}
async _renameCustomSound(file, current) {
const name = prompt(this._t("panel.sound_rename_prompt"), current || "");
if (name === null) return;
if (!name.trim()) { this._showToast("err", this._t("panel.sound_name_required")); return; }
const { ok, err } = await this._callWS({
type: "taskmate/rename_custom_sound", file, name: name.trim().slice(0, 40),
});
if (!ok) { this._showToast("err", err); return; }
await this._fetchState();
}
async _deleteCustomSound(file, name) {
if (!confirm(this._t("panel.sound_delete_confirm", { name: name || file }))) return;
const { ok, err, res } = await this._callWS({ type: "taskmate/remove_custom_sound", file });
if (!ok) { this._showToast("err", err); return; }
// Chores that used it were reset to the default sound server-side; say so
// rather than letting the change be discovered later.
const reset = (res && res.chores_reset) || 0;
this._showToast("ok", reset
? this._t("panel.sound_deleted_reset", { count: reset })
: this._t("panel.sound_deleted"));
await this._fetchState();
}
_pickChoreImage() {
const input = document.createElement("input");
input.type = "file";
@@ -1619,7 +1725,8 @@ class TaskMatePanel extends HTMLElement {
assigned_to: [], is_jackpot: false, pool_enabled: false,
quantity_str: "", expires_at: "",
restock_enabled: false, restock_amount: 0, restock_period: "weekly",
unlock_entity: "", unlock_minutes: 30 };
unlock_entity: "", unlock_minutes: 30,
time_lock_enabled: false, available_days: [], available_from: "", available_until: "" };
if (id) {
const r = (this._state.rewards || []).find(x => x.id === id);
if (!r) return;
@@ -1628,6 +1735,7 @@ class TaskMatePanel extends HTMLElement {
assigned_to: [...(r.assigned_to || [])],
quantity_str: r.quantity == null ? "" : String(r.quantity),
expires_at: r.expires_at || "",
available_days: (r.available_days || []).map(Number),
} });
} else {
this._openDialog({ kind: "reward", mode: "add", data: blank });
@@ -1655,6 +1763,10 @@ class TaskMatePanel extends HTMLElement {
unlock_minutes: Math.max(0, Number(d.unlock_minutes) || 0),
restock_amount: Math.max(0, Number(d.restock_amount) || 0),
restock_period: d.restock_period || "weekly",
time_lock_enabled: !!d.time_lock_enabled,
available_days: (d.available_days || []).map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6),
available_from: d.available_from || "",
available_until: d.available_until || "",
};
const payload = wasAdd ? { type: "taskmate/add_reward", ...base } : { type: "taskmate/update_reward", reward_id: d.id, ...base };
const { ok, err } = await this._callWS(payload);
@@ -2368,7 +2480,9 @@ class TaskMatePanel extends HTMLElement {
_timeAgo(iso) {
if (!iso) return "—";
const then = new Date(iso);
if (isNaN(then)) return iso;
// Echoing an unparseable value straight back would put stored text into
// markup, so escape it — the panel builds its HTML as strings.
if (isNaN(then)) return this._esc(iso);
const diffSec = Math.floor((Date.now() - then.getTime()) / 1000);
if (diffSec < 60) return this._t("panel.time_ago_just_now");
if (diffSec < 3600) return this._t("panel.time_ago_minutes", {count: Math.floor(diffSec / 60)});
@@ -2832,7 +2946,7 @@ class TaskMatePanel extends HTMLElement {
<td><strong>${this._t("panel.day_" + d.weekday.slice(0, 3))}</strong> <span class="tm-proj-date">${d.date.slice(5)}</span></td>
${kids.map(k => {
const cell = d.children.find(c => c.id === k.id) || { points: 0, chores: 0 };
return `<td>${cell.chores ? `${cell.points} <span class="tm-proj-count">(${cell.chores})</span>` : "—"}</td>`;
return `<td>${cell.chores ? `${this._num(cell.points)} <span class="tm-proj-count">(${this._num(cell.chores)})</span>` : "—"}</td>`;
}).join("")}
</tr>
`).join("")}
@@ -2976,7 +3090,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-fair-figs">
<strong>${c.completions}</strong> ${this._t(c.completions === 1 ? "panel.insights_chore" : "panel.insights_chores")}
· ${c.points} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
· ${this._num(c.points)} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
· ${c.share_completions}%
</div>
<div class="tm-fair-status tm-fair-${c.status}">${this._t("panel.insights_status_" + c.status)}</div>
@@ -3191,7 +3305,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-approval-icon"><ha-icon icon="mdi:gift-outline"></ha-icon></div>
<div class="tm-approval-body">
<div class="tm-approval-line">${this._t("panel.activity_claimed_text", {child: this._esc((child && child.name) || "?"), reward: this._esc((reward && reward.name) || this._t("panel.activity_deleted_reward"))})}</div>
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${reward.cost} ${this._t("panel.activity_points")}` : ""}</div>
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${this._num(reward.cost)} ${this._t("panel.activity_points")}` : ""}</div>
</div>
<div class="tm-approval-actions">
<button type="button" class="tm-btn tm-btn-sm" data-act="reject-reward" data-id="${this._esc(c.id)}">${this._t("panel.activity_reject")}</button>
@@ -3234,7 +3348,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-timeline-body">
<div><strong>${this._esc(ev.child)}</strong> · ${this._esc(ev.label)}</div>
</div>
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points >= 0 ? '+' : ''}${ev.points || 0}</div>
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points >= 0 ? '+' : ''}${this._num(ev.points)}</div>
</div>
`).join("")}
</div>
@@ -3258,7 +3372,7 @@ class TaskMatePanel extends HTMLElement {
<tr class="tm-row">
<td class="tm-meta">${this._esc(this._timeAgo(t.created_at))}</td>
<td>${this._esc((child && child.name) || "?")}</td>
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${t.points}</strong></td>
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${this._num(t.points)}</strong></td>
<td>${this._esc(this._translateReason(t.reason) || "—")}</td>
<td>${undoable ? `<button type="button" class="tm-icon-btn" data-act="undo-tx" data-id="${this._esc(t.id)}" title="${this._esc(this._t("panel.activity_undo"))}"><ha-icon icon="mdi:undo-variant"></ha-icon></button>` : ""}</td>
</tr>
@@ -3365,7 +3479,7 @@ class TaskMatePanel extends HTMLElement {
<button type="button" class="tm-icon-btn" data-act="toggle-row-menu" data-id="${this._esc(c.id)}" title="${this._t("panel.row_menu_more")}" aria-haspopup="menu" aria-label="${this._t("panel.row_menu_more")}">⋮</button>
</span>`}
</div></td>
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${c.timed_rate_points || 0}/${c.timed_rate_minutes || 1} min` : c.points}</strong></td>
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${this._num(c.timed_rate_points)}/${this._num(c.timed_rate_minutes, 1)} min` : this._num(c.points)}</strong></td>
<td><span class="tm-pill">${this._esc(this._timeCategoryLabel(c.time_category))}</span></td>
<td>${assignedNames} ${modeBadge}</td>
<td>${currentName}</td>
@@ -3396,6 +3510,18 @@ class TaskMatePanel extends HTMLElement {
`;
}
/** Reward-list pill summarising a time lock (#857), or "" when unrestricted. */
_rewardTimeLockPill(r) {
if (!r.time_lock_enabled) return "";
const summary = window.__taskmate_time_lock_label
? window.__taskmate_time_lock_label(
{ days: r.available_days, from: r.available_from, until: r.available_until },
(k, pr) => this._t(k, pr))
: "";
const label = summary || this._t("panel.reward_timelock_badge");
return `<span class="tm-pill tm-pill-timelock">${this._mdi("mdi:clock-outline")} ${this._esc(label)}</span>`;
}
_renderRewardCard(r, pointsName) {
const totalPooled = (this._state.pool_allocations || []).filter(a => a.reward_id === r.id).reduce((s, a) => s + (a.allocated_points || 0), 0);
const showProgress = (r.pool_enabled || r.is_jackpot) && r.cost > 0;
@@ -3405,14 +3531,14 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-child-head">
<div class="tm-avatar tm-avatar-reward">${this._mdi(r.icon || "mdi:gift")}</div>
<div class="tm-child-name">
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""}</h3>
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""} ${this._rewardTimeLockPill(r)}</h3>
${this._idBadge(r.id)}
<div class="tm-meta">${this._esc(r.description || "")}</div>
</div>
</div>
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr ${r.expires_at ? "1fr" : ""};">
<div class="tm-stat"><div class="tm-stat-value tm-numeric tm-cost">${this._fmtNum(r.cost)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_cost", {points_name: pointsName})}</div></div>
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${r.quantity}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(r.quantity)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
${r.expires_at ? `<div class="tm-stat"><div class="tm-stat-value" style="font-size: 14px;">${this._esc(r.expires_at)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_expires")}</div></div>` : ""}
</div>
${showProgress ? `
@@ -3533,7 +3659,7 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr;">
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${c.target}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(c.target)}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._fmtNum(c.bonus_points || 0)}</div><div class="tm-stat-label">${this._t("panel.quest_stat_bonus", {points_name: pointsName})}</div></div>
</div>
<div class="tm-meta">${this._t("panel.quest_assigned_to")}: ${this._esc(assigned.join(", "))}</div>
@@ -3577,7 +3703,7 @@ class TaskMatePanel extends HTMLElement {
_criteriaLabel(criteria, combinator = "AND") {
if (!criteria || !criteria.length) return this._t("badge.manual_award_only");
const joinKey = combinator === "OR" ? "badge.criteria_or" : "badge.criteria_and";
return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${c.operator} ${c.value}`).join(` ${this._t(joinKey)} `);
return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${this._esc(c.operator)} ${this._num(c.value)}`).join(` ${this._t(joinKey)} `);
}
_badgeName(b) {
@@ -3870,7 +3996,7 @@ class TaskMatePanel extends HTMLElement {
<select class="tm-select tm-badge-op-select" data-badge-criterion-field="operator" data-badge-criterion-idx="${idx}">
${BADGE_OPERATORS.map(sym => { const v = BADGE_OP_VALUES[sym]; return `<option value="${v}"${(c.operator || ">=") === v ? " selected" : ""}>${sym}</option>`; }).join("")}
</select>`}
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${c.value || 1}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${this._num(c.value, 1)}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
<button type="button" class="tm-icon-btn" data-act="badge-remove-criterion" data-idx="${idx}" title="${this._t("panel.badge_remove_criterion_btn")}">✕</button>
</div>
`;
@@ -3935,7 +4061,7 @@ class TaskMatePanel extends HTMLElement {
return `
<tr class="tm-row">
<td><span class="tm-row-icon">${this._mdi(item.icon)}</span><strong>${this._esc(item.name)}</strong>${this._idBadge(item.id)}${item.description ? `<div class="tm-meta">${this._esc(item.description)}</div>` : ""}</td>
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${item.points}</strong></td>
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${this._num(item.points)}</strong></td>
<td>${assignedNames}</td>
<td class="tm-row-actions"><div>
<button type="button" class="tm-btn tm-btn-sm" data-act="apply-${kind}" data-id="${this._esc(item.id)}">${this._t("panel.btn_apply")}</button>
@@ -4120,7 +4246,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-tpl-preview-body">
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-field="name" data-tpl-idx="${idx}" value="${this._esc(chore.name)}"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${chore.points || 0}" min="0"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${this._num(chore.points)}" min="0"></div>
</div>
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
@@ -4147,7 +4273,7 @@ class TaskMatePanel extends HTMLElement {
</select>
</div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_daily_limit_label")}</span>
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${chore.daily_limit || 1}" min="1">
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${this._num(chore.daily_limit, 1)}" min="1">
</div>
</div>
</div>
@@ -4336,6 +4462,50 @@ class TaskMatePanel extends HTMLElement {
}
// -- Settings tab ------------------------------------------------------
/**
* Custom completion sounds (#856).
*
* Uploaded clips are listed with preview / rename / delete. Deleting one
* resets any chore using it back to the default sound server-side, so the
* confirm copy warns about that rather than leaving it to be discovered.
*/
_renderSoundsSection() {
const list = this._customSounds();
const busy = this._soundBusy;
return `
<div class="tm-section">
<div class="tm-section-head"><div>
<h3>${this._t("panel.settings_sounds_title")}</h3>
<p class="tm-meta">${this._t("panel.settings_sounds_hint")}</p>
</div></div>
<div class="tm-section-body">
${list.length ? `
<div class="tm-sound-list">
${list.map(s => `
<div class="tm-sound-item">
<span class="tm-sound-name">${this._esc(s.name)}</span>
<div class="tm-sound-item-actions">
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview"
data-value="${this._esc(s.value)}"
aria-label="${this._esc(this._t("panel.sound_preview"))}"
title="${this._esc(this._t("panel.sound_preview"))}">▶</button>
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-rename"
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_rename")}</button>
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="sound-delete"
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_delete")}</button>
</div>
</div>`).join("")}
</div>` : `<p class="tm-meta">${this._t("panel.settings_sounds_empty")}</p>`}
<div class="tm-sound-upload">
<button type="button" class="tm-btn tm-btn-raised" data-act="sound-upload" ${busy ? "disabled" : ""}>
${busy ? this._t("panel.sound_uploading") : this._t("panel.sound_upload")}
</button>
<span class="tm-field-hint">${this._t("panel.settings_sounds_formats")}</span>
</div>
</div>
</div>`;
}
_renderSettingsTab() {
const s = this._state.settings || {};
const notifyServices = Object.keys((this._hass && this._hass.services && this._hass.services.notify) || {});
@@ -4377,6 +4547,8 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
${this._renderSoundsSection()}
<div class="tm-section">
<div class="tm-section-head"><div>
<h3>${this._t("panel.settings_parents_title")}</h3>
@@ -4389,6 +4561,10 @@ class TaskMatePanel extends HTMLElement {
<input type="checkbox" class="tm-role-check" data-parent-user="${this._esc(u.id)}" ${(s.parent_user_ids || []).includes(u.id) ? "checked" : ""}>
</label>`).join("")
|| `<p class="tm-meta">${this._t("panel.settings_parents_empty")}</p>`}
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_require_linked_child_label")}<small>${this._t("panel.settings_require_linked_child_hint")}</small></div>
<ha-switch data-setting="require_linked_child" ${s.require_linked_child ? "checked" : ""}></ha-switch>
</div>
</div>
</div>
@@ -4397,7 +4573,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-section-body">
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_retention_label")}<small>${this._t("panel.settings_retention_hint")}</small></div>
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${s.history_days || 90}">
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${this._num(s.history_days, 90)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_streak_reset_label")}<small>${this._t("panel.settings_streak_reset_hint")}</small></div>
@@ -4411,19 +4587,19 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_weekend_multiplier_label")}<small>${this._t("panel.settings_weekend_multiplier_hint")}</small></div>
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${s.weekend_multiplier || 1.0}">
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${this._num(s.weekend_multiplier, 1.0)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_difficulty_multipliers_label")}<small>${this._t("panel.settings_difficulty_multipliers_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${s.difficulty_multiplier_easy ?? 0.5}"></label>
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${s.difficulty_multiplier_medium ?? 1.0}"></label>
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${s.difficulty_multiplier_hard ?? 2.0}"></label>
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${this._num(s.difficulty_multiplier_easy, 0.5)}"></label>
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${this._num(s.difficulty_multiplier_medium, 1.0)}"></label>
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${this._num(s.difficulty_multiplier_hard, 2.0)}"></label>
</div>
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_calendar_projection_label")}<small>${this._t("panel.settings_calendar_projection_hint")}</small></div>
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${s.calendar_projection_days || 14}">
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${this._num(s.calendar_projection_days, 14)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_surprise_enabled_label")}<small>${this._t("panel.settings_surprise_enabled_hint")}</small></div>
@@ -4432,9 +4608,9 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<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="${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="${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="${s.surprise_bonus_max ?? 20}"></label>
<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>
</div>
</div>
<div class="tm-setting-row tm-setting-stack">
@@ -4460,8 +4636,8 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_roulette_section")}</div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${s.roulette_multiplier ?? 2}"></label>
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${s.roulette_daily_spins ?? 1}"></label>
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${this._num(s.roulette_multiplier, 2)}"></label>
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${this._num(s.roulette_daily_spins, 1)}"></label>
</div>
</div>
<div class="tm-setting-row">
@@ -4471,7 +4647,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_decay_params_label")}<small>${this._t("panel.settings_decay_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${s.points_decay_percent ?? 10}"></label>
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${this._num(s.points_decay_percent, 10)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="points_decay_period">
<option value="weekly" ${s.points_decay_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -4486,7 +4662,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_level_step_label")}<small>${this._t("panel.settings_level_step_hint")}</small></div>
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${s.level_xp_step || 100}">
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${this._num(s.level_xp_step, 100)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_spendcap_enabled_label")}<small>${this._t("panel.settings_spendcap_enabled_hint")}</small></div>
@@ -4495,7 +4671,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="${s.spend_cap_amount ?? 0}"></label>
<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_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>
@@ -4511,7 +4687,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_interest_params_label")}<small>${this._t("panel.settings_interest_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${s.interest_percent ?? 5}"></label>
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${this._num(s.interest_percent, 5)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="interest_period">
<option value="weekly" ${(s.interest_period || "weekly") === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -4544,7 +4720,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_perfect_week_bonus_label")}<small>${this._t("panel.settings_perfect_week_bonus_hint")}</small></div>
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${s.perfect_week_bonus || 50}">
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${this._num(s.perfect_week_bonus, 50)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_celebration_notify_label")}<small>${this._t("panel.settings_celebration_notify_hint")}</small></div>
@@ -4658,7 +4834,7 @@ class TaskMatePanel extends HTMLElement {
<tr>
<td>${this._esc((p.date || "").slice(0, 10))}</td>
<td>${this._esc(p.child_name || "")}</td>
<td style="text-align:right">${p.points} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
<td style="text-align:right">${this._num(p.points)} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
</tr>
`).join("")}
</tbody></table></div>
@@ -5115,7 +5291,7 @@ class TaskMatePanel extends HTMLElement {
<div style="padding:0 14px 12px;border-top:1px solid var(--tm-border-soft)">
<div class="tm-field-row" style="margin-top:10px">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-dialog-field="name" data-tpl-dialog-idx="${i}" value="${this._esc(c.name)}"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${c.points || 0}" min="0"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${this._num(c.points)}" min="0"></div>
</div>
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
@@ -5266,7 +5442,7 @@ class TaskMatePanel extends HTMLElement {
${this._select(this._t("panel.chore_first_occurrence_label"), "first_occurrence_mode", d.first_occurrence_mode, FIRST_OCCURRENCE)}
</div>
` : "",
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
this._soundField(d.completion_sound),
this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [
{ v: "easy", l: this._t("panel.difficulty_easy") },
{ v: "medium", l: this._t("panel.difficulty_medium") },
@@ -5299,7 +5475,7 @@ class TaskMatePanel extends HTMLElement {
${(d.bonus_subtasks || []).map((b, idx) => `
<div class="tm-field-row" style="align-items:flex-end;gap:6px;margin-bottom:6px">
<div class="tm-field" style="flex:2;margin:0"><input class="tm-input" placeholder="${this._t("panel.chore_subtask_name_placeholder")}" value="${this._esc(b.name)}" data-field="bonus_subtasks[${idx}].name"></div>
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${b.points}" data-field="bonus_subtasks[${idx}].points"></div>
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${this._num(b.points)}" data-field="bonus_subtasks[${idx}].points"></div>
<button type="button" class="tm-btn tm-btn-icon" data-act="remove-bonus-subtask" data-idx="${idx}" title="${this._t("panel.tooltip_remove")}" style="padding:6px 10px">✕</button>
</div>
`).join("")}
@@ -5595,6 +5771,7 @@ class TaskMatePanel extends HTMLElement {
{ v: "monthly", l: this._t("panel.reward_restock_monthly") },
])}
</div>`,
this._renderRewardTimeLock(d),
this._renderRewardUnlock(d),
].join(""),
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
@@ -5602,6 +5779,42 @@ class TaskMatePanel extends HTMLElement {
);
}
/**
* Time lock (#857). Restricts *claiming* to certain weekdays and/or a
* time-of-day window — the reward stays visible the rest of the time, greyed
* out with a "when it's available" badge, and saving points into a pool
* reward is never blocked.
*/
_renderRewardTimeLock(d) {
const open = this._dialog._openAdvanced?.has("timelock") || !!d.time_lock_enabled;
const days = (d.available_days || []).map(Number);
return `<details class="tm-advanced" data-section="timelock"${open ? " open" : ""}>
<summary>${this._t("panel.reward_timelock_section")}</summary>
<div>
${this._switch(this._t("panel.reward_timelock_label"), "time_lock_enabled", d.time_lock_enabled,
this._t("panel.reward_timelock_hint"), true)}
${d.time_lock_enabled ? `
<div class="tm-field">
<span class="tm-field-label">${this._t("panel.reward_timelock_days_label")}</span>
<div class="tm-chip-row">
${DAYS.map((day, i) => `
<button type="button" class="tm-chip-btn ${days.includes(i) ? "tm-chip-on" : ""}" data-act="toggle-reward-day" data-day="${i}">
${this._t(day.lk)}
</button>
`).join("")}
</div>
<span class="tm-field-hint">${this._t("panel.reward_timelock_days_hint")}</span>
</div>
<div class="tm-field-row">
${this._timeField(this._t("panel.reward_timelock_from_label"), "available_from", d.available_from)}
${this._timeField(this._t("panel.reward_timelock_until_label"), "available_until", d.available_until)}
</div>
<span class="tm-field-hint">${this._t("panel.reward_timelock_times_hint")}</span>
` : ""}
</div>
</details>`;
}
/**
* Timed unlock (#678). Only offers entities the parent has allowlisted in
* Settings — a reward must not be able to reach arbitrary devices.
@@ -5845,7 +6058,7 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
` : "",
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
this._soundField(d.completion_sound),
this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [
{ v: "easy", l: this._t("panel.difficulty_easy") },
{ v: "medium", l: this._t("panel.difficulty_medium") },
@@ -5881,7 +6094,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-reorder-item" draggable="true" data-drag-id="${this._esc(id)}">
<div class="tm-reorder-handle">⠿</div>
<div class="tm-reorder-name">${this._esc(c.name)}</div>
<div class="tm-reorder-points tm-numeric">${c.points}</div>
<div class="tm-reorder-points tm-numeric">${this._num(c.points)}</div>
<div class="tm-reorder-moves">
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="-1" data-drag-id="${this._esc(id)}" ${idx === 0 ? "disabled" : ""} title="${this._t("reorder.move_up")}" aria-label="${this._t("reorder.move_up")}">▲</button>
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="1" data-drag-id="${this._esc(id)}" ${idx === arr.length - 1 ? "disabled" : ""} title="${this._t("reorder.move_down")}" aria-label="${this._t("reorder.move_down")}">▼</button>
@@ -5910,7 +6123,7 @@ class TaskMatePanel extends HTMLElement {
</div>`;
}
_field(label, name, value, type = "text", hint = "") {
_field(label, name, value, type = "text", hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5920,11 +6133,11 @@ class TaskMatePanel extends HTMLElement {
value="${this._esc(value == null ? "" : value)}"
${type === "number" ? 'type="number"' : 'type="text"'}
>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_dateField(label, name, value, hint = "") {
_dateField(label, name, value, hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5934,11 +6147,25 @@ class TaskMatePanel extends HTMLElement {
value="${this._esc(value || "")}"
type="date"
>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_select(label, name, value, options, hint = "", rerender = false) {
_timeField(label, name, value, hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
<input
class="tm-input"
data-field="${name}"
value="${this._esc(value || "")}"
type="time"
>
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_select(label, name, value, options, hintHtml = "", rerender = false) {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5949,17 +6176,17 @@ class TaskMatePanel extends HTMLElement {
>
${options.map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(value) ? "selected" : ""}>${this._esc(o.lk ? this._t(o.lk) : o.l)}</option>`).join("")}
</select>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_switch(label, name, checked, hint = "", rerender = false) {
_switch(label, name, checked, hintHtml = "", rerender = false) {
return `
<div class="tm-check-row">
<ha-switch data-field="${name}" ${checked ? "checked" : ""} ${rerender ? 'data-rerender="true"' : ""}></ha-switch>
<div>
<div class="tm-check-title">${this._esc(label)}</div>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>
</div>`;
}
@@ -5985,11 +6212,63 @@ class TaskMatePanel extends HTMLElement {
</div>`;
}
/** Uploaded custom sounds from the last get_state (#856). */
_customSounds() {
return (this._state && this._state.custom_sounds) || [];
}
/** Built-in + uploaded sounds, as <select> options. */
_soundOptions() {
return [
...COMPLETION_SOUNDS.map(v => ({ v, l: v })),
...this._customSounds().map(s => ({ v: s.value, l: s.name })),
];
}
/**
* The chore editor's completion-sound picker, with a preview button.
*
* Picking a sound used to be silent here: the only preview code lived in
* taskmate-config-sounds.js, which scans the document for sound dropdowns
* and so could never see this one inside the panel's shadow root (#856).
*/
_soundField(value) {
const current = value || "coin";
return `
<div class="tm-field">
<span class="tm-field-label">${this._t("panel.chore_completion_sound_label")}</span>
<div class="tm-sound-row">
<select class="tm-select" data-field="completion_sound">
${this._soundOptions().map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(current) ? "selected" : ""}>${this._esc(o.l)}</option>`).join("")}
</select>
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview-field"
title="${this._esc(this._t("panel.sound_preview"))}"
aria-label="${this._esc(this._t("panel.sound_preview"))}">▶</button>
</div>
</div>`;
}
/** Play a sound through the shared engine (taskmate-sounds.js). */
_previewSound(value) {
const engine = window.__taskmate_sounds;
if (engine) engine.play(value, this._customSounds());
}
/**
* Icon field. `ha-icon-picker` is HA's own component — we keep it for the
* searchable MDI list, but its Material shell (grey fill, underline, 56px
* box) reads as a foreign control beside the panel's own inputs. The
* `.tm-icon-well` wrapper supplies the tm-input frame and the picker inside
* it is made transparent; see the CSS for why the wrapper, not the picker,
* carries the border.
*/
_iconPickerField(label, name, value) {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
<div class="tm-icon-well">
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
</div>
</div>`;
}
@@ -6014,7 +6293,7 @@ class TaskMatePanel extends HTMLElement {
else { (this._settingsEntityDraft = this._settingsEntityDraft || {})[field] = value; }
}
_entityPickerField(label, name, value, domains, hint = "") {
_entityPickerField(label, name, value, domains, hintHtml = "") {
const friendly = value && this._hass?.states?.[value]?.attributes?.friendly_name || "";
const icon = value ? (this._hass?.states?.[value]?.attributes?.icon || this._entityDomainIcon(value)) : "";
return `
@@ -6033,7 +6312,7 @@ class TaskMatePanel extends HTMLElement {
</div>`
}
</div>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
@@ -6208,6 +6487,15 @@ class TaskMatePanel extends HTMLElement {
return new Intl.NumberFormat().format(n);
}
// A number bound for a markup or attribute slot. The panel assembles its
// HTML as strings, so anything stored in a field we *assume* is numeric has
// to be proven numeric before it is echoed back. Non-numbers, inf and NaN
// fall back rather than reaching the DOM.
_num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
// Quick point-adjust amounts (#746). Stored as a comma-separated string like
// "5, 10, 20" — same shape as streak_milestones — so the generic settings
// collector can round-trip it as a plain text input. Junk degrades to the
@@ -6740,6 +7028,24 @@ class TaskMatePanel extends HTMLElement {
}
.tm-image-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* Custom completion sounds (#856) */
.tm-sound-row { display: flex; gap: 6px; align-items: center; }
.tm-sound-row .tm-select { flex: 1; min-width: 0; }
.tm-sound-list {
display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;
}
.tm-sound-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border: 1px solid var(--tm-border);
border-radius: var(--tm-radius-sm); background: var(--tm-surface-2);
}
.tm-sound-name {
flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tm-sound-item-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.tm-sound-upload { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.tm-points-adjust {
display: flex; gap: 4px; flex-wrap: wrap;
align-items: center;
@@ -6911,6 +7217,8 @@ class TaskMatePanel extends HTMLElement {
.tm-pill-pool { background: var(--tm-pool-soft); color: var(--tm-pool); border-color: color-mix(in srgb, var(--tm-pool), transparent 75%); }
.tm-pill-sticky { background: var(--tm-sticky-soft); color: var(--tm-sticky); border-color: color-mix(in srgb, var(--tm-sticky), transparent 75%); }
.tm-pill-spread { background: var(--tm-positive-soft); color: var(--tm-positive); border-color: var(--tm-positive-border); }
.tm-pill-timelock { background: var(--tm-accent-soft); color: var(--tm-accent-text); border-color: var(--tm-accent-border); }
.tm-pill-timelock ha-icon { --mdc-icon-size: 13px; vertical-align: -2px; }
.tm-pill-jackpot { background: var(--tm-gold-soft); color: var(--tm-gold); border-color: color-mix(in srgb, var(--tm-gold), transparent 75%); }
.tm-pill-muted { background: var(--tm-surface-2); color: var(--tm-text-muted); border-color: var(--tm-border); }
.tm-quest-inactive { opacity: 0.6; }
@@ -7335,6 +7643,42 @@ class TaskMatePanel extends HTMLElement {
display: block; width: 100%;
}
/* Icon field well: make ha-icon-picker sit in the dialogs like a
tm-input. The picker's inner shell can't be reached from here (shadow
DOM), so the wrapper carries the frame and the picker is stripped back:
- the fill and the 1px "active indicator" are var() usages inside
ha-picker-field, so overriding the vars here does reach them;
- ha-combo-box-item hard-sets its own 56px row height and square
bottom corners, which no variable overrides — so the well is
tm-input height with overflow:hidden, and the picker is pulled up
by half the difference to keep its content optically centred.
The --mdc-* vars cover older HA, which still renders an mwc textfield. */
.tm-icon-well {
box-sizing: border-box;
height: 37px;
background: var(--tm-surface-0);
border: 1px solid var(--tm-border);
border-radius: var(--tm-radius-sm);
box-shadow: var(--tm-shadow-xs);
overflow: hidden;
transition: all 0.1s var(--tm-easing);
}
.tm-icon-well:hover { border-color: var(--tm-border-strong); }
.tm-icon-well:focus-within { border-color: var(--tm-accent); box-shadow: var(--tm-shadow-focus); }
.tm-icon-well ha-icon-picker {
display: block; width: 100%;
margin-top: calc((37px - 56px) / 2);
--ha-color-form-background: transparent;
--ha-color-border-neutral-loud: transparent;
--md-list-item-leading-space: 11px;
--md-list-item-trailing-space: 4px;
--md-list-item-label-text-size: 13px;
--mdc-text-field-fill-color: transparent;
--mdc-text-field-idle-line-color: transparent;
--mdc-text-field-hover-line-color: transparent;
--mdc-text-field-focused-line-color: transparent;
}
/* Chip multi-select */
/* Insight reports (#679) */
.tm-verdict {