278 files
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user