/**
* TaskMate admin panel — sidebar entry at /taskmate-admin.
*
* HA-native styling:
* - All custom --tm-* tokens now map to HA theme variables.
* - Dark mode handled automatically via HA vars.
* - Version extracted from resource URL (?v=x.x.x), same as cards.
*/
const PANEL_VERSION = (() => {
// The version comes from the resource URL (?v=x.x.x), set by the integration
// itself — but it is still DOM-sourced text that we later splice into
// innerHTML, so clamp it to a version-safe charset to remove any chance of
// HTML injection (and silence CodeQL js/xss-through-dom).
const sanitize = (v) => ((v || "").replace(/[^\w.+-]/g, "") || "?");
try { return sanitize(new URL(import.meta.url).searchParams.get("v")); } catch (_) {}
const el = document.querySelector('script[src*="/taskmate-panel.js"]');
return el ? sanitize(new URLSearchParams(el.src.split("?")[1] || "").get("v")) : "?";
})();
const TABS = [
{ id: "children", lk: "panel.tab_children" },
{ id: "activity", lk: "panel.tab_activity" },
{ id: "insights", lk: "panel.tab_insights" },
{ id: "chores", lk: "panel.tab_chores" },
{ id: "rewards", lk: "panel.tab_rewards" },
{ id: "penalties", lk: "panel.tab_penalties" },
{ id: "bonuses", lk: "panel.tab_bonuses" },
{ id: "groups", lk: "panel.tab_groups" },
{ id: "quests", lk: "panel.tab_quests" },
{ id: "challenges", lk: "panel.tab_challenges" },
{ id: "badges", lk: "panel.tab_badges" },
{ id: "templates", lk: "panel.tab_templates" },
{ id: "notifications", lk: "panel.tab_notifications" },
{ id: "audit", lk: "panel.tab_audit" },
{ id: "settings", lk: "panel.tab_settings", label: "⚙" },
];
const BADGE_METRICS = [
{ v: "total_points", lk: "badge.metric_total_points" },
{ v: "total_chores", lk: "badge.metric_total_chores" },
{ v: "total_rewards", lk: "badge.metric_total_rewards" },
{ v: "current_streak", lk: "badge.metric_current_streak" },
{ v: "best_streak", lk: "badge.metric_best_streak" },
{ v: "perfect_weeks", lk: "badge.metric_perfect_weeks" },
{ v: "first_chore", lk: "badge.metric_first_chore" },
{ v: "first_reward", lk: "badge.metric_first_reward" },
];
const BADGE_BOOL_METRICS = ["first_chore", "first_reward"];
const BADGE_OPERATORS = ["≥", "=", "≤", ">", "<", "≠"];
const BADGE_OP_VALUES = { "≥": ">=", "=": "==", "≤": "<=", ">": ">", "<": "<", "≠": "!=" };
const BADGE_TIERS = [
{ v: "bronze", lk: "badge.tier_bronze" },
{ v: "silver", lk: "badge.tier_silver" },
{ v: "gold", lk: "badge.tier_gold" },
{ v: "platinum", lk: "badge.tier_platinum" },
];
const SCHEDULE_MODES = [
{ v: "specific_days", lk: "panel.schedule_specific_days" },
{ v: "recurring", lk: "panel.schedule_recurring" },
{ v: "one_shot", lk: "panel.schedule_one_shot" },
];
const RECURRENCES = [
{ v: "every_2_days", lk: "panel.recurrence_every_2_days" },
{ v: "weekly", lk: "panel.recurrence_weekly" },
{ v: "every_2_weeks", lk: "panel.recurrence_every_2_weeks" },
{ v: "monthly", lk: "panel.recurrence_monthly" },
{ v: "every_3_months", lk: "panel.recurrence_every_3_months" },
{ v: "every_6_months", lk: "panel.recurrence_every_6_months" },
];
const FIRST_OCCURRENCE = [
{ v: "available_immediately", lk: "panel.first_occ_available_immediately" },
{ v: "wait_for_first_occurrence", lk: "panel.first_occ_wait" },
];
const ASSIGNMENT_MODES = [
{ v: "everyone", lk: "panel.assign_everyone" },
{ v: "alternating", lk: "panel.assign_alternating" },
{ v: "random", lk: "panel.assign_random" },
{ v: "balanced", lk: "panel.assign_balanced" },
{ v: "first_come", lk: "panel.assign_first_come" },
{ v: "unassigned", lk: "panel.assign_unassigned" },
];
const VISIBILITY_OPS = [
{ v: "none", lk: "panel.vis_none" },
{ v: "equals", lk: "panel.vis_equals" },
{ v: "not_equals", lk: "panel.vis_not_equals" },
{ v: "gte", lk: "panel.vis_gte" },
{ v: "lte", lk: "panel.vis_lte" },
{ v: "gt", lk: "panel.vis_gt" },
{ v: "lt", lk: "panel.vis_lt" },
];
// Chore fields a change can be scheduled against (#675). Must stay in step with
// SCHEDULED_CHANGE_FIELDS in models.py — the backend rejects anything else.
const SCHEDULED_FIELDS = [
{ v: "points", kind: "number" },
{ v: "assigned_to", kind: "children" },
{ v: "enabled", kind: "bool" },
{ v: "requires_approval", kind: "bool" },
{ v: "daily_limit", kind: "number" },
{ v: "due_days", kind: "days" },
{ v: "mandatory", kind: "bool" },
{ v: "mandatory_penalty_points", kind: "number" },
{ v: "expires_on", kind: "text" },
{ v: "description", kind: "text" },
{ v: "time_category", kind: "select", options: [
{ v: "anytime", lk: "panel.time_anytime" },
{ v: "morning", lk: "panel.time_morning" },
{ v: "afternoon", lk: "panel.time_afternoon" },
{ v: "evening", lk: "panel.time_evening" },
{ v: "night", lk: "panel.time_night" },
] },
{ v: "difficulty", kind: "select", options: [
{ v: "easy", lk: "panel.difficulty_easy" },
{ v: "medium", lk: "panel.difficulty_medium" },
{ v: "hard", lk: "panel.difficulty_hard" },
] },
];
// Adverse HA weather conditions a chore can be blocked by. The pleasant ones
// (sunny, clear-night, partlycloudy) are deliberately omitted — nobody rains
// off "mow the lawn" because it's sunny.
const WEATHER_CONDITIONS = [
{ v: "rainy", icon: "mdi:weather-rainy" },
{ v: "pouring", icon: "mdi:weather-pouring" },
{ v: "snowy", icon: "mdi:weather-snowy" },
{ v: "snowy-rainy", icon: "mdi:weather-snowy-rainy" },
{ v: "hail", icon: "mdi:weather-hail" },
{ v: "lightning", icon: "mdi:weather-lightning" },
{ v: "lightning-rainy", icon: "mdi:weather-lightning-rainy" },
{ v: "fog", icon: "mdi:weather-fog" },
{ v: "windy", icon: "mdi:weather-windy" },
{ v: "windy-variant", icon: "mdi:weather-windy-variant" },
{ v: "cloudy", icon: "mdi:weather-cloudy" },
{ v: "exceptional", icon: "mdi:alert-outline" },
];
const COMPLETION_SOUNDS = [
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
"fart8", "fart9", "fart10", "fart_random",
];
const DAYS = [
{ v: "monday", lk: "panel.day_mon" },
{ v: "tuesday", lk: "panel.day_tue" },
{ v: "wednesday", lk: "panel.day_wed" },
{ v: "thursday", lk: "panel.day_thu" },
{ v: "friday", lk: "panel.day_fri" },
{ v: "saturday", lk: "panel.day_sat" },
{ v: "sunday", lk: "panel.day_sun" },
];
const STREAK_MODES = [
{ v: "reset", lk: "panel.streak_reset" },
{ v: "pause", lk: "panel.streak_pause" },
];
const TASK_GROUP_POLICIES = [
{ v: "sticky", lk: "panel.group_policy_sticky" },
{ v: "spread", lk: "panel.group_policy_spread" },
];
class TaskMatePanel extends HTMLElement {
constructor() {
super();
this._hass = null;
this._state = null;
this._error = null;
this._loading = false;
this._timePeriodsDraft = null; // local edit state for the period editor
this._vacationDraft = null; // local edit state for the vacation editor
this._bulkMode = false; // chores multi-select mode
this._bulkSel = new Set(); // selected chore ids for bulk actions
this._activeTab = "children";
this._mobileNavOpen = false; // mobile section-picker dropdown state
this._dialog = null;
this._dialogInitialHash = null; // for confirm-on-leave
this._filter = ""; // per-tab search/filter
this._inlineRename = null; // { kind: "chore", id, value }
this._rowMenuEl = null; // floating row-action menu element (chores)
this._rowMenuForId = null; // chore id the open row menu belongs to
this._onRowMenuDismiss = null; // scroll/resize listener while menu open
this._reorderDrag = null; // tracks drag state during reorder
this._templateView = null; // null | "picker" | "preview"
this._templateSelected = null; // selected template object
this._templateChores = []; // editable chores for preview step
this._saveTemplateDialog = false; // "save as template" dialog state
this._badgesSubTab = "catalogue"; // "catalogue" | "custom" | "history"
this._rendered = false;
this._shellReady = false;
this._zoneCache = {};
this._cachedStyles = null;
this._onFocusIn = this._onFocusIn.bind(this);
this._onClick = this._onClick.bind(this);
this._onDblClick = this._onDblClick.bind(this);
this._onInput = this._onInput.bind(this);
this._onChange = this._onChange.bind(this);
this._onValueChanged = this._onValueChanged.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this._onDragStart = this._onDragStart.bind(this);
this._onDragOver = this._onDragOver.bind(this);
this._onDrop = this._onDrop.bind(this);
this._onVisibilityChange = this._onVisibilityChange.bind(this);
this._lastConnected = null;
this._showIds = localStorage.getItem("taskmate-show-ids") === "true";
this._ensureHaComponents();
}
async _ensureHaComponents() {
if (customElements.get("ha-textfield")) return;
try { await window.loadCardHelpers?.(); } catch (_) {}
await Promise.all([
customElements.whenDefined("ha-textfield").catch(() => {}),
customElements.whenDefined("ha-switch").catch(() => {}),
customElements.whenDefined("ha-icon-picker").catch(() => {}),
]);
if (this._rendered) this._render();
}
set hass(value) {
const first = this._hass === null;
const prevConnected = this._lastConnected;
this._hass = value;
const isDark = this._isHaDark();
this.classList.toggle("dark", isDark);
this.classList.toggle("light", !isDark);
const connNow = !!(value && value.connection && value.connection.connected !== false);
this._lastConnected = connNow;
if (first) {
this._fetchState();
} else if (connNow && prevConnected === false) {
// WS reconnected after a drop — refetch so the panel reflects current state.
this._fetchState();
} else if (connNow && this._error) {
// Recover from a previous fetch failure (e.g. WS was mid-handshake earlier).
this._error = null;
this._fetchState();
}
// Render on first hass, or if HA pushed a fresh hass after a reconnect
// that completed while the tab was already visible (no visibilitychange
// to lean on) and our shell was stripped out in the meantime.
if (!this._rendered || !this.querySelector(".tm-shell")) this._render();
this._bindHaPickers(false);
}
get hass() { return this._hass; }
set narrow(_v) {}
set route(_v) {}
set panel(_v) {}
connectedCallback() {
this.addEventListener("focusin", this._onFocusIn);
this.addEventListener("click", this._onClick);
this.addEventListener("dblclick", this._onDblClick);
this.addEventListener("input", this._onInput);
this.addEventListener("change", this._onChange);
this.addEventListener("value-changed", this._onValueChanged);
this.addEventListener("keydown", this._onKeyDown);
this.addEventListener("dragstart", this._onDragStart);
this.addEventListener("dragover", this._onDragOver);
this.addEventListener("drop", this._onDrop);
document.addEventListener("visibilitychange", this._onVisibilityChange);
if (!this.classList.contains("dark") && !this.classList.contains("light")) {
const isDark = this._isHaDark();
this.classList.toggle("dark", isDark);
this.classList.toggle("light", !isDark);
}
// Render if we never have, or if our content was stripped while we were
// detached (HA cached us through a connection drop, or SPA nav away/back).
// _render's self-heal rebuilds the shell when .tm-shell is missing.
if (!this._rendered || !this.querySelector(".tm-shell")) this._render();
}
disconnectedCallback() {
this.removeEventListener("focusin", this._onFocusIn);
this.removeEventListener("click", this._onClick);
this.removeEventListener("dblclick", this._onDblClick);
this.removeEventListener("input", this._onInput);
this.removeEventListener("change", this._onChange);
this.removeEventListener("value-changed", this._onValueChanged);
this.removeEventListener("keydown", this._onKeyDown);
this.removeEventListener("dragstart", this._onDragStart);
this.removeEventListener("dragover", this._onDragOver);
this.removeEventListener("drop", this._onDrop);
document.removeEventListener("visibilitychange", this._onVisibilityChange);
this._closeRowMenu();
}
_onVisibilityChange() {
if (document.visibilityState !== "visible") return;
if (!this._hass) return;
// Tab woke up. HA may have stripped our DOM while the socket was down —
// repaint cached state immediately if the shell is gone, then always
// refetch so returning to the tab shows current data. This mirrors how
// Lovelace repaints on reconnect and also clears any staleness from
// changes made on another device while the tab was idle.
if (!this.querySelector(".tm-shell")) this._render();
this._fetchState();
}
_isHaDark() {
const el = this.isConnected ? this : document.documentElement;
const bg = getComputedStyle(el)
.getPropertyValue("--primary-background-color").trim();
if (!bg) return false;
let r, g, b;
if (bg.startsWith("#")) {
const h = bg.slice(1);
r = parseInt(h.substr(0, 2), 16);
g = parseInt(h.substr(2, 2), 16);
b = parseInt(h.substr(4, 2), 16);
} else {
const m = bg.match(/\d+/g);
if (m) { r = +m[0]; g = +m[1]; b = +m[2]; }
}
if (r === undefined) return false;
return (r * 299 + g * 587 + b * 114) / 1000 < 128;
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this._hass, key, params) : key;
}
// Transaction reasons are stored in English in the DB (e.g. "Penalty: Messy room").
// Mirror the activity-card mapping so panel views render in the user's language.
_translateReason(reason) {
if (!reason) return reason;
const prefixMap = [
['Allocated to pool:', 'activity.reason_allocated_to_pool'],
['Pool refund (reward expired):', 'activity.reason_pool_refund_expired'],
['Pool refund (reward sold out):', 'activity.reason_pool_refund_sold_out'],
['Pool refund (reward cost reduced):', 'activity.reason_pool_refund_cost_reduced'],
['Pool refund (reward deleted):', 'activity.reason_pool_refund_deleted'],
['Penalty:', 'activity.reason_penalty'],
['Bonus:', 'activity.reason_bonus'],
];
for (const [prefix, key] of prefixMap) {
if (reason.startsWith(prefix)) {
const name = reason.slice(prefix.length).trim();
return this._t(key, { name });
}
}
if (reason.startsWith('Perfect week bonus!')) {
return this._t('activity.reason_perfect_week');
}
const weekendMatch = reason.match(/^Weekend bonus \(×(\d+)\)$/);
if (weekendMatch) {
return this._t('activity.reason_weekend_bonus', { multiplier: weekendMatch[1] });
}
const streakMatch = reason.match(/^Streak milestone bonus \((\d+) day streak!\)$/);
if (streakMatch) {
return this._t('activity.reason_streak_milestone', { days: streakMatch[1] });
}
return reason;
}
// ---- state -----------------------------------------------------------
async _fetchState(_attempt = 0) {
if (!this._hass) return;
this._loading = true;
this._render();
try {
this._state = await this._hass.callWS({ type: "taskmate/get_state" });
this._error = null;
} catch (err) {
// Retry once after a short delay — covers the race where the WS is
// still mid-reconnect when we make the call.
if (_attempt < 1) {
this._loading = false;
await new Promise(r => setTimeout(r, 800));
return this._fetchState(_attempt + 1);
}
this._error = (err && err.message) || String(err);
this._state = null;
}
// Fetch notifications state separately — failure here must not abort
// the main state load or its retry logic.
try {
this._notifState = await this._hass.callWS({ type: "taskmate/notifications/get_state" });
this._notifyServices = await this._hass.callWS({ type: "taskmate/notifications/list_notify_services" });
} catch (e) {
console.warn("TaskMate notifications state fetch failed", e);
this._notifState = null;
this._notifyServices = [];
}
this._loading = false;
this._render();
}
async _callWS(payload) {
try {
const res = await this._hass.callWS(payload);
return { ok: true, res };
} catch (err) {
return { ok: false, err: (err && err.message) || String(err) };
}
}
_showToast(kind, text) {
const existing = this.querySelector(".tm-toast");
if (existing) existing.remove();
const node = document.createElement("div");
node.className = `tm-toast tm-toast-${kind}`;
node.textContent = text;
this.appendChild(node);
setTimeout(() => { if (node.isConnected) node.remove(); }, 3500);
}
_hashDialog() {
return this._dialog ? JSON.stringify(this._dialog.data) : "";
}
_closeDialog(force = false) {
if (!force && this._dialog && this._dialogInitialHash != null && this._hashDialog() !== this._dialogInitialHash) {
if (!confirm(this._t("panel.confirm_unsaved"))) return;
}
this._dialog = null;
this._dialogInitialHash = null;
this._render();
}
_openDialog(d) {
this._dialog = d;
this._dialogInitialHash = this._hashDialog();
this._render();
}
// ---- event delegation ------------------------------------------------
_onClick(e) {
if (!e.target.closest(".tm-ep")) this._closeEntityDropdown();
// Row action menu: dismiss on any click that isn't the kebab toggle itself.
// Clicks on a menu item still resolve their action below (e.target's parent
// chain stays intact after the menu is detached), then the menu closes.
if (this._rowMenuEl && !e.target.closest('[data-act="toggle-row-menu"]')) this._closeRowMenu();
const epSelected = e.target.closest(".tm-ep-selected");
if (epSelected && !e.target.closest(".tm-entity-clear")) {
const picker = epSelected.closest(".tm-ep");
const field = picker?.dataset?.epField;
if (field) {
this._setEntityField(field, "");
this._render();
requestAnimationFrame(() => {
const input = this.querySelector(`.tm-ep[data-ep-field="${field}"] .tm-ep-input`);
if (input) { input.focus(); }
});
}
return;
}
const t = e.target.closest("[data-act]");
if (!t) return;
const act = t.dataset.act;
if (act === "tab") {
this._activeTab = t.dataset.tab; this._filter = ""; this._mobileNavOpen = false; this._render();
// Settings tab lists HA users for the parent-role picker (#661); load then repaint.
if (this._activeTab === "settings") this._ensureHaUsers().then(() => this._render());
return;
}
if (act === "toggle-mobile-nav") { this._mobileNavOpen = !this._mobileNavOpen; this._render(); return; }
if (act === "close-mobile-nav") { this._mobileNavOpen = false; this._render(); return; }
if (act === "close-dialog") { this._closeDialog(); return; }
if (act === "clear-field") { this._setEntityField(t.dataset.field, ""); this._render(); return; }
if (act === "pick-entity") {
// The allowlist picker (#678) isn't a dialog field — picking there adds
// the entity to the saved list rather than writing into _dialog.data.
if (t.dataset.field === "_allowlist_pick") {
this._closeEntityDropdown();
this._addToAllowlist(t.dataset.value);
return;
}
this._setEntityField(t.dataset.field, t.dataset.value); this._closeEntityDropdown(); this._render(); return;
}
if (act === "scrim") {
if (e.target === this.querySelector(".tm-scrim")) this._closeDialog();
return;
}
if (act === "retry") { this._fetchState(); return; }
if (act === "switch-to-activity") { this._activeTab = "activity"; this._render(); return; }
if (act === "toggle-ha-menu") { this.dispatchEvent(new CustomEvent("hass-toggle-menu", { bubbles: true, composed: true })); return; }
if (act === "copy-id") { navigator.clipboard.writeText(t.dataset.id).then(() => this._showToast("ok", this._t("panel.toast_copied"))); return; }
if (act === "view-photo") {
if (window.__taskmate_lightbox) { e.preventDefault(); window.__taskmate_lightbox(t.dataset.photo, t.dataset.cap, this._t("common.close")); }
return;
}
// Children
if (act === "add-child") { this._openChildDialog(null); return; }
if (act === "gift-points") { this._openGiftDialog(); return; }
if (act === "save-gift") { this._doGiftPoints(); return; }
if (act === "edit-child") { this._openChildDialog(t.dataset.id); return; }
if (act === "delete-child") { this._confirmDelete("child", t.dataset.id); return; }
if (act === "save-child") { this._doSaveChild(); return; }
if (act === "reorder-chores-for-child") { this._openReorderDialog(t.dataset.id); return; }
if (act === "reorder-move") { this._moveInReorder(t.dataset.dragId, Number(t.dataset.dir)); return; }
if (act === "save-chore-order") { this._dialog && this._dialog.data && this._dialog.data.global ? this._doSaveGlobalChoreOrder() : this._doSaveChoreOrder(); return; }
if (act === "reorder-chores-global") { this._openGlobalReorderDialog(); return; }
// Chores
if (act === "add-chore") { this._openChoreDialog(null); return; }
if (act === "edit-chore") { this._openChoreDialog(t.dataset.id); return; }
if (act === "toggle-row-menu") { this._toggleRowMenu(t); return; }
if (act === "clone-chore") { this._doCloneChore(t.dataset.id); return; }
if (act === "request-swap") { this._openSwapDialog(t.dataset.id); return; }
if (act === "save-swap") { this._doRequestSwap(); return; }
if (act === "approve-swap") { this._doSwap("approve", t.dataset.id); return; }
if (act === "reject-swap") { this._doSwap("reject", t.dataset.id); return; }
if (act === "bulk-toggle") { this._bulkMode = !this._bulkMode; this._bulkSel.clear(); this._render(); return; }
if (act === "bulk-enable") { this._doBulk("enable"); return; }
if (act === "bulk-disable") { this._doBulk("disable"); return; }
if (act === "bulk-delete") { this._doBulk("delete"); return; }
if (act === "bulk-reassign") {
const sel = this.querySelector("[data-role='bulk-reassign']");
const v = sel ? sel.value : "";
if (!v) { this._showToast("err", this._t("panel.bulk_pick_target")); return; }
this._doBulk("reassign", v === "__all__" ? [] : [v]); return;
}
if (act === "delete-chore") { this._confirmDelete("chore", t.dataset.id); return; }
if (act === "parent-complete-chore") { this._doParentComplete(t.dataset.id); return; }
if (act === "skip-chore") { this._doSkipChore(t.dataset.id); return; }
if (act === "toggle-chore-active") { this._doToggleChoreActive(t.dataset.id); return; }
if (act === "save-chore") { this._doSaveChore(); return; }
if (act === "bulk-add-chore") { this._openBulkAddDialog(); return; }
if (act === "save-bulk-chores") { this._doSaveBulkChores(); return; }
if (act === "rename-chore-start") { this._startInlineRename("chore", t.dataset.id); return; }
if (act === "rename-chore-commit") { this._commitInlineRename(); return; }
if (act === "rename-chore-cancel") { this._inlineRename = null; this._render(); return; }
if (act === "toggle-day") { this._toggleArrayField("due_days", t.dataset.day); return; }
if (act === "toggle-bulk-day") { this._toggleArrayField("due_days", t.dataset.day); return; }
if (act === "toggle-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; }
if (act === "toggle-depends") { this._toggleArrayField("depends_on", t.dataset.id); return; }
if (act === "toggle-calendar") { this._toggleArrayField("publish_calendar_entities", t.dataset.id); return; }
if (act === "toggle-weather-condition") { this._toggleArrayField("weather_block_conditions", t.dataset.id); return; }
if (act === "insights-view") {
this._insightView = t.dataset.id;
this._render();
return;
}
if (act === "health-refresh") { this._health = null; this._loadHealth(); return; }
if (act === "health-goto") {
// Jump straight to where the problem lives rather than describing it.
const target = { chores: "chores", rewards: "rewards", children: "children",
settings: "settings", activity: "activity" }[t.dataset.id];
if (target) { this._activeTab = target; this._render(); }
return;
}
if (act === "projection-window") {
this._projectionDays = Number(t.dataset.id);
this._projection = null;
this._loadProjection(this._projectionDays);
return;
}
if (act === "friction-window") {
this._frictionDays = Number(t.dataset.id);
this._friction = null;
this._loadFriction(this._frictionDays);
return;
}
if (act === "insights-window") {
this._insightDays = Number(t.dataset.id);
this._fairness = null;
this._loadFairness(this._insightDays);
return;
}
if (act === "remove-allowlist") { this._removeFromAllowlist(t.dataset.id); return; }
if (act === "add-scheduled") { this._addScheduledChange(); return; }
if (act === "remove-scheduled") { this._removeScheduledChange(t.dataset.id); return; }
if (act === "toggle-scheduled-child" || act === "toggle-scheduled-day") {
this._toggleScheduledListValue(t.dataset.id); return;
}
if (act === "add-bonus-subtask") { this._addBonusSubtask(); return; }
if (act === "remove-bonus-subtask") { this._removeBonusSubtask(Number(t.dataset.idx)); return; }
// Rewards
if (act === "add-reward") { this._openRewardDialog(null); return; }
if (act === "edit-reward") { this._openRewardDialog(t.dataset.id); return; }
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; }
// Quests (chore chains)
if (act === "add-quest") { this._openQuestDialog(null); return; }
if (act === "edit-quest") { this._openQuestDialog(t.dataset.id); return; }
if (act === "delete-quest") { this._confirmDelete("quest", t.dataset.id); return; }
if (act === "save-quest") { this._doSaveQuest(); return; }
if (act === "toggle-quest-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; }
if (act === "toggle-quest-step") { this._toggleArrayField("steps", t.dataset.id); return; }
if (act === "quest-step-move") { this._moveQuestStep(Number(t.dataset.idx), t.dataset.dir); return; }
// Challenges (daily / weekly)
if (act === "add-challenge") { this._openChallengeDialog(null); return; }
if (act === "edit-challenge") { this._openChallengeDialog(t.dataset.id); return; }
if (act === "delete-challenge") { this._confirmDelete("challenge", t.dataset.id); return; }
if (act === "save-challenge") { this._doSaveChallenge(); return; }
if (act === "toggle-challenge-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; }
// Avatar unlockables
if (act === "manage-avatars") { this._openAvatarCatalogDialog(); return; }
if (act === "avatar-add-row") { this._avatarAddRow(); return; }
if (act === "avatar-del-row") { this._avatarDelRow(Number(t.dataset.idx)); return; }
if (act === "save-avatar-catalog") { this._doSaveAvatarCatalog(); return; }
// Penalties
if (act === "add-penalty") { this._openPenBonDialog("penalty", null); return; }
if (act === "edit-penalty") { this._openPenBonDialog("penalty", t.dataset.id); return; }
if (act === "delete-penalty") { this._confirmDelete("penalty", t.dataset.id); return; }
if (act === "save-penalty") { this._doSavePenBon("penalty"); return; }
if (act === "apply-penalty") { this._openApplyDialog("penalty", t.dataset.id); return; }
if (act === "do-apply-penalty") { this._doApplyPenBon("penalty", t.dataset.id, t.dataset.child); return; }
// Bonuses
if (act === "add-bonus") { this._openPenBonDialog("bonus", null); return; }
if (act === "edit-bonus") { this._openPenBonDialog("bonus", t.dataset.id); return; }
if (act === "delete-bonus") { this._confirmDelete("bonus", t.dataset.id); return; }
if (act === "save-bonus") { this._doSavePenBon("bonus"); return; }
if (act === "apply-bonus") { this._openApplyDialog("bonus", t.dataset.id); return; }
if (act === "do-apply-bonus") { this._doApplyPenBon("bonus", t.dataset.id, t.dataset.child); return; }
if (act === "toggle-penbon-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; }
// Templates
if (act === "tpl-add-from") { this._activeTab = "templates"; this._templateView = "picker"; this._render(); return; }
if (act === "tpl-select") { this._selectTemplate(t.dataset.id); return; }
if (act === "tpl-back") { this._templateView = null; this._templateSelected = null; this._templateChores = []; this._render(); return; }
if (act === "tpl-remove-chore") { this._templateChores.splice(Number(t.dataset.idx), 1); this._render(); return; }
if (act === "tpl-toggle-expand"){ const idx = Number(t.dataset.idx); if (this._templateChores[idx]) { this._templateChores[idx]._expanded = !this._templateChores[idx]._expanded; this._render(); } return; }
if (act === "tpl-apply") { this._doApplyTemplate(); return; }
if (act === "tpl-save-from") { this._saveTemplateDialog = true; this._render(); return; }
if (act === "tpl-save-confirm") { this._doSaveFromChores(); return; }
if (act === "tpl-save-cancel") { this._saveTemplateDialog = false; this._render(); return; }
if (act === "tpl-create") { this._openCreateTemplateDialog(); return; }
if (act === "tpl-edit") { this._openEditTemplateDialog(t.dataset.id); return; }
if (act === "tpl-delete") { this._confirmDeleteTemplate(t.dataset.id); return; }
if (act === "tpl-save-created") { this._doSaveCreatedTemplate(); return; }
if (act === "tpl-save-edited") { this._doSaveEditedTemplate(); return; }
if (act === "tpl-toggle-day") { const idx = Number(t.dataset.idx); const day = t.dataset.day; if (this._templateChores[idx]) { const days = this._templateChores[idx].due_days || []; const pos = days.indexOf(day); if (pos >= 0) days.splice(pos, 1); else days.push(day); this._templateChores[idx].due_days = days; this._render(); } return; }
if (act === "tpl-dialog-add-chore") { if (this._dialog?.data?.chores) { this._dialog.data.chores.push({ name: "", points: 5, time_category: "anytime", schedule_mode: "specific_days", due_days: [], requires_approval: false, assignment_mode: "everyone", daily_limit: 1, completion_sound: "coin" }); this._render(); } return; }
if (act === "tpl-dialog-remove-chore") { if (this._dialog?.data?.chores) { this._dialog.data.chores.splice(Number(t.dataset.idx), 1); this._render(); } return; }
// Groups
if (act === "add-group") { this._openGroupDialog(null); return; }
if (act === "edit-group") { this._openGroupDialog(t.dataset.id); return; }
if (act === "delete-group") { this._confirmDelete("group", t.dataset.id); return; }
if (act === "save-group") { this._doSaveGroup(); return; }
if (act === "toggle-group-chore") { this._toggleArrayField("chore_ids", t.dataset.id); return; }
// Badges
if (act === "badges-subtab") { this._badgesSubTab = t.dataset.sub; this._render(); return; }
if (act === "add-badge") { this._openBadgeDialog(null); return; }
if (act === "edit-badge") { this._openBadgeDialog(t.dataset.id); return; }
if (act === "delete-badge") { this._confirmDeleteBadge(t.dataset.id); return; }
if (act === "save-badge") { this._doSaveBadge(); return; }
if (act === "toggle-badge-enabled") { this._doToggleBadgeEnabled(t.dataset.id, t.dataset.enabled === "true"); return; }
if (act === "award-badge") { this._openAwardDialog(t.dataset.id); return; }
if (act === "do-award-badge") { this._doAwardBadge(t.dataset.id, this._dialog?.data?.child_id || ""); return; }
if (act === "revoke-badge") { this._doRevokeBadge(t.dataset.id, t.dataset.name); return; }
if (act === "rebuild-badges") { this._doRebuildBadges(); return; }
if (act === "badge-add-criterion") { this._syncIconPickers(); if (this._dialog?.data?.criteria) { this._dialog.data.criteria.push({ metric: "total_points", operator: ">=", value: 1 }); this._render(); } return; }
if (act === "badge-remove-criterion") { this._syncIconPickers(); if (this._dialog?.data?.criteria) { this._dialog.data.criteria.splice(Number(t.dataset.idx), 1); this._render(); } return; }
if (act === "badge-toggle-assigned") { this._syncIconPickers(); this._toggleArrayField("assigned_to", t.dataset.id); return; }
// Notifications tab
if (act === "notif-set-master") { this._notifSetMaster(t.dataset.typeId, t.checked); return; }
if (act === "notif-send-test") { this._doSendTestNotif(t.dataset.typeId); return; }
if (act === "notif-set-route") { this._notifSetRoute(t.dataset.typeId, t.dataset.recipientId, t.checked, t.dataset.time || null); return; }
if (act === "notif-set-route-time") { /* handled in _onChange */ return; }
if (act === "notif-set-child-notify") { /* handled in _onChange */ return; }
if (act === "notif-set-parent-notify"){ /* handled in _onChange */ return; }
if (act === "notif-rename-parent") { /* handled in _onChange */ return; }
if (act === "notif-set-streak-cutoff"){ /* handled in _onChange */ return; }
if (act === "notif-add-parent") { this._notifAddParent(); return; }
if (act === "notif-delete-parent") { this._notifDeleteParent(t.dataset.parentId); return; }
if (act === "notif-add-custom") { this._notifAddCustom(); return; }
if (act === "notif-delete-custom") { this._notifDeleteCustom(t.dataset.customId); return; }
if (act === "notif-update-custom") { /* handled in _onChange */ return; }
if (act === "notif-toggle-custom") { this._notifToggleCustom(t.dataset.customId, t.checked); return; }
if (act === "notif-toggle-day") { this._notifToggleDay(t.dataset.customId, Number(t.dataset.day), t.checked); return; }
if (act === "notif-toggle-recipient") { this._notifToggleRecipient(t.dataset.customId, t.dataset.recipientId); return; }
// Settings
if (act === "save-settings") { this._doSaveSettings(); return; }
if (act === "config-export") { this._doExportConfig(); return; }
if (act === "config-import") { this.querySelector("[data-role='config-import-file']")?.click(); return; }
if (act === "ics-show") { this._icsShow(); return; }
if (act === "ics-regenerate") { this._icsRegenerate(); return; }
if (act === "ics-copy") { this._icsCopy(); return; }
// Audit log
if (act === "audit-clear") { this._doClearAudit(); return; }
// Undo a points transaction (penalty / bonus)
if (act === "undo-tx") { this._doUndoTransaction(t.dataset.id); return; }
// Time-of-day period editor
if (act === "tp-add") {
this._syncTimePeriodInputs();
this._timePeriodsDraft.push({ id: "", label: "", start: "", end: "", icon: "mdi:clock-outline" });
this._render();
return;
}
if (act === "tp-remove") {
this._syncTimePeriodInputs();
this._timePeriodsDraft.splice(Number(t.dataset.idx), 1);
this._render();
return;
}
if (act === "tp-reset") {
this._timePeriodsDraft = this._defaultTimePeriods();
this._render();
return;
}
// Vacation / pause-mode editor
if (act === "vac-add") {
this._syncVacationInputs();
this._vacationDraft.push({ id: "", name: "", start: "", end: "" });
this._render();
return;
}
if (act === "vac-remove") {
this._syncVacationInputs();
this._vacationDraft.splice(Number(t.dataset.idx), 1);
this._render();
return;
}
// Activity / approvals
if (act === "approve-all-chores") { this._doApproveAll(); return; }
if (act === "approve-chore") { this._doApprove("chore", t.dataset.id); return; }
if (act === "reject-chore") { this._doReject("chore", t.dataset.id); return; }
if (act === "approve-reward") { this._doApprove("reward", t.dataset.id); return; }
if (act === "reject-reward") { this._doReject("reward", t.dataset.id); return; }
// Filter clear
if (act === "clear-filter") { this._filter = ""; this._render(); return; }
}
_onDblClick(e) {
const t = e.target.closest("[data-rename]");
if (!t) return;
this._startInlineRename(t.dataset.rename, t.dataset.id);
}
_onFocusIn(e) {
if (e.target.classList?.contains("tm-ep-input")) {
e.target.value = "";
this._openEntityDropdown(e.target);
}
}
_onInput(e) {
const t = e.target;
if (!t.dataset) return;
if (t.classList?.contains("tm-ep-input")) {
this._openEntityDropdown(t);
return;
}
// Filter input
if (t.dataset.filter === "true") {
this._filter = t.value || "";
// Re-render only the visible tab body — but full re-render is fine for now.
this._render();
// Restore focus to the filter input
const f = this.querySelector("[data-filter='true']");
if (f) { f.focus(); f.setSelectionRange(this._filter.length, this._filter.length); }
return;
}
// Inline rename input
if (t.dataset.field === "_inlineRename") {
this._inlineRename.value = t.value;
return;
}
// Time-of-day period editor field edits
const tpField = t.dataset?.tpField;
const tpIdx = t.dataset?.tpIdx;
if (tpField && tpIdx != null && this._timePeriodsDraft?.[Number(tpIdx)]) {
this._timePeriodsDraft[Number(tpIdx)][tpField] = t.value;
return;
}
// Vacation editor field edits
const vacField = t.dataset?.vacField;
const vacIdx = t.dataset?.vacIdx;
if (vacField && vacIdx != null && this._vacationDraft?.[Number(vacIdx)]) {
this._vacationDraft[Number(vacIdx)][vacField] = t.value;
return;
}
// Template preview chore field edits
const tplField = t.dataset?.tplField;
const tplIdx = t.dataset?.tplIdx;
if (tplField && tplIdx != null && this._templateChores[Number(tplIdx)]) {
let value = t.value;
if (tplField === "points" || tplField === "daily_limit") value = Math.max(0, parseInt(value) || 0);
if (tplField === "requires_approval") value = value === "true";
this._templateChores[Number(tplIdx)][tplField] = value;
return;
}
// Template dialog chore field edits
const dField = t.dataset?.tplDialogField;
const dIdx = t.dataset?.tplDialogIdx;
if (dField && dIdx != null && this._dialog?.data?.chores?.[Number(dIdx)]) {
let value = t.value;
if (dField === "points") value = Math.max(0, parseInt(value) || 0);
this._dialog.data.chores[Number(dIdx)][dField] = value;
return;
}
// Badge criterion field edits
const bcField = t.dataset?.badgeCriterionField;
const bcIdx = t.dataset?.badgeCriterionIdx;
if (bcField && bcIdx != null && this._dialog?.data?.criteria?.[Number(bcIdx)]) {
const val = (t.type === "number") ? (t.value === "" ? 1 : Number(t.value)) : t.value;
this._dialog.data.criteria[Number(bcIdx)][bcField] = val;
if (bcField === "metric") this._render(); // re-render to show/hide value input for bool metrics
return;
}
// Scheduled-change draft (#675) lives beside the dialog data, not in it —
// it is a separate record, not a property of the chore being edited.
if (this._dialog && t.dataset.field && t.dataset.field.startsWith("_sched_")) {
const key = t.dataset.field.slice("_sched_".length);
const draft = this._schedDraft();
draft[key] = t.value;
// Switching field changes which value control is shown, and any list
// selection from the previous field no longer applies.
if (key === "field") { draft.value = ""; draft.value_list = []; this._render(); }
return;
}
// Dialog field
if (this._dialog && t.dataset.field) {
const field = t.dataset.field;
// ha-switch / checkbox elements fire `input` on toggle in newer HA; their
// `value` is the constant string "on", so read `.checked` instead — otherwise
// a switch can never be set false (it always stores a truthy string). Mirrors _onChange.
let value;
if (t.type === "checkbox" || t.tagName === "HA-SWITCH") value = t.checked;
else value = (t.type === "number") ? (t.value === "" ? null : Number(t.value)) : t.value;
const arrMatch = field.match(/^(\w+)\[(\d+)\]\.(\w+)$/);
if (arrMatch) {
const [, arr, idx, prop] = arrMatch;
if (this._dialog.data[arr] && this._dialog.data[arr][Number(idx)] !== undefined) {
this._dialog.data[arr][Number(idx)][prop] = value;
}
} else {
this._dialog.data[field] = value;
}
return;
}
}
_onChange(e) {
const t = e.target;
if (!t.dataset) return;
if (t.dataset.act === "bulk-toggle-row") {
const id = t.dataset.id;
if (t.checked) this._bulkSel.add(id); else this._bulkSel.delete(id);
this._render();
return;
}
if (t.dataset.role === "config-import-file") {
const file = t.files && t.files[0];
t.value = ""; // allow re-selecting the same file later
if (file) this._doImportConfig(file);
return;
}
if (t.dataset.local === "show-ids") {
this._showIds = t.checked;
localStorage.setItem("taskmate-show-ids", this._showIds);
this._render();
return;
}
// Time-of-day period editor (time inputs fire change on blur)
if (t.dataset?.tpField && t.dataset.tpIdx != null && this._timePeriodsDraft?.[Number(t.dataset.tpIdx)]) {
this._timePeriodsDraft[Number(t.dataset.tpIdx)][t.dataset.tpField] = t.value;
return;
}
// Vacation editor (date inputs fire change on blur)
if (t.dataset?.vacField && t.dataset.vacIdx != null && this._vacationDraft?.[Number(t.dataset.vacIdx)]) {
this._vacationDraft[Number(t.dataset.vacIdx)][t.dataset.vacField] = t.value;
return;
}
// Notifications tab change-driven actions
if (t.dataset.act === "notif-set-route-time") {
this._notifSetRoute(t.dataset.typeId, t.dataset.recipientId, true, t.value || null);
return;
}
if (t.dataset.act === "notif-set-child-notify") {
this._notifSetChildNotify(t.dataset.childId, t.value);
return;
}
if (t.dataset.act === "notif-set-child-quiet") {
const row = t.closest("[data-quiet-row]");
const startEl = row && row.querySelector('[data-quiet-bound="start"]');
const endEl = row && row.querySelector('[data-quiet-bound="end"]');
this._notifSetChildQuiet(t.dataset.childId, (startEl && startEl.value) || "", (endEl && endEl.value) || "");
return;
}
if (t.dataset.act === "notif-set-parent-notify") {
const p = (this._notifState && this._notifState.recipients && this._notifState.recipients.parents || []).find(x => x.id === t.dataset.parentId);
if (p) this._notifUpsertParent({ ...p, notify_service: t.value });
return;
}
if (t.dataset.act === "notif-rename-parent") {
const p = (this._notifState && this._notifState.recipients && this._notifState.recipients.parents || []).find(x => x.id === t.dataset.parentId);
if (p) this._notifUpsertParent({ ...p, name: t.value });
return;
}
if (t.dataset.act === "notif-set-streak-cutoff") {
this._notifSetStreakCutoff(t.value);
return;
}
if (t.dataset.act === "notif-set-escalation") {
this._notifSetEscalation(t.dataset.escField, t.value);
return;
}
if (t.dataset.act === "notif-update-custom") {
this._notifUpdateCustomField(t.dataset.customId, t.dataset.field, t.value);
return;
}
// Badge criterion select changes
const bcField2 = t.dataset?.badgeCriterionField;
const bcIdx2 = t.dataset?.badgeCriterionIdx;
if (bcField2 && bcIdx2 != null && this._dialog?.data?.criteria?.[Number(bcIdx2)]) {
this._dialog.data.criteria[Number(bcIdx2)][bcField2] = t.value;
if (bcField2 === "metric") this._render();
return;
}
// Template preview chore field changes (selects)
const tplField = t.dataset?.tplField;
const tplIdx = t.dataset?.tplIdx;
if (tplField && tplIdx != null && this._templateChores[Number(tplIdx)]) {
let value = t.value;
if (tplField === "requires_approval") value = value === "true";
this._templateChores[Number(tplIdx)][tplField] = value;
return;
}
// Template dialog chore field changes (selects)
const dField = t.dataset?.tplDialogField;
const dIdx = t.dataset?.tplDialogIdx;
if (dField && dIdx != null && this._dialog?.data?.chores?.[Number(dIdx)]) {
this._dialog.data.chores[Number(dIdx)][dField] = t.value;
return;
}
if (!this._dialog) return;
if (!t.dataset.field) return;
let value;
if (t.type === "checkbox" || t.tagName === "HA-SWITCH") value = t.checked;
else if (t.type === "number") value = (t.value === "" ? null : Number(t.value));
else value = t.value;
this._dialog.data[t.dataset.field] = value;
if (t.dataset.rerender === "true") this._render();
}
_onValueChanged(e) {
const t = e.target;
// Period editor icon pickers live outside any dialog
if (t.dataset?.tpField && t.dataset.tpIdx != null && this._timePeriodsDraft?.[Number(t.dataset.tpIdx)]) {
const val = e.detail && "value" in e.detail ? e.detail.value : t.value;
this._timePeriodsDraft[Number(t.dataset.tpIdx)][t.dataset.tpField] = val == null ? "" : val;
return;
}
if (!this._dialog) return;
if (!t.dataset || !t.dataset.field) return;
const v = e.detail && "value" in e.detail ? e.detail.value : t.value;
this._dialog.data[t.dataset.field] = v == null ? "" : v;
}
_syncIconPickers() {
if (!this._dialog) return;
this.querySelectorAll("ha-icon-picker[data-field]").forEach(el => {
if (el.value != null) this._dialog.data[el.dataset.field] = el.value;
});
}
_onKeyDown(e) {
if (e.key === "Escape") {
if (this._rowMenuEl) { this._closeRowMenu(); return; }
if (this._inlineRename) { this._inlineRename = null; this._render(); return; }
if (this._dialog) { this._closeDialog(); return; }
}
if (e.key === "Enter" && this._inlineRename) {
e.preventDefault();
this._commitInlineRename();
}
}
// ---- Drag and drop (reorder dialog) ----------------------------------
_onDragStart(e) {
const t = e.target.closest("[data-drag-id]");
if (!t) return;
this._reorderDrag = { id: t.dataset.dragId };
t.classList.add("tm-dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", t.dataset.dragId);
}
_onDragOver(e) {
const t = e.target.closest("[data-drag-id]");
if (!t || !this._reorderDrag) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
_onDrop(e) {
const t = e.target.closest("[data-drag-id]");
if (!t || !this._reorderDrag || !this._dialog || this._dialog.kind !== "reorder") return;
e.preventDefault();
const fromId = this._reorderDrag.id;
const toId = t.dataset.dragId;
if (fromId === toId) { this._reorderDrag = null; this._render(); return; }
const order = [...(this._dialog.data.order || [])];
const fromIdx = order.indexOf(fromId);
const toIdx = order.indexOf(toId);
if (fromIdx < 0 || toIdx < 0) return;
order.splice(fromIdx, 1);
order.splice(toIdx, 0, fromId);
this._dialog.data.order = order;
this._reorderDrag = null;
this._render();
}
// Up/down buttons — the reliable cross-device path (native HTML5 drag does
// not fire on touchscreens, so mobile users reorder via these).
_moveInReorder(id, dir) {
if (!this._dialog || this._dialog.kind !== "reorder") return;
const order = [...(this._dialog.data.order || [])];
const from = order.indexOf(id);
if (from < 0) return;
const to = from + dir;
if (to < 0 || to >= order.length) return;
order.splice(from, 1);
order.splice(to, 0, id);
this._dialog.data.order = order;
this._render();
}
_toggleArrayField(field, value) {
if (!this._dialog || !this._dialog.data) return;
const arr = Array.isArray(this._dialog.data[field]) ? [...this._dialog.data[field]] : [];
const i = arr.indexOf(value);
if (i >= 0) arr.splice(i, 1); else arr.push(value);
this._dialog.data[field] = arr;
const body = this.querySelector(".tm-dialog-body");
const scrollY = body ? body.scrollTop : 0;
this._render();
const b = this.querySelector(".tm-dialog-body");
if (b) b.scrollTop = scrollY;
}
_confirmDelete(kind, id) {
const labels = { child: this._t("panel.entity_child"), chore: this._t("panel.entity_chore"), reward: this._t("panel.entity_reward"), penalty: this._t("panel.entity_penalty"), bonus: this._t("panel.entity_bonus"), group: this._t("panel.entity_group"), quest: this._t("panel.entity_quest"), challenge: this._t("panel.entity_challenge") };
const collectionKey = { child: "children", chore: "chores", reward: "rewards", penalty: "penalties", bonus: "bonuses", group: "task_groups", quest: "quests", challenge: "challenges" }[kind];
const item = (this._state[collectionKey] || []).find(x => x.id === id);
if (!item) return;
const extraWarn = kind === "child"
? `\n\n${this._t("panel.confirm_delete_child_extra")}`
: kind === "reward"
? `\n\n${this._t("panel.confirm_delete_reward_extra")}`
: kind === "chore"
? `\n\n${this._t("panel.confirm_delete_chore_extra")}`
: "";
if (!confirm(`${this._t("panel.confirm_delete_prompt", {kind: labels[kind].toLowerCase(), name: item.name})}${extraWarn}\n\n${this._t("panel.confirm_delete_suffix")}`)) return;
this._doRemove(kind, id);
}
async _doRemove(kind, id) {
const wsType = {
child: "taskmate/remove_child",
chore: "taskmate/remove_chore",
reward: "taskmate/remove_reward",
penalty: "taskmate/remove_penalty",
bonus: "taskmate/remove_bonus",
group: "taskmate/remove_task_group",
quest: "taskmate/delete_quest",
challenge: "taskmate/delete_challenge",
}[kind];
const idField = {
child: "child_id", chore: "chore_id", reward: "reward_id",
penalty: "penalty_id", bonus: "bonus_id", group: "group_id",
quest: "quest_id", challenge: "challenge_id",
}[kind];
const { ok, err } = await this._callWS({ type: wsType, [idField]: id });
if (!ok) { this._showToast("err", this._t("panel.toast_delete_failed", {error: err})); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_deleted"));
}
// ---- Parent complete --------------------------------------------------
async _doParentComplete(choreId) {
if (!confirm(this._t("panel.parent_complete_confirm"))) return;
const { ok, err } = await this._callWS({ type: "taskmate/parent_complete_chore", chore_id: choreId });
if (!ok) { this._showToast("err", this._t("panel.parent_complete_failed", {error: err})); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.parent_complete_success"));
}
// ---- Skip chore --------------------------------------------------------
async _doSkipChore(choreId) {
const s = this._state.settings || {};
if (s.skip_confirmation_enabled !== false && !confirm(this._t("panel.skip_chore_confirm"))) return;
const { ok, err } = await this._callService("skip_chore", { chore_id: choreId });
if (!ok) { this._showToast("err", this._t("panel.toast_skip_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_skip_done"));
}
async _doToggleChoreActive(choreId) {
const chore = (this._state.chores || []).find(c => c.id === choreId);
if (!chore) return;
const newEnabled = chore.enabled === false;
const { ok, err } = await this._callWS({ type: "taskmate/update_chore", chore_id: choreId, enabled: newEnabled });
if (!ok) { this._showToast("err", this._t("panel.toast_toggle_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", newEnabled ? this._t("panel.toast_chore_activated") : this._t("panel.toast_chore_deactivated"));
}
// ---- Approvals -------------------------------------------------------
async _doApprove(kind, id) {
const wsType = kind === "chore" ? "taskmate/approve_chore" : "taskmate/approve_reward";
const idField = kind === "chore" ? "completion_id" : "claim_id";
const { ok, err } = await this._callWS({ type: wsType, [idField]: id });
if (!ok) { this._showToast("err", this._t("panel.toast_approve_failed", {error: err})); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_approved"));
}
async _doApproveAll() {
const pending = this._state?.pending_completions || [];
if (pending.length === 0) return;
if (!confirm(this._t("panel.activity_approve_all_confirm", { count: pending.length }))) return;
const { ok, err } = await this._callWS({
type: "taskmate/approve_all_chores",
completion_ids: pending.map(c => c.id),
});
if (!ok) { this._showToast("err", this._t("panel.toast_approve_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.activity_approve_all_done", { count: pending.length }));
}
async _doReject(kind, id) {
const wsType = kind === "chore" ? "taskmate/reject_chore" : "taskmate/reject_reward";
const idField = kind === "chore" ? "completion_id" : "claim_id";
const { ok, err } = await this._callWS({ type: wsType, [idField]: id });
if (!ok) { this._showToast("err", this._t("panel.toast_reject_failed", {error: err})); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_rejected"));
}
// ---- Children --------------------------------------------------------
async _openChildDialog(id) {
await this._ensureHaUsers();
if (id) {
const c = (this._state.children || []).find(x => x.id === id);
if (!c) return;
this._openDialog({ kind: "child", mode: "edit", data: {
id: c.id, name: c.name || "", avatar: c.avatar || "mdi:account-circle",
availability_entity: c.availability_entity || "",
availability_inverted: !!c.availability_inverted,
unavailability_entity: c.unavailability_entity || "",
pause_streak_when_unavailable: !!c.pause_streak_when_unavailable,
linked_user_id: c.linked_user_id || "",
} });
} else {
this._openDialog({ kind: "child", mode: "add", data: {
name: "", avatar: "mdi:account-circle", availability_entity: "",
availability_inverted: false, unavailability_entity: "",
pause_streak_when_unavailable: false,
linked_user_id: "",
} });
}
}
async _ensureHaUsers() {
if (this._haUsers) return;
const { ok, res } = await this._callWS({ type: "taskmate/list_ha_users" });
this._haUsers = (ok && res && Array.isArray(res.users)) ? res.users : [];
}
async _doSaveChild() {
this._syncIconPickers();
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
const wasAdd = this._dialog.mode === "add";
const payload = wasAdd
? { type: "taskmate/add_child", name: d.name.trim(), avatar: d.avatar || "mdi:account-circle",
availability_entity: d.availability_entity || "", availability_inverted: !!d.availability_inverted,
unavailability_entity: d.unavailability_entity || "",
pause_streak_when_unavailable: !!d.pause_streak_when_unavailable, linked_user_id: d.linked_user_id || "" }
: { type: "taskmate/update_child", child_id: d.id, name: d.name.trim(), avatar: d.avatar || "mdi:account-circle",
availability_entity: d.availability_entity || "", availability_inverted: !!d.availability_inverted,
unavailability_entity: d.unavailability_entity || "",
pause_streak_when_unavailable: !!d.pause_streak_when_unavailable, linked_user_id: d.linked_user_id || "" };
const { ok, err } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", wasAdd ? this._t("panel.toast_child_added") : this._t("panel.toast_child_updated"));
}
// ---- Chore reorder ---------------------------------------------------
_openReorderDialog(child_id) {
const child = (this._state.children || []).find(c => c.id === child_id);
if (!child) return;
const childChores = (this._state.chores || []).filter(c =>
c.assignment_mode !== "unassigned" && ((c.assigned_to || []).length === 0 || (c.assigned_to || []).includes(child_id))
);
// Use the child's stored chore_order if present, else fall back to the chore list order.
const existingOrder = (child.chore_order || []).filter(id => childChores.find(c => c.id === id));
const missing = childChores.map(c => c.id).filter(id => !existingOrder.includes(id));
const order = [...existingOrder, ...missing];
this._openDialog({ kind: "reorder", mode: "edit", data: { child_id, order, name: child.name } });
}
async _doSaveChoreOrder() {
const d = this._dialog.data;
const { ok, err } = await this._callWS({
type: "taskmate/set_chore_order",
child_id: d.child_id,
chore_order: d.order || [],
});
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t("panel.toast_order_saved"));
}
// ---- Global chore reorder ---------------------------------------------
_openGlobalReorderDialog() {
const allChores = this._state.chores || [];
if (allChores.length < 2) return;
const existingOrder = (this._state.chore_display_order || []).filter(id => allChores.find(c => c.id === id));
const missing = allChores.map(c => c.id).filter(id => !existingOrder.includes(id));
const order = [...existingOrder, ...missing];
this._openDialog({ kind: "reorder", mode: "edit", data: { global: true, order, name: this._t("panel.tab_chores") } });
}
async _doSaveGlobalChoreOrder() {
const d = this._dialog.data;
const { ok, err } = await this._callWS({
type: "taskmate/set_global_chore_order",
chore_order: d.order || [],
});
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t("panel.toast_order_saved"));
}
// ---- Bulk add chores -------------------------------------------------
_openBulkAddDialog() {
this._openDialog({ kind: "bulk-chore", mode: "add", data: {
chore_names: "",
points: 10,
assigned_to: [],
requires_approval: true,
time_category: "anytime",
schedule_mode: "specific_days",
due_days: [],
daily_limit: 1,
completion_sound: "coin",
} });
}
async _doSaveBulkChores() {
const d = this._dialog.data;
const names = (d.chore_names || "").split(/[\n,]/).map(s => s.trim()).filter(Boolean);
if (names.length === 0) { this._showToast("err", this._t("panel.toast_enter_chore_name")); return; }
const { ok, err, res } = await this._callWS({
type: "taskmate/add_chores_bulk",
chore_names: names,
points: Number(d.points) || 10,
assigned_to: d.assigned_to || [],
depends_on: d.depends_on || [],
requires_approval: !!d.requires_approval,
time_category: d.time_category || "anytime",
schedule_mode: d.schedule_mode || "specific_days",
due_days: d.due_days || [],
daily_limit: Number(d.daily_limit) || 1,
completion_sound: d.completion_sound || "coin",
icon: d.icon || "",
});
if (!ok) { this._showToast("err", this._t("panel.toast_bulk_add_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t("panel.toast_bulk_added", {count: (res && res.count) || names.length}));
}
// ---- Inline rename ---------------------------------------------------
_startInlineRename(kind, id) {
if (kind !== "chore") return;
const c = (this._state.chores || []).find(x => x.id === id);
if (!c) return;
this._inlineRename = { kind: "chore", id, value: c.name };
this._render();
const inp = this.querySelector("[data-field='_inlineRename']");
if (inp) { inp.focus(); inp.select(); }
}
async _commitInlineRename() {
if (!this._inlineRename) return;
const { kind, id, value } = this._inlineRename;
const newName = (value || "").trim();
if (!newName) { this._inlineRename = null; this._render(); return; }
if (kind === "chore") {
const c = (this._state.chores || []).find(x => x.id === id);
if (c && c.name === newName) { this._inlineRename = null; this._render(); return; }
const { ok, err } = await this._callWS({ type: "taskmate/update_chore", chore_id: id, name: newName });
this._inlineRename = null;
if (!ok) { this._showToast("err", this._t("panel.toast_rename_failed", {error: err})); this._render(); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_renamed"));
}
}
// ---- Chores ----------------------------------------------------------
async _doBulk(action, assigned_to) {
const ids = [...this._bulkSel];
if (!ids.length) return;
if (action === "delete" && !confirm(this._t("panel.bulk_delete_confirm", { count: ids.length }))) return;
const payload = { type: "taskmate/bulk_chore_action", action, chore_ids: ids };
if (action === "reassign") payload.assigned_to = assigned_to || [];
const { ok, err, res } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
this._bulkSel.clear();
this._bulkMode = false;
await this._fetchState();
this._showToast("ok", this._t("panel.bulk_done_toast", { count: (res && res.count) || ids.length }));
}
async _doCloneChore(id) {
if (!id) return;
const { ok, err } = await this._callWS({ type: "taskmate/clone_chore", chore_id: id });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_chore_cloned"));
}
_openChoreDialog(id) {
const blank = {
name: "", description: "", points: 10,
task_type: "standard",
timed_rate_points: 10, timed_rate_minutes: 5, timed_max_daily_minutes: 0,
assigned_to: [], requires_approval: true,
time_category: "anytime", completion_sound: "coin", daily_limit: 1,
difficulty: "medium",
claim_allowance_minutes: 0,
schedule_mode: "specific_days",
due_days: [], recurrence: "weekly", recurrence_day: "", recurrence_start: "",
first_occurrence_mode: "available_immediately",
assignment_mode: "everyone", assignment_rotation_anchor: "",
manual_start_child_id: "",
require_availability: false,
icon: "",
visibility_entity: "", visibility_state: "on", visibility_operator: "none",
weather_entity: "", weather_block_conditions: [],
weather_temp_min: "", weather_temp_max: "", weather_wind_max: "",
enabled: true,
expires_on: "",
due_time: "", early_bonus: 0, late_penalty: 0,
mandatory: false, mandatory_penalty_points: 0,
require_photo: false,
publish_calendar_entities: [],
depends_on: [],
bonus_subtasks: [],
};
if (id) {
const c = (this._state.chores || []).find(x => x.id === id);
if (!c) return;
this._openDialog({ kind: "chore", mode: "edit", data: {
...blank, ...c,
assigned_to: [...(c.assigned_to || [])],
due_days: [...(c.due_days || [])],
publish_calendar_entities: [...(c.publish_calendar_entities || [])],
depends_on: [...(c.depends_on || [])],
bonus_subtasks: (c.bonus_subtasks || []).map(b => ({...b})),
visibility_operator: c.visibility_operator || "none",
weather_block_conditions: [...(c.weather_block_conditions || [])],
// null/undefined limits render as an empty input, not "null"
weather_temp_min: c.weather_temp_min ?? "",
weather_temp_max: c.weather_temp_max ?? "",
weather_wind_max: c.weather_wind_max ?? "",
manual_start_child_id: "",
} });
} else {
this._openDialog({ kind: "chore", mode: "add", data: blank });
}
}
async _doSaveChore() {
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
const wasAdd = this._dialog.mode === "add";
const base = {
name: d.name.trim(),
description: d.description || "",
points: Number(d.points) || 0,
assigned_to: d.assigned_to || [],
requires_approval: !!d.requires_approval,
time_category: d.time_category || "anytime",
claim_allowance_minutes: Math.max(0, Number(d.claim_allowance_minutes) || 0),
completion_sound: d.completion_sound || "coin",
difficulty: d.difficulty || "medium",
daily_limit: Number(d.daily_limit) || 1,
schedule_mode: d.schedule_mode || "specific_days",
due_days: d.due_days || [],
recurrence: d.recurrence || "weekly",
recurrence_day: d.recurrence_day || "",
recurrence_start: d.recurrence_start || "",
first_occurrence_mode: d.first_occurrence_mode || "available_immediately",
assignment_mode: d.assignment_mode || "everyone",
assignment_rotation_anchor: d.assignment_rotation_anchor || "",
require_availability: !!d.require_availability,
visibility_entity: d.visibility_entity || "",
visibility_state: d.visibility_state || "on",
visibility_operator: d.visibility_operator || "none",
weather_entity: d.weather_entity || "",
weather_block_conditions: d.weather_block_conditions || [],
weather_temp_min: this._optNum(d.weather_temp_min),
weather_temp_max: this._optNum(d.weather_temp_max),
weather_wind_max: this._optNum(d.weather_wind_max),
enabled: d.enabled !== false,
expires_on: (d.expires_on || "").trim(),
due_time: (d.due_time || "").trim(),
early_bonus: Math.max(0, Number(d.early_bonus) || 0),
late_penalty: Math.max(0, Number(d.late_penalty) || 0),
mandatory: !!d.mandatory,
mandatory_penalty_points: Math.max(0, Number(d.mandatory_penalty_points) || 0),
require_photo: !!d.require_photo,
publish_calendar_entities: d.publish_calendar_entities || [],
bonus_subtasks: (d.bonus_subtasks || []).filter(b => b.name && b.name.trim()).map(b => ({
name: b.name.trim(), points: Number(b.points) || 5,
description: b.description || "", ...(b.id ? {id: b.id} : {}),
})),
task_type: d.task_type || "standard",
timed_rate_points: Number(d.timed_rate_points) || 10,
timed_rate_minutes: Math.max(1, Number(d.timed_rate_minutes) || 5),
timed_max_daily_minutes: Math.max(0, Number(d.timed_max_daily_minutes) || 0),
manual_start_child_id: d.manual_start_child_id || null,
};
const payload = wasAdd
? { type: "taskmate/add_chore", ...base }
: { type: "taskmate/update_chore", chore_id: d.id, ...base };
const { ok, err } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", wasAdd ? this._t("panel.toast_chore_added") : this._t("panel.toast_chore_updated"));
}
_addBonusSubtask() {
if (!this._dialog) return;
const d = this._dialog.data;
d.bonus_subtasks = d.bonus_subtasks || [];
d.bonus_subtasks.push({ name: "", points: 5, description: "" });
this._render();
}
_removeBonusSubtask(idx) {
if (!this._dialog) return;
const d = this._dialog.data;
if (d.bonus_subtasks && d.bonus_subtasks[idx] !== undefined) {
d.bonus_subtasks.splice(idx, 1);
this._render();
}
}
// ---- Rewards ---------------------------------------------------------
_openRewardDialog(id) {
const blank = { name: "", description: "", cost: 50, icon: "mdi:gift",
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 };
if (id) {
const r = (this._state.rewards || []).find(x => x.id === id);
if (!r) return;
this._openDialog({ kind: "reward", mode: "edit", data: {
...blank, ...r,
assigned_to: [...(r.assigned_to || [])],
quantity_str: r.quantity == null ? "" : String(r.quantity),
expires_at: r.expires_at || "",
} });
} else {
this._openDialog({ kind: "reward", mode: "add", data: blank });
}
}
async _doSaveReward() {
this._syncIconPickers();
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
const wasAdd = this._dialog.mode === "add";
const qty = (d.quantity_str === "" || d.quantity_str == null) ? null : Math.max(0, Number(d.quantity_str));
const base = {
name: d.name.trim(),
cost: Number(d.cost) || 0,
description: d.description || "",
icon: d.icon || "mdi:gift",
assigned_to: d.assigned_to || [],
is_jackpot: !!d.is_jackpot,
pool_enabled: !!d.pool_enabled,
quantity: qty,
expires_at: (d.expires_at || "").trim() || null,
restock_enabled: !!d.restock_enabled,
unlock_entity: d.unlock_entity || "",
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",
};
const payload = wasAdd ? { type: "taskmate/add_reward", ...base } : { type: "taskmate/update_reward", reward_id: d.id, ...base };
const { ok, err } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", wasAdd ? this._t("panel.toast_reward_added") : this._t("panel.toast_reward_updated"));
}
// ---- Quests (chore chains) -------------------------------------------
_openQuestDialog(id) {
const blank = { name: "", description: "", icon: "mdi:map-marker-path",
steps: [], bonus_points: 25, assigned_to: [], repeatable: false, active: true };
if (id) {
const q = (this._state.quests || []).find(x => x.id === id);
if (!q) return;
this._openDialog({ kind: "quest", mode: "edit", data: {
...blank, ...q,
steps: [...(q.steps || [])],
assigned_to: [...(q.assigned_to || [])],
} });
} else {
this._openDialog({ kind: "quest", mode: "add", data: blank });
}
}
_moveQuestStep(idx, dir) {
if (!this._dialog || !this._dialog.data) return;
const arr = [...(this._dialog.data.steps || [])];
const j = dir === "up" ? idx - 1 : idx + 1;
if (idx < 0 || idx >= arr.length || j < 0 || j >= arr.length) return;
[arr[idx], arr[j]] = [arr[j], arr[idx]];
this._dialog.data.steps = arr;
this._render();
}
async _doSaveQuest() {
this._syncIconPickers();
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
if (!d.steps || d.steps.length === 0) { this._showToast("err", this._t("panel.quest_need_step")); return; }
const wasAdd = this._dialog.mode === "add";
const base = {
name: d.name.trim(),
description: d.description || "",
icon: d.icon || "mdi:map-marker-path",
steps: [...d.steps],
bonus_points: Math.max(0, Number(d.bonus_points) || 0),
assigned_to: d.assigned_to || [],
repeatable: !!d.repeatable,
active: d.active !== false,
};
const payload = wasAdd ? { type: "taskmate/create_quest", ...base } : { type: "taskmate/update_quest", quest_id: d.id, ...base };
const { ok, err } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", wasAdd ? this._t("panel.toast_quest_added") : this._t("panel.toast_quest_updated"));
}
// ---- Challenges (daily / weekly) -------------------------------------
_openChallengeDialog(id) {
const blank = { name: "", description: "", icon: "mdi:trophy-outline",
scope: "daily", metric: "chores", target: 3, bonus_points: 15,
assigned_to: [], active: true };
if (id) {
const c = (this._state.challenges || []).find(x => x.id === id);
if (!c) return;
this._openDialog({ kind: "challenge", mode: "edit", data: {
...blank, ...c, assigned_to: [...(c.assigned_to || [])],
} });
} else {
this._openDialog({ kind: "challenge", mode: "add", data: blank });
}
}
async _doSaveChallenge() {
this._syncIconPickers();
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
const target = Math.max(1, Number(d.target) || 0);
const wasAdd = this._dialog.mode === "add";
const base = {
name: d.name.trim(),
description: d.description || "",
icon: d.icon || "mdi:trophy-outline",
scope: d.scope || "daily",
metric: d.metric || "chores",
target,
bonus_points: Math.max(0, Number(d.bonus_points) || 0),
assigned_to: d.assigned_to || [],
active: d.active !== false,
};
const payload = wasAdd ? { type: "taskmate/create_challenge", ...base } : { type: "taskmate/update_challenge", challenge_id: d.id, ...base };
const { ok, err } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", wasAdd ? this._t("panel.toast_challenge_added") : this._t("panel.toast_challenge_updated"));
}
_renderChallengeDialog() {
const d = this._dialog.data;
const children = this._state.children || [];
const pointsName = this._state.settings.points_name || this._t("common.points");
return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_challenge") : this._t("panel.dialog_edit_challenge"),
[
this._field(this._t("panel.chore_name_label"), "name", d.name, "text"),
this._field(this._t("panel.chore_description_label"), "description", d.description, "text"),
`
${this._select(this._t("panel.challenge_scope_label"), "scope", d.scope || "daily", [
{ v: "daily", l: this._t("panel.challenge_scope_daily") },
{ v: "weekly", l: this._t("panel.challenge_scope_weekly") },
])}
${this._select(this._t("panel.challenge_metric_label"), "metric", d.metric || "chores", [
{ v: "chores", l: this._t("panel.challenge_metric_chores") },
{ v: "points", l: this._t("panel.challenge_metric_points", {points_name: pointsName}) },
])}
`,
`
${this._field(this._t("panel.challenge_target_label"), "target", d.target, "number", this._t("panel.challenge_target_hint"))}
${this._field(this._t("panel.quest_bonus_label", {points_name: pointsName}), "bonus_points", d.bonus_points, "number")}
`,
this._iconPickerField(this._t("panel.reward_icon_label"), "icon", d.icon),
children.length > 0 ? `
${this._t("panel.quest_assigned_label")}
${children.map(c => `
${this._esc(c.name)}
`).join("")}
${this._t("panel.quest_assigned_hint")}
` : "",
this._switch(this._t("panel.quest_active_label"), "active", d.active !== false,
this._t("panel.quest_active_hint")),
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save")} `
);
}
// ---- Avatar unlockables ----------------------------------------------
_openAvatarCatalogDialog() {
const cat = (this._state.avatar_catalog || []).map(a => ({
id: a.id || a.icon, label: a.label || "", icon: a.icon || "mdi:account-circle",
unlock_type: a.unlock_type || "free", unlock_value: Number(a.unlock_value) || 0,
}));
this._openDialog({ kind: "avatar-catalog", mode: "edit", data: { catalog: cat } });
}
_avatarAddRow() {
if (!this._dialog || !this._dialog.data) return;
this._dialog.data.catalog = [...(this._dialog.data.catalog || []), {
id: "", label: "", icon: "mdi:account-circle", unlock_type: "free", unlock_value: 0,
}];
this._render();
}
_avatarDelRow(idx) {
if (!this._dialog || !this._dialog.data) return;
const arr = [...(this._dialog.data.catalog || [])];
if (idx < 0 || idx >= arr.length) return;
arr.splice(idx, 1);
this._dialog.data.catalog = arr;
this._render();
}
async _doSaveAvatarCatalog() {
this._syncIconPickers();
const cat = (this._dialog.data.catalog || [])
.filter(a => (a.icon || "").trim())
.map(a => ({
id: (a.id || a.icon).trim(),
label: (a.label || "").trim(),
icon: a.icon.trim(),
unlock_type: a.unlock_type || "free",
unlock_value: Math.max(0, Number(a.unlock_value) || 0),
}));
const { ok, err } = await this._callWS({ type: "taskmate/update_avatar_catalog", catalog: cat });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t("panel.toast_avatars_saved"));
}
// ---- Penalties / Bonuses ---------------------------------------------
_openPenBonDialog(kind, id) {
const blank = { name: "", description: "", points: 10,
icon: kind === "penalty" ? "mdi:alert-circle-outline" : "mdi:star-circle-outline",
assigned_to: [] };
if (id) {
const item = ((kind === "penalty" ? this._state.penalties : this._state.bonuses) || []).find(x => x.id === id);
if (!item) return;
this._openDialog({ kind, mode: "edit", data: { ...blank, ...item, assigned_to: [...(item.assigned_to || [])] } });
} else {
this._openDialog({ kind, mode: "add", data: blank });
}
}
async _doSavePenBon(kind) {
this._syncIconPickers();
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
if (!d.points || d.points < 1) { this._showToast("err", this._t("panel.toast_points_required")); return; }
const wasAdd = this._dialog.mode === "add";
const wsBase = kind === "penalty" ? "penalty" : "bonus";
const idField = `${wsBase}_id`;
const wsType = wasAdd ? `taskmate/add_${wsBase}` : `taskmate/update_${wsBase}`;
const base = {
name: d.name.trim(),
points: Number(d.points),
description: d.description || "",
icon: d.icon || (kind === "penalty" ? "mdi:alert-circle-outline" : "mdi:star-circle-outline"),
assigned_to: d.assigned_to || [],
};
const payload = wasAdd ? { type: wsType, ...base } : { type: wsType, [idField]: d.id, ...base };
const { ok, err } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t(`panel.toast_${wasAdd ? "added" : "updated"}_${kind}`));
}
_openApplyDialog(kind, id) {
const item = ((kind === "penalty" ? this._state.penalties : this._state.bonuses) || []).find(x => x.id === id);
if (!item) return;
this._openDialog({ kind: `apply-${kind}`, mode: "apply", data: { id, item } });
}
async _doApplyPenBon(kind, id, child_id) {
const wsType = kind === "penalty" ? "taskmate/apply_penalty" : "taskmate/apply_bonus";
const idField = `${kind}_id`;
const { ok, err } = await this._callWS({ type: wsType, [idField]: id, child_id });
if (!ok) { this._showToast("err", this._t("panel.toast_apply_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t(kind === "penalty" ? "panel.toast_penalty_applied" : "panel.toast_bonus_applied"));
}
// ---- Task groups -----------------------------------------------------
_openGroupDialog(id) {
const blank = { name: "", policy: "sticky", chore_ids: [] };
if (id) {
const g = (this._state.task_groups || []).find(x => x.id === id);
if (!g) return;
this._openDialog({ kind: "group", mode: "edit", data: { ...blank, ...g, chore_ids: [...(g.chore_ids || [])] } });
} else {
this._openDialog({ kind: "group", mode: "add", data: blank });
}
}
async _doSaveGroup() {
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
const wasAdd = this._dialog.mode === "add";
const base = { name: d.name.trim(), policy: d.policy || "sticky", chore_ids: d.chore_ids || [] };
const payload = wasAdd ? { type: "taskmate/add_task_group", ...base } : { type: "taskmate/update_task_group", group_id: d.id, ...base };
const { ok, err } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", wasAdd ? this._t("panel.toast_group_added") : this._t("panel.toast_group_updated"));
}
// ---- Time-of-day periods ----------------------------------------------
_defaultTimePeriods() {
return [
{ id: "morning", label: "", start: "06:00", end: "12:00", icon: "mdi:weather-sunny" },
{ id: "afternoon", label: "", start: "12:00", end: "17:00", icon: "mdi:white-balance-sunny" },
{ id: "evening", label: "", start: "17:00", end: "21:00", icon: "mdi:weather-sunset" },
{ id: "night", label: "", start: "21:00", end: "23:59", icon: "mdi:weather-night" },
];
}
// Saved periods, falling back to the legacy flat keys / built-in defaults
// for installs that haven't saved through the new editor yet.
_effectiveTimePeriods() {
const s = (this._state && this._state.settings) || {};
if (Array.isArray(s.time_periods) && s.time_periods.length) {
return s.time_periods.map(p => ({ ...p }));
}
return this._defaultTimePeriods().map(p => ({
...p,
start: s[`time_${p.id}_start`] || p.start,
end: s[`time_${p.id}_end`] || p.end,
}));
}
_isBuiltinPeriod(id) {
return ["morning", "afternoon", "evening", "night"].includes(id);
}
_timePeriodLabel(p) {
if (p.label) return p.label;
return this._isBuiltinPeriod(p.id) ? this._t(`panel.time_${p.id}`) : (p.id || "");
}
_timeCategoryOptions() {
const opts = [{ v: "anytime", l: this._t("panel.time_anytime") }];
for (const p of this._effectiveTimePeriods()) opts.push({ v: p.id, l: this._timePeriodLabel(p) });
return opts;
}
_timeCategoryLabel(cat) {
const c = cat || "anytime";
if (c === "anytime") return this._t("panel.time_anytime");
const p = this._effectiveTimePeriods().find(x => x.id === c);
return p ? this._timePeriodLabel(p) : c;
}
_renderTimePeriodsSection() {
if (!this._timePeriodsDraft) this._timePeriodsDraft = this._effectiveTimePeriods();
const inUse = {};
(this._state.chores || []).forEach(c => {
const tc = c.time_category;
if (tc && tc !== "anytime") inUse[tc] = (inUse[tc] || 0) + 1;
});
const rows = this._timePeriodsDraft.map((p, i) => {
const used = p.id ? inUse[p.id] || 0 : 0;
const placeholder = this._isBuiltinPeriod(p.id) ? this._t(`panel.time_${p.id}`) : this._t("panel.tp_label_placeholder");
return `
`;
}).join("");
return `
${this._t("panel.settings_time_boundaries_title")} ${this._t("panel.settings_time_boundaries_hint")}
${rows}
${this._t("panel.time_anytime")}${this._t("panel.tp_anytime_hint")}
${this._t("panel.tp_add_period")}
${this._t("panel.tp_reset_defaults")}
`;
}
// Pull current editor input values into the draft. Inputs also stream into
// the draft via input/value-changed events; this is the safety net before
// any re-render or save.
_syncTimePeriodInputs() {
if (!this._timePeriodsDraft) return;
this.querySelectorAll("[data-tp-field]").forEach(el => {
const idx = Number(el.dataset.tpIdx);
const field = el.dataset.tpField;
if (this._timePeriodsDraft[idx] && el.value != null) {
this._timePeriodsDraft[idx][field] = el.value;
}
});
}
_renderVacationSection() {
if (!this._vacationDraft) {
const saved = (this._state?.settings?.vacation_periods) || [];
this._vacationDraft = saved.map(p => ({ id: p.id || "", name: p.name || "", start: p.start || "", end: p.end || "" }));
}
const rows = this._vacationDraft.map((p, i) => `
`).join("");
const calValue = this._settingsEntityDraft?.vacation_calendar
?? this._state?.settings?.vacation_calendar ?? "";
return `
${this._t("panel.settings_vacation_title")} ${this._t("panel.settings_vacation_hint")}
${rows || `
${this._t("panel.vacation_none")}
`}
${this._t("panel.vacation_add")}
${this._entityPickerField(this._t("panel.vacation_calendar_label"), "vacation_calendar", calValue, ["calendar"], this._t("panel.vacation_calendar_hint"))}
`;
}
_syncVacationInputs() {
if (!this._vacationDraft) return;
this.querySelectorAll("[data-vac-field]").forEach(el => {
const idx = Number(el.dataset.vacIdx);
const field = el.dataset.vacField;
if (this._vacationDraft[idx] && el.value != null) this._vacationDraft[idx][field] = el.value;
});
}
_validateTimePeriodsDraft() {
const periods = [];
for (const p of this._timePeriodsDraft) {
const label = (p.label || "").trim();
const id = (p.id || "").trim();
const name = label || (this._isBuiltinPeriod(id) ? this._t(`panel.time_${id}`) : id);
if (!label && !this._isBuiltinPeriod(id)) return { error: this._t("panel.tp_err_label_required") };
if (!/^\d{2}:\d{2}$/.test(p.start || "") || !/^\d{2}:\d{2}$/.test(p.end || "")) {
return { error: this._t("panel.tp_err_time_required", { name }) };
}
if (p.start >= p.end) return { error: this._t("panel.tp_err_order", { name }) };
periods.push({ id, label, start: p.start, end: p.end, icon: p.icon || "" });
}
if (!periods.length) return { error: this._t("panel.tp_err_empty") };
periods.sort((a, b) => (a.start < b.start ? -1 : 1));
for (let i = 1; i < periods.length; i++) {
if (periods[i].start < periods[i - 1].end) {
const nameOf = (q) => q.label || (this._isBuiltinPeriod(q.id) ? this._t(`panel.time_${q.id}`) : q.id);
return { error: this._t("panel.tp_err_overlap", { a: nameOf(periods[i]), b: nameOf(periods[i - 1]) }) };
}
}
return { periods };
}
// ---- Backup / restore ------------------------------------------------
async _doExportConfig() {
const { ok, err, res } = await this._callWS({ type: "taskmate/config/export" });
if (!ok || !res) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
try {
const blob = new Blob([JSON.stringify(res, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "taskmate-backup.json";
a.click();
URL.revokeObjectURL(url);
this._showToast("ok", this._t("panel.backup_exported"));
} catch (e) {
this._showToast("err", String(e));
}
}
async _icsShow() {
const { ok, err, res } = await this._callWS({ type: "taskmate/calendar/get_ics_url" });
if (!ok || !res) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
this._icsUrl = res.url;
this._render();
}
async _icsRegenerate() {
const { ok, err, res } = await this._callWS({ type: "taskmate/calendar/regenerate_ics_token" });
if (!ok || !res) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
this._icsUrl = res.url;
this._render();
this._showToast("ok", this._t("panel.ics_regenerated"));
}
async _icsCopy() {
const input = this.querySelector("[data-role='ics-url']");
if (!input) return;
try {
await navigator.clipboard.writeText(input.value);
this._showToast("ok", this._t("panel.ics_copied"));
} catch (e) {
input.select();
this._showToast("err", String(e));
}
}
async _doImportConfig(file) {
let payload;
try {
payload = JSON.parse(await file.text());
} catch (e) {
this._showToast("err", this._t("panel.backup_import_bad_file"));
return;
}
if (!payload || typeof payload !== "object" || !payload.data) {
this._showToast("err", this._t("panel.backup_import_bad_file"));
return;
}
if (!confirm(this._t("panel.backup_import_confirm"))) return;
const { ok, err } = await this._callWS({ type: "taskmate/config/import", payload });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.backup_imported"));
}
// ---- Settings --------------------------------------------------------
async _doSaveSettings() {
const root = this.querySelector(".tm-settings");
if (!root) return;
const fields = root.querySelectorAll("[data-setting]");
const payload = { type: "taskmate/update_settings" };
fields.forEach(el => {
const name = el.dataset.setting;
const declaredType = el.getAttribute("type") || el.type;
let v;
if (declaredType === "checkbox" || el.tagName === "HA-SWITCH") v = el.checked;
else if (declaredType === "number") v = el.value === "" ? undefined : Number(el.value);
else v = el.value;
if (v !== undefined) payload[name] = v;
});
root.querySelectorAll("ha-icon-picker[data-setting]").forEach(el => {
payload[el.dataset.setting] = el.value || "";
});
// Non-admin parent role (#661): collect the ticked HA users.
const parentBoxes = root.querySelectorAll("input[type=checkbox][data-parent-user]");
if (parentBoxes.length) {
payload.parent_user_ids = Array.from(parentBoxes)
.filter(el => el.checked)
.map(el => el.dataset.parentUser);
}
if (this._settingsEntityDraft) {
Object.assign(payload, this._settingsEntityDraft);
}
if (this._timePeriodsDraft) {
this._syncTimePeriodInputs();
const { periods, error } = this._validateTimePeriodsDraft();
if (error) { this._showToast("err", error); return; }
payload.time_periods = periods;
}
if (this._vacationDraft) {
this._syncVacationInputs();
const vacs = [];
for (const p of this._vacationDraft) {
const name = (p.name || "").trim();
const start = p.start || "";
const end = p.end || "";
if (!start && !end && !name) continue; // skip wholly-empty rows
if (!start || !end) { this._showToast("err", this._t("panel.vacation_dates_required")); return; }
vacs.push({ ...(p.id ? { id: p.id } : {}), name, start, end });
}
payload.vacation_periods = vacs;
}
const { ok, err, res } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
this._timePeriodsDraft = null; // rebuild from saved state on next render
this._vacationDraft = null;
this._settingsEntityDraft = null;
await this._fetchState();
this._showToast("ok", this._t("panel.toast_settings_saved", {count: (res && res.updated || []).length}));
}
// ---- Notifications WS helpers ----------------------------------------
async _notifSetMaster(typeId, enabled) {
await this._callWS({ type: "taskmate/notifications/set_master_enabled", type_id: typeId, enabled });
await this._fetchState();
}
async _doSendTestNotif(typeId) {
const { ok, err, res } = await this._callWS({ type: "taskmate/notifications/send_test", type_id: typeId });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
const count = (res && res.sent || []).length;
this._showToast("ok", this._t("panel.notif_test_sent", { count }));
}
async _notifSetRoute(typeId, recipientId, enabled, time) {
await this._callWS({ type: "taskmate/notifications/set_route", type_id: typeId, recipient_id: recipientId, enabled, time });
await this._fetchState();
}
async _notifSetChildNotify(childId, notifyService) {
await this._callWS({ type: "taskmate/notifications/set_child_notify", child_id: childId, notify_service: notifyService || null });
await this._fetchState();
}
async _notifSetChildQuiet(childId, start, end) {
await this._callWS({ type: "taskmate/notifications/set_child_quiet", child_id: childId, quiet_hours_start: start || "", quiet_hours_end: end || "" });
await this._fetchState();
}
async _notifSetStreakCutoff(time) {
await this._callWS({ type: "taskmate/notifications/set_streak_cutoff", time });
await this._fetchState();
}
async _notifSetEscalation(field, value) {
const s = (this._notifState && this._notifState.settings) || {};
const cur = {
reminder_minutes: Number(s.mandatory_escalation_reminder_minutes) || 30,
parent_minutes: Number(s.mandatory_escalation_parent_minutes) || 120,
};
const n = Math.max(1, Math.min(1440, Math.round(Number(value) || cur[field])));
cur[field] = n;
await this._callWS({ type: "taskmate/notifications/set_escalation", reminder_minutes: cur.reminder_minutes, parent_minutes: cur.parent_minutes });
await this._fetchState();
}
async _notifUpsertParent(p) {
const payload = { type: "taskmate/notifications/upsert_parent", name: p.name, notify_service: p.notify_service, enabled: p.enabled !== false };
if (p.id) payload.parent_id = p.id;
await this._callWS(payload);
await this._fetchState();
}
async _notifAddParent() {
const first = (this._notifyServices || [])[0] || "";
await this._notifUpsertParent({ name: this._t("panel.notif_default_parent_name"), notify_service: first, enabled: true });
}
async _notifDeleteParent(parentId) {
await this._callWS({ type: "taskmate/notifications/delete_parent", parent_id: parentId });
await this._fetchState();
}
async _notifUpsertCustom(n) {
const payload = {
type: "taskmate/notifications/upsert_custom",
name: n.name, message_template: n.message_template, time: n.time,
day_mask: n.day_mask, recipient_ids: n.recipient_ids, enabled: n.enabled,
};
if (n.id) payload.custom_id = n.id;
await this._callWS(payload);
await this._fetchState();
}
async _notifAddCustom() {
await this._notifUpsertCustom({
name: this._t("panel.notif_default_custom_name"),
message_template: this._t("panel.notif_default_custom_message"),
time: "20:00",
day_mask: 0b1111111,
recipient_ids: [],
enabled: true,
});
}
async _notifDeleteCustom(customId) {
await this._callWS({ type: "taskmate/notifications/delete_custom", custom_id: customId });
await this._fetchState();
}
async _notifToggleCustom(customId, enabled) {
const n = (this._notifState && this._notifState.custom || []).find(x => x.id === customId);
if (!n) return;
await this._notifUpsertCustom({ ...n, enabled });
}
async _notifToggleDay(customId, dayIdx, isOn) {
const n = (this._notifState && this._notifState.custom || []).find(x => x.id === customId);
if (!n) return;
const bit = 1 << dayIdx;
const newMask = isOn ? (n.day_mask | bit) : (n.day_mask & ~bit);
await this._notifUpsertCustom({ ...n, day_mask: newMask });
}
async _notifToggleRecipient(customId, recipientId) {
const n = (this._notifState && this._notifState.custom || []).find(x => x.id === customId);
if (!n) return;
const has = n.recipient_ids.includes(recipientId);
const newIds = has ? n.recipient_ids.filter(x => x !== recipientId) : [...n.recipient_ids, recipientId];
await this._notifUpsertCustom({ ...n, recipient_ids: newIds });
}
async _notifUpdateCustomField(customId, field, value) {
const n = (this._notifState && this._notifState.custom || []).find(x => x.id === customId);
if (!n) return;
await this._notifUpsertCustom({ ...n, [field]: value });
}
// ---- HA picker binding -----------------------------------------------
_bindHaPickers(syncValues = true) {
if (!this._hass) return;
this.querySelectorAll("ha-icon-picker").forEach(el => {
el.hass = this._hass;
if (syncValues) {
const v = el.getAttribute("data-current") || "";
if (el.value !== v) el.value = v;
}
});
if (!syncValues) return;
this.querySelectorAll("ha-textfield[data-value]").forEach(el => {
const v = el.getAttribute("data-value") || "";
if (el.value !== v) el.value = v;
});
this.querySelectorAll("ha-select[data-value], select[data-value]").forEach(el => {
const v = el.getAttribute("data-value") || "";
if (el.value !== v) el.value = v;
});
}
// ---- Time helpers ----------------------------------------------------
_timeAgo(iso) {
if (!iso) return "—";
const then = new Date(iso);
if (isNaN(then)) return 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)});
if (diffSec < 86400) return this._t("panel.time_ago_hours", {count: Math.floor(diffSec / 3600)});
if (diffSec < 604800) return this._t("panel.time_ago_days", {count: Math.floor(diffSec / 86400)});
return then.toLocaleDateString();
}
// ---- rendering -------------------------------------------------------
_render() {
this._rendered = true;
// A re-render rebuilds the rows the floating row menu is anchored to, so
// drop any open menu rather than leave it orphaned/mispositioned.
if (this._rowMenuEl) this._closeRowMenu();
// Self-heal: HA's partial-panel-resolver removes the panel element from
// the DOM while the WS connection is down (e.g. a long-backgrounded tab)
// and re-attaches it on reconnect. If our shell was stripped out, force a
// full rebuild — otherwise the zone-diff below short-circuits on the
// stale _zoneCache and the content area stays blank until a hard reload.
if (this._shellReady && !this.querySelector(".tm-shell")) {
this._shellReady = false;
this._zoneCache = {};
}
if (!this._shellReady) {
if (!this._cachedStyles) {
this._cachedStyles = this._styles();
}
const existingToast = this.querySelector(".tm-toast");
this.innerHTML = `
${this._cachedStyles}
${this._sidebar()}
${this._topbar()}
${this._mobileTabs()}
${this._approvalBanner()}
${this._dialog ? this._renderDialog() : ""}
${this._renderSaveTemplateDialog()}
`;
if (existingToast) this.appendChild(existingToast);
this._shellReady = true;
this._zoneCache = {};
this._applyDesign();
this._bindHaPickers();
return;
}
const zones = {
sidebar: this._sidebar(),
topbar: this._topbar(),
mtabs: this._mobileTabs(),
approval: this._approvalBanner(),
body: this._renderBody(),
dialog: this._dialog ? this._renderDialog() : "",
tpl: this._renderSaveTemplateDialog(),
};
const dialogZone = this.querySelector('[data-zone="dialog"]');
let savedScroll = 0;
if (dialogZone) {
const dialogBody = dialogZone.querySelector(".tm-dialog-body");
savedScroll = dialogBody ? dialogBody.scrollTop : 0;
}
if (this._dialog) {
const openSet = this._dialog._openAdvanced instanceof Set ? this._dialog._openAdvanced : new Set();
this.querySelectorAll("details.tm-advanced[data-section]").forEach(el => {
const key = el.dataset.section;
if (!key) return;
if (el.open) openSet.add(key); else openSet.delete(key);
});
this._dialog._openAdvanced = openSet;
}
let anyChanged = false;
for (const [name, html] of Object.entries(zones)) {
if (this._zoneCache[name] === html) continue;
this._zoneCache[name] = html;
const el = this.querySelector(`[data-zone="${name}"]`);
if (!el) { this._shellReady = false; this._render(); return; }
el.innerHTML = html;
anyChanged = true;
}
if (anyChanged) {
this._bindHaPickers();
}
if (savedScroll) {
const newBody = dialogZone && dialogZone.querySelector(".tm-dialog-body");
if (newBody) newBody.scrollTop = savedScroll;
}
this._applyDesign();
}
// The admin panel intentionally stays CLASSIC regardless of the global card
// design (the per-card design styles apply to Lovelace cards only). This
// clears any stale attribute so no designed rules can attach.
_applyDesign() {
const shell = this.querySelector(".tm-shell");
if (shell) shell.removeAttribute("data-tm-design");
}
_sidebarGroups() {
const counts = this._state ? {
children: (this._state.children || []).length,
activity: (this._state.pending_completions || []).length + (this._state.pending_reward_claims || []).length + (this._state.swap_requests || []).length,
chores: (this._state.chores || []).length,
rewards: (this._state.rewards || []).length,
penalties: (this._state.penalties || []).length,
bonuses: (this._state.bonuses || []).length,
groups: (this._state.task_groups || []).length,
templates: (this._state.templates || []).length,
} : {};
return [
{ head: this._t("panel.nav_today"), items: [
{ id: "activity", label: this._t("panel.tab_activity"), icon: "mdi:pulse" },
{ id: "insights", label: this._t("panel.tab_insights"), icon: "mdi:chart-box-outline" },
]},
{ head: this._t("panel.nav_manage"), items: [
{ id: "children", label: this._t("panel.tab_children"), icon: "mdi:account-multiple" },
{ id: "chores", label: this._t("panel.tab_chores"), icon: "mdi:check-circle-outline" },
{ id: "rewards", label: this._t("panel.tab_rewards"), icon: "mdi:gift-outline" },
{ id: "penalties", label: this._t("panel.tab_penalties"), icon: "mdi:alert-circle-outline" },
{ id: "bonuses", label: this._t("panel.tab_bonuses"), icon: "mdi:flash-outline" },
{ id: "groups", label: this._t("panel.tab_groups"), icon: "mdi:layers-outline" },
{ id: "quests", label: this._t("panel.tab_quests"), icon: "mdi:map-marker-path" },
{ id: "challenges", label: this._t("panel.tab_challenges"), icon: "mdi:trophy-outline" },
{ id: "badges", label: this._t("panel.tab_badges"), icon: "mdi:medal-outline" },
{ id: "templates", label: this._t("panel.tab_templates"), icon: "mdi:clipboard-list-outline" },
]},
{ head: this._t("panel.nav_system"), items: [
{ id: "notifications", label: this._t("panel.tab_notifications"), icon: "mdi:bell-outline" },
// The audit view has always been implemented (and documented in the
// wiki), but it was never listed here — and the nav is built solely
// from this list, so there was no way to open it.
{ id: "audit", label: this._t("panel.tab_audit"), icon: "mdi:history" },
{ id: "settings", label: this._t("panel.tab_settings"), icon: "mdi:cog-outline" },
]},
].map(g => ({
...g,
items: g.items.map(it => ({ ...it, count: counts[it.id] }))
}));
}
_sidebar() {
const groups = this._sidebarGroups();
return `
`;
}
_topbar() {
const crumb = this._sidebarGroups()
.flatMap(g => g.items)
.find(i => i.id === this._activeTab);
const crumbLabel = crumb ? crumb.label : "";
const pendingCount = this._state
? (this._state.pending_completions || []).length + (this._state.pending_reward_claims || []).length + (this._state.swap_requests || []).length
: 0;
return `
TaskMate
/
${this._esc(crumbLabel)}
${pendingCount > 0 && this._activeTab !== "activity" ? `
${this._t("panel.topbar_pending", {count: pendingCount})}
` : ""}
`;
}
_mobileTabs() {
const groups = this._sidebarGroups();
const open = !!this._mobileNavOpen;
const current = groups.flatMap(g => g.items).find(it => it.id === this._activeTab)
|| { id: this._activeTab, label: "", icon: "mdi:cog-outline" };
const curUrgent = current.id === "activity" && current.count > 0;
const curShowCount = current.count != null && current.count > 0;
return `
TaskMate
${this._esc(current.label)}
${curShowCount ? `${current.count} ` : ""}
${open ? `
${groups.map(g => `
${this._esc(g.head)}
${g.items.map(it => {
const active = it.id === this._activeTab;
const urgent = it.id === "activity" && it.count > 0;
const showCount = it.count != null && it.count > 0;
return `
${this._esc(it.label)}
${showCount ? `${it.count} ` : ""}
`;
}).join("")}
`).join("")}
` : ""}
`;
}
_approvalBanner() {
if (!this._state) return "";
if (this._activeTab === "activity") return "";
const completionsP = (this._state.pending_completions || []).length;
const rewardsP = (this._state.pending_reward_claims || []).length;
const swapsP = (this._state.swap_requests || []).length;
const total = completionsP + rewardsP + swapsP;
if (total === 0) return "";
const parts = [];
// The count strings already include {count}; pass it bolded so the number
// renders once (not duplicated by a separate prefix — issue #573).
if (completionsP) parts.push(this._t("panel.topbar_chore_count", {count: `${completionsP} `}));
if (rewardsP) parts.push(this._t("panel.topbar_reward_count", {count: `${rewardsP} `}));
if (swapsP) parts.push(this._t("panel.topbar_swap_count", {count: `${swapsP} `}));
return `
${this._t("panel.topbar_awaiting_approval", {details: parts.join(" · ")})}
${this._t("panel.btn_review")}
`;
}
_renderBody() {
if (this._loading && !this._state) return `${this._t("panel.loading")}
`;
if (this._error) return `
${this._t("panel.error_failed_to_load")}
${this._esc(this._error)}
${this._t("panel.btn_retry")}
`;
if (!this._state) return `${this._t("panel.error_no_state")}
`;
switch (this._activeTab) {
case "children": return this._renderChildrenTab();
case "activity": return this._renderActivityTab();
case "insights": return this._renderInsightsTab();
case "chores": return this._renderChoresTab();
case "rewards": return this._renderRewardsTab();
case "penalties": return this._renderPenBonTab("penalty");
case "bonuses": return this._renderPenBonTab("bonus");
case "groups": return this._renderGroupsTab();
case "quests": return this._renderQuestsTab();
case "challenges": return this._renderChallengesTab();
case "badges": return this._renderBadgesTab();
case "templates": return this._renderTemplatesTab();
case "notifications": return this._renderNotificationsTab();
case "audit": return this._renderAuditTab();
case "settings": return this._renderSettingsTab();
default: return `${this._t("panel.tab_unknown")}
`;
}
}
/**
* Insight reports (#679). Computed on demand rather than stored — a stale
* cached report is worse than a slow one — so the tab fetches when opened
* and when the window changes.
*/
_renderInsightsTab() {
const view = this._insightView || "fairness";
const switcher = `
${["fairness", "friction", "projection", "health"].map(v => `
${this._t("panel.insights_view_" + v)}
`).join("")}
`;
if (view === "friction") return switcher + this._renderFrictionReport();
if (view === "projection") return switcher + this._renderProjectionReport();
if (view === "health") return switcher + this._renderHealthReport();
return switcher + this._renderFairnessReport();
}
/**
* Friction (#680): which chores aren't working, and what to do about each.
* Deliberately leads with a suggestion rather than just a diagnosis.
*/
_renderFrictionReport() {
const report = this._friction;
if (!report) {
this._loadFriction(this._frictionDays || 30);
return `${this._t("panel.insights_loading")}
`;
}
const days = this._frictionDays || 30;
const problems = report.chores.filter(c => c.verdict !== "fine");
return `
${this._t("panel.insights_friction_title")}
${[14, 30, 90].map(d => `
${this._t("panel.insights_last_days", { days: d })}
`).join("")}
${this._t("panel.insights_friction_intro", { start: report.start, end: report.end })}
${report.chores.length === 0 ? `
${this._t("panel.insights_friction_none")}
` : `
${report.problem_count
? this._t("panel.insights_friction_problems", { count: report.problem_count })
: this._t("panel.insights_friction_healthy")}
${this._t("panel.insights_col_chore")}
${this._t("panel.insights_col_done")}
${this._t("panel.insights_col_rate")}
${this._t("panel.insights_col_last")}
${this._t("panel.insights_col_verdict")}
${this._t("panel.insights_col_suggestion")}
${(problems.length ? problems : report.chores).map(c => `
${this._esc(c.name)} ${c.outstanding_misses
? ` ⚠ ${c.outstanding_misses} ` : ""}
${c.completed}${c.expected ? ` / ${c.expected}` : ""}
${c.rate === null ? "—" : c.rate + "%"}
${c.days_since === null
? this._t("panel.insights_never")
: this._t("panel.insights_days_ago", { days: c.days_since })}
${this._t("panel.insights_verdict_" + c.verdict)}
${this._t("panel.insights_suggest_" + c.suggestion)}
`).join("")}
${this._t("panel.insights_friction_note")}
`}
`;
}
/**
* Projection (#681): what the week ahead looks like. A ceiling rather than
* a forecast — shared-pool chores are credited to every eligible child,
* because the schedule can't know who will actually get there first.
*/
_renderProjectionReport() {
const report = this._projection;
if (!report) {
this._loadProjection(this._projectionDays || 7);
return `${this._t("panel.insights_loading")}
`;
}
const days = this._projectionDays || 7;
const kids = report.children;
const maxPoints = Math.max(1, ...kids.map(k => k.points));
const pointsName = this._state.settings?.points_name || this._t("common.points");
return `
${this._t("panel.insights_projection_title")}
${[7, 14, 28].map(d => `
${this._t("panel.insights_next_days", { days: d })}
`).join("")}
${this._t("panel.insights_projection_intro", { start: report.start, end: report.end })}
${kids.map(k => `
${this._esc(k.name)}
${this._t("panel.insights_projected_earn", { points: k.points, name: this._esc(pointsName) })}
· ${k.chores} ${this._t(k.chores === 1 ? "panel.insights_chore" : "panel.insights_chores")}
· ${this._t("panel.insights_projected_total", { total: k.projected_total })}
`).join("")}
${report.unassigned_points > 0 ? `
${this._t("panel.insights_projection_unassigned", { points: report.unassigned_points })}
` : ""}
${this._t("panel.insights_col_day")}
${kids.map(k => `${this._esc(k.name)} `).join("")}
${report.by_day.map(d => `
${this._t("panel.day_" + d.weekday.slice(0, 3))} ${d.date.slice(5)}
${kids.map(k => {
const cell = d.children.find(c => c.id === k.id) || { points: 0, chores: 0 };
return `${cell.chores ? `${cell.points} (${cell.chores}) ` : "—"} `;
}).join("")}
`).join("")}
${this._t("panel.insights_projection_note")}
`;
}
/**
* Health & diagnostics (#682). Every issue carries a severity, a plain
* sentence and where to fix it — a diagnostic that only says "3 problems"
* is one the parent can't act on.
*/
_renderHealthReport() {
const report = this._health;
if (!report) {
this._loadHealth();
return `${this._t("panel.insights_loading")}
`;
}
const kb = (report.storage_bytes / 1024).toFixed(1);
const counts = report.counts;
return `
${this._t("panel.health_title")}
${this._t("panel.health_recheck")}
${report.healthy
? this._t("panel.health_all_good")
: this._t("panel.health_issues", { count: report.issues.filter(i => i.severity !== "info").length })}
${report.issues.length ? `
${report.issues.map(i => `
${this._t("panel.health_sev_" + i.severity)}
${this._esc(i.message)}
${this._t("panel.health_goto")}
`).join("")}
` : ""}
${Object.entries(counts).map(([key, value]) => `
${value}
${this._t("panel.health_count_" + key)}
`).join("")}
${kb} KB
${this._t("panel.health_count_storage")}
`;
}
async _loadHealth() {
if (this._healthLoading) return;
this._healthLoading = true;
const { ok, res, err } = await this._callWS({ type: "taskmate/reports/health" });
this._healthLoading = false;
if (!ok) { this._showToast("err", err); return; }
this._health = res;
this._render();
}
async _loadProjection(days) {
if (this._projectionLoading) return;
this._projectionLoading = true;
const { ok, res, err } = await this._callWS({ type: "taskmate/reports/projection", days });
this._projectionLoading = false;
if (!ok) { this._showToast("err", err); return; }
this._projection = res;
this._render();
}
async _loadFriction(days) {
if (this._frictionLoading) return;
this._frictionLoading = true;
const { ok, res, err } = await this._callWS({ type: "taskmate/reports/friction", days });
this._frictionLoading = false;
if (!ok) { this._showToast("err", err); return; }
this._friction = res;
this._render();
}
_renderFairnessReport() {
const days = this._insightDays || 7;
const report = this._fairness;
if (!report) {
this._loadFairness(days);
return `${this._t("panel.insights_loading")}
`;
}
const windows = [7, 14, 30];
const maxShare = Math.max(1, ...report.children.map(c => c.share_completions));
return `
${this._t("panel.insights_fairness_title")}
${windows.map(d => `
${this._t("panel.insights_last_days", { days: d })}
`).join("")}
${this._t("panel.insights_fairness_intro", {
start: report.start, end: report.end, fair: report.fair_share,
})}
${report.total_completions === 0 ? `
${this._t("panel.insights_no_data")}
` : `
${report.balanced
? this._t("panel.insights_balanced")
: this._t("panel.insights_unbalanced")}
${report.children.map(c => `
${this._esc(c.name)}
${c.completions} ${this._t(c.completions === 1 ? "panel.insights_chore" : "panel.insights_chores")}
· ${c.points} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
· ${c.share_completions}%
${this._t("panel.insights_status_" + c.status)}
`).join("")}
${this._t("panel.insights_fairness_note", { tolerance: report.tolerance })}
`}
`;
}
async _loadFairness(days) {
if (this._fairnessLoading) return;
this._fairnessLoading = true;
const { ok, res, err } = await this._callWS({ type: "taskmate/reports/fairness", days });
this._fairnessLoading = false;
if (!ok) { this._showToast("err", err); return; }
this._fairness = res;
this._render();
}
_searchBox(placeholder) {
return `
${this._filter ? `✕ ` : ""}
`;
}
_filterByName(items) {
if (!this._filter) return items;
const q = this._filter.toLowerCase();
return items.filter(it => (it.name || "").toLowerCase().includes(q));
}
// -- Children tab ------------------------------------------------------
_renderChildrenTab() {
const all = this._state.children || [];
const children = this._filterByName(all);
const pointsName = this._state.settings.points_name || this._t("common.points");
return `
${all.length > 0 ? this._searchBox(this._t("panel.search_children")) : ""}
${all.length >= 2 ? `${this._t("panel.gift_btn")} ` : ""}
${this._t("panel.btn_add_child")}
${all.length === 0 ? this._emptyState("👨👩👧👦", this._t("panel.empty_children_title"), this._t("panel.empty_children_copy"), "add-child", this._t("panel.btn_add_child")) :
children.length === 0 ? `${this._t("panel.empty_no_match", {kind: this._t("panel.tab_children").toLowerCase(), filter: this._esc(this._filter)})}
` : `
${children.map(c => this._renderChildCard(c, pointsName)).join("")}
+ ${this._t("panel.child_add_tile")}
`}
`;
}
_renderChildCard(child, pointsName) {
return `
${this._mdi(child.avatar)}
${this._esc(child.name || this._t("panel.child_unnamed"))}
${this._idBadge(child.id)}
${child.availability_entity ? `${this._esc(child.availability_entity)}${child.availability_inverted ? ` ${this._t("panel.child_inverted")} ` : ""}` : `${this._t("panel.child_no_availability")}`}${child.unavailability_entity ? ` · ${this._t("panel.child_busy_label")} ${this._esc(child.unavailability_entity)}` : ""}
${this._fmtNum(child.points || 0)}
${this._esc(pointsName)}
${this._fmtNum(child.level || 1)}
${this._t("panel.child_stat_level")}
${this._fmtNum(child.total_points_earned || 0)}
${this._t("panel.child_stat_earned")}
${this._fmtNum(child.total_chores_completed || 0)}
${this._t("panel.child_stat_done")}
`;
}
// -- Activity tab ------------------------------------------------------
_renderActivityTab() {
const pendingCompletions = this._state.pending_completions || [];
const pendingClaims = this._state.pending_reward_claims || [];
const pendingSwaps = this._state.swap_requests || [];
const transactions = this._state.points_transactions || [];
const completions = this._state.completions || [];
const claims = this._state.reward_claims || [];
const childById = Object.fromEntries((this._state.children || []).map(c => [c.id, c]));
const choreById = Object.fromEntries((this._state.chores || []).map(c => [c.id, c]));
const rewardById = Object.fromEntries((this._state.rewards || []).map(r => [r.id, r]));
// Recent activity feed: merge approved completions + claims + transactions
const events = [];
completions.filter(c => c.approved).forEach(c => events.push({
ts: c.approved_at || c.completed_at, kind: "completion",
child: c.child_id === "__parent__" ? "Parent" : ((childById[c.child_id] || {}).name || "?"),
label: `${this._t("panel.activity_completed_chore", {name: (choreById[c.chore_id] || {}).name || this._t("panel.activity_deleted_chore")})}`,
points: c.points_awarded,
}));
claims.filter(c => c.approved).forEach(c => events.push({
ts: c.approved_at || c.claimed_at, kind: "claim",
child: (childById[c.child_id] || {}).name || "?",
label: `${this._t("panel.activity_claimed_reward", {name: (rewardById[c.reward_id] || {}).name || this._t("panel.activity_deleted_reward")})}`,
points: -((rewardById[c.reward_id] || {}).cost || 0),
}));
transactions.forEach(t => events.push({
ts: t.created_at, kind: "manual",
child: (childById[t.child_id] || {}).name || "?",
label: this._translateReason(t.reason) || (t.points >= 0 ? this._t("panel.activity_manual_addition") : this._t("panel.activity_manual_deduction")),
points: t.points,
}));
events.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
const recent = events.slice(0, 30);
return `
${this._t("panel.activity_pending_approvals")}
${(pendingCompletions.length + pendingClaims.length + pendingSwaps.length) > 0
? `${pendingCompletions.length + pendingClaims.length + pendingSwaps.length} `
: `${this._t("panel.activity_pending_all_clear")} `}
${pendingCompletions.length > 0
? `${this._t("panel.activity_approve_all")} `
: ""}
${pendingCompletions.length === 0 && pendingClaims.length === 0 && pendingSwaps.length === 0 ? `
${this._t("panel.activity_no_items")}
` : `
${pendingCompletions.map(c => {
const chore = choreById[c.chore_id];
const child = childById[c.child_id];
let choreName = (chore && chore.name) || this._t("panel.activity_deleted_chore");
let chorePoints = chore ? chore.points : 0;
if (c.bonus_subtask_id && chore) {
const sub = (chore.bonus_subtasks || []).find(b => b.id === c.bonus_subtask_id);
if (sub) { choreName = `${chore.name} › ${sub.name}`; chorePoints = sub.points; }
} else if (chore && chore.task_type === "timed" && (c.timed_duration_seconds || 0) > 0) {
// Timed chores accrue points by elapsed-time rate, so the actual
// earned points can be lower than chore.points. Mirror sensor.py.
const rateSeconds = (chore.timed_rate_minutes || 0) * 60;
if (rateSeconds > 0) {
chorePoints = Math.floor(c.timed_duration_seconds / rateSeconds) * (chore.timed_rate_points || 0);
}
}
const photoCap = [choreName, (child && child.name) || "", c.completed_at ? new Date(c.completed_at).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""].filter(Boolean).join(" · ");
return `
${this._safePhotoUrl(c.photo_url) ? `
` : ""}
${this._t("panel.activity_completed_text", {child: this._esc((child && child.name) || "?"), chore: this._esc(choreName)})}
${this._timeAgo(c.completed_at)} · ${chorePoints} ${this._t("panel.activity_points")}
${this._t("panel.activity_reject")}
${this._t("panel.activity_approve")}
`;
}).join("")}
${pendingClaims.map(c => {
const reward = rewardById[c.reward_id];
const child = childById[c.child_id];
return `
${this._t("panel.activity_claimed_text", {child: this._esc((child && child.name) || "?"), reward: this._esc((reward && reward.name) || this._t("panel.activity_deleted_reward"))})}
${this._timeAgo(c.claimed_at)}${reward ? ` · ${reward.cost} ${this._t("panel.activity_points")}` : ""}
${this._t("panel.activity_reject")}
${this._t("panel.activity_approve")}
`;
}).join("")}
${pendingSwaps.map(sw => {
const chore = choreById[sw.chore_id];
const req = childById[sw.requester_id];
const from = childById[sw.from_child_id];
return `
${this._t("panel.swap_request_text", {child: this._esc((req && req.name) || "?"), chore: this._esc((chore && chore.name) || "?")})}
${from ? this._t("panel.swap_from", {name: this._esc(from.name)}) + " · " : ""}${this._timeAgo(sw.created_at)}
${this._t("panel.activity_reject")}
${this._t("panel.activity_approve")}
`;
}).join("")}
`}
${this._t("panel.activity_recent")}
${recent.length === 0 ? `
${this._t("panel.activity_no_recent")}
` : `
${recent.map(ev => `
${this._esc(this._timeAgo(ev.ts))}
${this._esc(ev.child)} · ${this._esc(ev.label)}
${ev.points >= 0 ? '+' : ''}${ev.points || 0}
`).join("")}
`}
${this._t("panel.activity_audit_log")}
${this._t(transactions.length === 1 ? "panel.activity_transaction_count" : "panel.activity_transaction_count_plural", {count: transactions.length})}
${transactions.length === 0 ? `
${this._t("panel.activity_no_points_transactions")}
` : `
${this._t("panel.activity_table_when")} ${this._t("panel.activity_table_child")} ${this._t("panel.activity_table_points")} ${this._t("panel.activity_table_reason")}
${[...transactions].reverse().map(t => {
const child = childById[t.child_id];
const undoable = typeof t.reason === "string" && (t.reason.startsWith("Penalty: ") || t.reason.startsWith("Bonus: "));
return `
${this._esc(this._timeAgo(t.created_at))}
${this._esc((child && child.name) || "?")}
${t.points >= 0 ? '+' : ''}${t.points}
${this._esc(this._translateReason(t.reason) || "—")}
${undoable ? ` ` : ""}
`;
}).join("")}
`}
`;
}
// -- Chores tab --------------------------------------------------------
_sortChoresByDisplayOrder(chores) {
const order = this._state.chore_display_order || [];
if (!order.length) return chores;
const idx = Object.fromEntries(order.map((id, i) => [id, i]));
return [...chores].sort((a, b) => {
const ai = idx[a.id] ?? Number.MAX_SAFE_INTEGER;
const bi = idx[b.id] ?? Number.MAX_SAFE_INTEGER;
return ai - bi;
});
}
_renderChoresTab() {
const raw = this._state.chores || [];
const all = this._sortChoresByDisplayOrder(raw);
const chores = this._filterByName(all);
const childById = Object.fromEntries((this._state.children || []).map(c => [c.id, c]));
return `
${all.length > 0 ? this._searchBox(this._t("panel.search_chores")) : ""}
${this._t("panel.btn_reorder_chores")}
${this._t("panel.btn_bulk_add")}
${this._t("panel.btn_from_template")}
${this._t("panel.btn_save_as_template")}
${all.length > 0 ? `${this._bulkMode ? this._t("panel.bulk_done") : this._t("panel.bulk_select")} ` : ""}
${this._t("panel.btn_add_chore")}
${this._bulkMode && all.length > 0 ? `
${this._t("panel.bulk_selected", { count: this._bulkSel.size })}
${this._t("panel.bulk_enable")}
${this._t("panel.bulk_disable")}
${this._t("panel.bulk_delete")}
${this._t("panel.bulk_reassign_to")}
${this._t("panel.common_all_children")}
${(this._state.children || []).map(c => `${this._esc(c.name)} `).join("")}
${this._t("panel.bulk_apply")}
` : ""}
${all.length === 0 ? this._emptyState("📋", this._t("panel.empty_chores_title"), this._t("panel.empty_chores_copy"), "add-chore", this._t("panel.btn_add_chore")) :
chores.length === 0 ? `${this._t("panel.empty_no_match", {kind: this._t("panel.tab_chores").toLowerCase(), filter: this._esc(this._filter)})}
` : `
${this._bulkMode ? " " : ""}${this._t("panel.chore_table_name")} ${this._t("panel.chore_table_points")} ${this._t("panel.chore_table_period")} ${this._t("panel.chore_table_assigned")} ${this._t("panel.chore_table_current")} ${this._t("panel.chore_table_schedule")} ${this._t("panel.chore_table_approval")}
${chores.map(c => this._renderChoreRow(c, childById)).join("")}
${this._t("panel.chore_tip_rename")}
`}
`;
}
_renderChoreRow(c, childById) {
const renaming = this._inlineRename && this._inlineRename.kind === "chore" && this._inlineRename.id === c.id;
const isRotation = ["alternating", "random", "balanced"].includes(c.assignment_mode);
const curChild = c.assignment_current_child_id || "";
const assignedNames = c.assignment_mode === "unassigned"
? `${this._t("panel.assign_unassigned_short")} `
: (c.assigned_to || []).length === 0
? `${this._t("panel.common_all_children")} `
: this._esc((c.assigned_to || []).map(id => (childById[id] && childById[id].name) || "?").join(", "));
const currentName = isRotation && curChild && childById[curChild]
? `${this._esc(childById[curChild].name)} `
: isRotation ? `— ` : "";
const schedLabel = c.schedule_mode === "recurring"
? this._labelOf(RECURRENCES, c.recurrence) + (c.recurrence_day && c.recurrence_day !== "any_day" ? ` · ${this._labelOf(DAYS, c.recurrence_day)}` : "")
: c.schedule_mode === "one_shot"
? this._t("panel.common_one_shot")
: ((c.due_days || []).length === 0 ? this._t("panel.common_daily") : (c.due_days || []).map(d => this._labelOf(DAYS, d)).join(" · "));
const schedClass = c.schedule_mode === "recurring" ? "tm-pill-accent" : c.schedule_mode === "one_shot" ? "tm-pill-warn" : "tm-pill-success";
const modeBadge = c.assignment_mode && c.assignment_mode !== "everyone"
? `${this._t(`panel.assign_${c.assignment_mode}_short`)} ` : "";
const nameCell = renaming
? `
✓
✕ `
: `${this._esc(c.name)} ${c.enabled === false ? ` ${this._t("panel.common_disabled")} ` : ""}${this._idBadge(c.id)}`;
return `
${this._bulkMode ? ` ` : ""}
${nameCell}
${renaming ? "" : `
✏
⋮
`}
${c.task_type === "timed" ? `${c.timed_rate_points || 0}/${c.timed_rate_minutes || 1} min` : c.points}
${this._esc(this._timeCategoryLabel(c.time_category))}
${assignedNames} ${modeBadge}
${currentName}
${this._esc(schedLabel)}
${c.requires_approval ? `${this._t("panel.common_yes")} ` : `${this._t("panel.common_no")} `}
`;
}
// -- Rewards tab -------------------------------------------------------
_renderRewardsTab() {
const all = this._state.rewards || [];
const rewards = this._filterByName(all);
const pointsName = this._state.settings.points_name || this._t("common.points");
return `
${all.length > 0 ? this._searchBox(this._t("panel.search_rewards")) : ""}
${this._t("panel.btn_add_reward")}
${all.length === 0 ? this._emptyState("🎁", this._t("panel.empty_rewards_title"), this._t("panel.empty_rewards_copy", {points_name: pointsName.toLowerCase()}), "add-reward", this._t("panel.btn_add_reward")) :
rewards.length === 0 ? `${this._t("panel.empty_no_match", {kind: this._t("panel.tab_rewards").toLowerCase(), filter: this._esc(this._filter)})}
` : `
${rewards.map(r => this._renderRewardCard(r, pointsName)).join("")}
+ ${this._t("panel.reward_add_tile")}
`}
`;
}
_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;
const pct = showProgress ? Math.min(100, Math.round(totalPooled / r.cost * 100)) : 0;
return `
${this._mdi(r.icon || "mdi:gift")}
${this._esc(r.name)} ${r.is_jackpot ? `🏆 ${this._t("panel.reward_badge_jackpot")} ` : ""} ${r.pool_enabled ? `${this._t("panel.reward_badge_pool")} ` : ""}
${this._idBadge(r.id)}
${this._esc(r.description || "")}
${this._fmtNum(r.cost)}
${this._t("panel.reward_stat_cost", {points_name: pointsName})}
${r.quantity != null ? `
${r.quantity}
${this._t("panel.reward_stat_remaining")}
` : `
∞
${this._t("panel.reward_stat_unlimited")}
`}
${r.expires_at ? `
${this._esc(r.expires_at)}
${this._t("panel.reward_stat_expires")}
` : ""}
${showProgress ? `
${this._fmtNum(totalPooled)} / ${this._fmtNum(r.cost)}${pct}%
` : ""}
`;
}
// -- Quests tab --------------------------------------------------------
_renderQuestsTab() {
const all = this._state.quests || [];
const quests = this._filterByName(all);
const pointsName = this._state.settings.points_name || this._t("common.points");
const haveChores = (this._state.chores || []).length > 0;
return `
${all.length > 0 ? this._searchBox(this._t("panel.search_quests")) : ""}
${haveChores ? `${this._t("panel.btn_add_quest")} ` : ""}
${!haveChores ? `${this._t("panel.quest_need_chores")}
` :
all.length === 0 ? this._emptyState("🗺️", this._t("panel.empty_quests_title"), this._t("panel.empty_quests_copy"), "add-quest", this._t("panel.btn_add_quest")) :
quests.length === 0 ? `${this._t("panel.empty_no_match", {kind: this._t("panel.tab_quests").toLowerCase(), filter: this._esc(this._filter)})}
` : `
${quests.map(q => this._renderQuestCard(q, pointsName)).join("")}
+ ${this._t("panel.quest_add_tile")}
`}
`;
}
_renderQuestCard(q, pointsName) {
const choreById = {};
(this._state.chores || []).forEach(c => { choreById[c.id] = c; });
const steps = q.steps || [];
const children = this._state.children || [];
const assigned = (q.assigned_to && q.assigned_to.length)
? children.filter(c => q.assigned_to.includes(c.id)).map(c => c.name)
: [this._t("panel.quest_all_children")];
const stepNames = steps.map((sid, i) => {
const name = choreById[sid] ? choreById[sid].name : this._t("panel.quest_missing_chore");
return `${this._esc(name)} `;
}).join("");
return `
${this._mdi(q.icon || "mdi:map-marker-path")}
${this._esc(q.name)}
${q.repeatable ? `${this._t("panel.quest_badge_repeatable")} ` : ""}
${q.active ? "" : `${this._t("panel.quest_badge_inactive")} `}
${this._idBadge(q.id)}
${this._esc(q.description || "")}
${steps.length}
${this._t("panel.quest_stat_steps")}
${this._fmtNum(q.bonus_points || 0)}
${this._t("panel.quest_stat_bonus", {points_name: pointsName})}
${stepNames}
${this._t("panel.quest_assigned_to")}: ${this._esc(assigned.join(", "))}
`;
}
// -- Challenges tab ----------------------------------------------------
_renderChallengesTab() {
const all = this._state.challenges || [];
const challenges = this._filterByName(all);
const pointsName = this._state.settings.points_name || this._t("common.points");
return `
${all.length > 0 ? this._searchBox(this._t("panel.search_challenges")) : ""}
${this._t("panel.btn_add_challenge")}
${all.length === 0 ? this._emptyState("🏆", this._t("panel.empty_challenges_title"), this._t("panel.empty_challenges_copy"), "add-challenge", this._t("panel.btn_add_challenge")) :
challenges.length === 0 ? `${this._t("panel.empty_no_match", {kind: this._t("panel.tab_challenges").toLowerCase(), filter: this._esc(this._filter)})}
` : `
${challenges.map(c => this._renderChallengeCard(c, pointsName)).join("")}
+ ${this._t("panel.challenge_add_tile")}
`}
`;
}
_renderChallengeCard(c, pointsName) {
const children = this._state.children || [];
const assigned = (c.assigned_to && c.assigned_to.length)
? children.filter(ch => c.assigned_to.includes(ch.id)).map(ch => ch.name)
: [this._t("panel.quest_all_children")];
const scopeLabel = c.scope === "weekly" ? this._t("panel.challenge_scope_weekly") : this._t("panel.challenge_scope_daily");
const metricLabel = c.metric === "points"
? this._t("panel.challenge_metric_points", {points_name: pointsName})
: this._t("panel.challenge_metric_chores");
return `
${this._mdi(c.icon || "mdi:trophy-outline")}
${this._esc(c.name)}
${this._esc(scopeLabel)}
${c.active ? "" : `${this._t("panel.quest_badge_inactive")} `}
${this._idBadge(c.id)}
${this._esc(c.description || "")}
${c.target}
${this._esc(metricLabel)}
${this._fmtNum(c.bonus_points || 0)}
${this._t("panel.quest_stat_bonus", {points_name: pointsName})}
${this._t("panel.quest_assigned_to")}: ${this._esc(assigned.join(", "))}
`;
}
// -- Badges tab --------------------------------------------------------
_renderBadgesTab() {
const allBadges = this._state.badges || [];
const sub = this._badgesSubTab || "catalogue";
const catalogueCount = allBadges.filter(b => b.builtin).length;
const customCount = allBadges.filter(b => !b.builtin).length;
const subTabs = [
{ id: "catalogue", label: this._t("panel.badge_subtab_catalogue", { count: catalogueCount }) },
{ id: "custom", label: this._t("panel.badge_subtab_custom", { count: customCount }) },
{ id: "history", label: this._t("panel.badge_subtab_history") },
];
return `
${sub === "custom" ? `${this._t("panel.badge_add_btn")} ` : ""}
${subTabs.map(s => `${this._esc(s.label)} `).join("")}
${sub === "catalogue" ? this._renderBadgeCatalogueTab(allBadges) : ""}
${sub === "custom" ? this._renderBadgeCustomTab(allBadges) : ""}
${sub === "history" ? this._renderBadgeHistoryTab() : ""}
`;
}
_tierClass(tier) {
return `tm-badge-tier-${(tier || "bronze").toLowerCase()}`;
}
_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)} `);
}
_badgeName(b) {
if (!b.builtin) return b.name;
const key = "badge.name_" + b.id.replace("builtin.", "");
const t = this._t(key);
return t !== key ? t : b.name;
}
_badgeDesc(b) {
if (!b.builtin) return b.description || "";
const key = "badge.desc_" + b.id.replace("builtin.", "");
const t = this._t(key);
return t !== key ? t : (b.description || "");
}
_renderBadgeCatalogueTab(allBadges) {
const builtins = allBadges.filter(b => b.builtin);
if (!builtins.length) return `${this._t("panel.badge_empty_catalogue")}
`;
return `
${this._t("panel.badge_table_badge")} ${this._t("panel.badge_table_tier")} ${this._t("panel.badge_table_criteria")} ${this._t("panel.badge_table_bonus")} ${this._t("panel.badge_table_enabled")}
${builtins.map(b => `
${this._mdi(b.icon)}
${this._esc(this._badgeName(b))}
${this._t("panel.badge_builtin_pill")}
${this._badgeDesc(b) ? `${this._esc(this._badgeDesc(b))}
` : ""}
${this._t("badge.tier_" + (b.tier || "bronze"))}
${this._esc(this._criteriaLabel(b.criteria, b.combinator))}
${b.point_bonus ? `+${b.point_bonus}` : "—"}
✏
`).join("")}
`;
}
_renderBadgeCustomTab(allBadges) {
const customs = allBadges.filter(b => !b.builtin);
if (!customs.length) return `
🏅
${this._t("panel.badge_empty_custom_title")}
${this._t("panel.badge_empty_custom_desc")}
${this._t("panel.badge_add_btn")}
`;
return `
${this._t("panel.badge_table_badge")} ${this._t("panel.badge_table_tier")} ${this._t("panel.badge_table_criteria")} ${this._t("panel.badge_table_bonus")} ${this._t("panel.badge_table_enabled")}
${customs.map(b => `
${this._mdi(b.icon || "mdi:medal")}
${this._esc(b.name)}
${b.description ? `${this._esc(b.description)}
` : ""}
${this._t("badge.tier_" + (b.tier || "bronze"))}
${this._esc(this._criteriaLabel(b.criteria, b.combinator))}
${b.point_bonus ? `+${b.point_bonus}` : "—"}
${this._t("panel.badge_award_btn")}
✏
🗑
`).join("")}
`;
}
_renderBadgeHistoryTab() {
const awards = (this._state.awarded_badges || []).slice().sort((a, b) => (b.earned_at || "").localeCompare(a.earned_at || ""));
const badgeById = Object.fromEntries((this._state.badges || []).map(b => [b.id, b]));
const childById = Object.fromEntries((this._state.children || []).map(c => [c.id, c]));
const rebuildBtn = `${this._t("panel.badge_rebuild_btn")} `;
if (!awards.length) return `
${rebuildBtn}
${this._t("panel.badge_empty_history")}
`;
return `
${rebuildBtn}
${this._t("panel.badge_table_badge")} ${this._t("panel.badge_table_child")} ${this._t("panel.badge_table_when")} ${this._t("panel.badge_table_source")}
${awards.map(a => {
const badge = badgeById[a.badge_id] || {};
const child = childById[a.child_id] || {};
const tierCls = this._tierClass(badge.tier || "bronze");
const srcLabel = a.silent ? this._t("badge.source_silent") : a.manually_awarded ? this._t("badge.source_manual") : this._t("badge.source_auto");
const srcCls = a.silent ? "tm-badge-src-silent" : a.manually_awarded ? "tm-badge-src-manual" : "tm-badge-src-auto";
const when = a.earned_at ? new Date(a.earned_at).toLocaleString() : "—";
return `
${this._mdi(badge.icon || "mdi:medal")}
${this._esc(this._badgeName(badge) || a.badge_id)}
${this._esc(child.name || a.child_id)}
${this._esc(when)}
${srcLabel}
${this._t("panel.badge_revoke_btn")}
`;
}).join("")}
`;
}
// Badge dialog open/save/delete
_openBadgeDialog(id) {
const blank = { name: "", description: "", icon: "mdi:medal", tier: "bronze", point_bonus: 0, notify_on_earn: true, assigned_to: [], criteria: [], builtin: false };
if (id) {
const b = (this._state.badges || []).find(x => x.id === id);
if (!b) return;
this._openDialog({ kind: "badge", mode: "edit", data: { ...blank, ...b, assigned_to: [...(b.assigned_to || [])], criteria: (b.criteria || []).map(c => ({ ...c })) } });
} else {
this._openDialog({ kind: "badge", mode: "add", data: { ...blank } });
}
}
async _doSaveBadge() {
this._syncIconPickers();
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_badge_name_required")); return; }
// validate criteria values
for (const c of (d.criteria || [])) {
if (!BADGE_BOOL_METRICS.includes(c.metric) && (!c.value || Number(c.value) < 1)) {
this._showToast("err", this._t("panel.toast_badge_criterion_invalid")); return;
}
}
const wasAdd = this._dialog.mode === "add";
const base = {
name: d.name.trim(),
description: d.description || "",
icon: d.icon || "mdi:medal",
tier: d.tier || "bronze",
point_bonus: Number(d.point_bonus) || 0,
notify_on_earn: d.notify_on_earn !== false && d.notify_on_earn !== "false",
assigned_to: d.assigned_to || [],
criteria: (d.criteria || []).map(c => ({
metric: c.metric,
operator: BADGE_BOOL_METRICS.includes(c.metric) ? ">=" : (c.operator || ">="),
value: BADGE_BOOL_METRICS.includes(c.metric) ? 1 : Number(c.value),
})),
combinator: d.combinator === "OR" ? "OR" : "AND",
};
let ok, err;
if (wasAdd) {
({ ok, err } = await this._callService("add_badge", base));
} else {
({ ok, err } = await this._callService("update_badge", { badge_id: d.id, ...base }));
}
if (!ok) { this._showToast("err", this._t("panel.toast_badge_save_failed", { error: err })); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", wasAdd ? this._t("panel.toast_badge_added") : this._t("panel.toast_badge_updated"));
}
async _confirmDeleteBadge(id) {
const b = (this._state.badges || []).find(x => x.id === id);
if (!b) return;
if (!confirm(this._t("panel.badge_confirm_delete", { name: b.name }))) return;
const { ok, err } = await this._callService("remove_badge", { badge_id: id });
if (!ok) { this._showToast("err", this._t("panel.toast_badge_delete_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_badge_deleted"));
}
async _doToggleBadgeEnabled(id, currentlyEnabled) {
const { ok, err } = await this._callService("update_badge", { badge_id: id, enabled: !currentlyEnabled });
if (!ok) { this._showToast("err", this._t("panel.toast_badge_update_failed", { error: err })); return; }
await this._fetchState();
}
_openAwardDialog(badgeId) {
const b = (this._state.badges || []).find(x => x.id === badgeId);
if (!b) return;
this._openDialog({ kind: "award-badge", mode: "award", data: { badge_id: badgeId, badge_name: b.name, child_id: "" } });
}
async _doAwardBadge(badgeId, childId) {
if (!childId) { this._showToast("err", this._t("panel.toast_badge_select_child")); return; }
const { ok, err } = await this._callService("award_badge_manually", { badge_id: badgeId, child_id: childId });
if (!ok) { this._showToast("err", this._t("panel.toast_badge_award_failed", { error: err })); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t("panel.toast_badge_awarded"));
}
async _doRevokeBadge(awardedBadgeId, badgeName) {
if (!confirm(this._t("panel.badge_confirm_revoke", { name: badgeName }))) return;
const { ok, err } = await this._callService("revoke_badge", { awarded_badge_id: awardedBadgeId });
if (!ok) { this._showToast("err", this._t("panel.toast_badge_revoke_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_badge_revoked"));
}
async _doRebuildBadges() {
if (!confirm(this._t("panel.badge_confirm_rebuild"))) return;
const { ok, err } = await this._callService("rebuild_badges", {});
if (!ok) { this._showToast("err", this._t("panel.toast_badge_rebuild_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.toast_badge_rebuilt"));
}
async _callService(service, data = {}) {
try {
await this._hass.callService("taskmate", service, data);
return { ok: true };
} catch (err) {
return { ok: false, err: (err && err.message) || String(err) };
}
}
// Badge dialog renders
_renderBadgeDialog() {
const d = this._dialog.data;
const isBuiltin = !!d.builtin;
const children = this._state.children || [];
const title = this._dialog.mode === "add" ? this._t("panel.badge_dialog_add_title") : this._t("panel.badge_dialog_edit_title", { name: d.name });
const criteria = d.criteria || [];
return this._dialogShell(title,
[
isBuiltin ? `${this._t("panel.badge_builtin_hint")}
` : "",
!isBuiltin ? this._field(this._t("panel.badge_name_label"), "name", d.name, "text") : "",
!isBuiltin ? this._field(this._t("panel.badge_description_label"), "description", d.description, "text") : "",
`
${!isBuiltin ? this._iconPickerField(this._t("panel.badge_icon_label"), "icon", d.icon) : ""}
${this._select(this._t("panel.badge_tier_label"), "tier", d.tier || "bronze", BADGE_TIERS)}
`,
`
${this._field(this._t("panel.badge_bonus_label"), "point_bonus", d.point_bonus || 0, "number")}
${this._select(this._t("panel.badge_notify_label"), "notify_on_earn", d.notify_on_earn !== false ? "true" : "false", [{ v: "true", l: this._t("panel.badge_notify_yes") }, { v: "false", l: this._t("panel.badge_notify_no") }])}
`,
children.length > 0 ? `
${this._t("panel.badge_assigned_label")}
${children.map(c => `
${this._esc(c.name)}
`).join("")}
${this._t("panel.badge_assigned_hint")}
` : "",
!isBuiltin ? `
${this._t("panel.badge_criteria_label")}
${criteria.length === 0 ? `
${this._t("panel.badge_no_criteria_hint")}
` : ""}
${criteria.map((c, idx) => {
const isBool = BADGE_BOOL_METRICS.includes(c.metric);
return `
${BADGE_METRICS.map(m => `${this._t(m.lk)} `).join("")}
${isBool ? `= ` : `
${BADGE_OPERATORS.map(sym => { const v = BADGE_OP_VALUES[sym]; return `=") === v ? " selected" : ""}>${sym} `; }).join("")}
`}
${isBool ? `true ` : ` `}
✕
`;
}).join("")}
${this._t("panel.badge_add_criterion_btn")}
${criteria.length >= 2 ? `
${this._t("panel.badge_combinator_label")}
${this._t("panel.badge_combinator_all")}
${this._t("panel.badge_combinator_any")}
` : ""}
` : "",
].join(""),
`${this._t("panel.badge_cancel_btn")}
${this._t("panel.badge_save_btn")} `
);
}
_renderAwardDialog() {
const d = this._dialog.data;
const children = this._state.children || [];
return this._dialogShell(this._t("panel.badge_dialog_award_title", { name: d.badge_name }),
`
${this._t("panel.badge_award_to_label")}
${this._t("panel.badge_award_select_child")}
${children.map(c => `${this._esc(c.name)} `).join("")}
`,
`${this._t("panel.badge_cancel_btn")}
${this._t("panel.badge_award_btn")} `
);
}
// -- Penalties / Bonuses tab -------------------------------------------
_renderPenBonTab(kind) {
const allItems = (kind === "penalty" ? this._state.penalties : this._state.bonuses) || [];
const items = this._filterByName(allItems);
const childById = Object.fromEntries((this._state.children || []).map(c => [c.id, c]));
const labels = kind === "penalty" ? { plural: this._t("panel.penalty_title"), add: this._t("panel.btn_add_penalty"), icon: "⚠️" } : { plural: this._t("panel.bonus_title"), add: this._t("panel.btn_add_bonus"), icon: "⭐" };
return `
${allItems.length > 0 ? this._searchBox(kind === "penalty" ? this._t("panel.search_penalties") : this._t("panel.search_bonuses")) : ""}
${labels.add}
${allItems.length === 0 ? this._emptyState(labels.icon, kind === "penalty" ? this._t("panel.penalty_empty_title") : this._t("panel.bonus_empty_title"), kind === "penalty" ? this._t("panel.penalty_empty_description") : this._t("panel.bonus_empty_description"), `add-${kind}`, labels.add) :
items.length === 0 ? `${this._t("panel.empty_no_match", {kind: labels.plural.toLowerCase(), filter: this._esc(this._filter)})}
` : `
${this._t("panel.penbon_table_name")} ${this._t("panel.penbon_table_points")} ${this._t("panel.penbon_table_assigned")}
${items.map(item => {
const assignedNames = (item.assigned_to || []).length === 0
? `${this._t("panel.common_all_children")} `
: this._esc((item.assigned_to || []).map(id => (childById[id] && childById[id].name) || "?").join(", "));
return `
${this._mdi(item.icon)} ${this._esc(item.name)} ${this._idBadge(item.id)}${item.description ? `${this._esc(item.description)}
` : ""}
${kind === "penalty" ? "−" : "+"}${item.points}
${assignedNames}
${this._t("panel.btn_apply")}
✏
🗑
`;
}).join("")}
`}
`;
}
// -- Groups tab --------------------------------------------------------
_renderGroupsTab() {
const all = this._state.task_groups || [];
const groups = this._filterByName(all);
const choreById = Object.fromEntries((this._state.chores || []).map(c => [c.id, c]));
return `
${all.length > 0 ? this._searchBox(this._t("panel.search_groups")) : ""}
${this._t("panel.btn_add_group")}
${all.length === 0 ? this._emptyState("🔗", this._t("panel.empty_groups_title"), this._t("panel.empty_groups_copy"), "add-group", this._t("panel.btn_add_group")) :
groups.length === 0 ? `${this._t("panel.empty_no_match", {kind: this._t("panel.tab_groups").toLowerCase(), filter: this._esc(this._filter)})}
` : `
${groups.map(g => `
${this._esc(g.name)}
${this._idBadge(g.id)}
${this._t(`panel.group_policy_${g.policy}_short`)} · ${this._t("panel.group_chore_count", {count: (g.chore_ids || []).length})}
${(g.chore_ids || []).map(id => `${this._esc((choreById[id] && choreById[id].name) || this._t("panel.group_missing_chore", {id}))} `).join("")}
`).join("")}
+ ${this._t("panel.group_add_tile")}
`}
`;
}
// -- Templates tab ---------------------------------------------------------
_renderTemplatesTab() {
if (this._templateView === "picker") return this._renderTemplatePicker();
if (this._templateView === "preview") return this._renderTemplatePreview();
return this._renderTemplateManagement();
}
_renderTemplateManagement() {
const templates = this._state.templates || [];
const custom = templates.filter(t => !t.builtin);
const builtin = templates.filter(t => t.builtin);
return `
${this._t("panel.btn_create_template_toolbar")}
${custom.length > 0 ? `
${this._t("panel.template_custom_templates")}
${custom.map(t => this._renderManageTemplateCard(t, false)).join("")}
` : ""}
${custom.length > 0 ? this._t("panel.template_builtin_templates") : this._t("panel.template_builtin_packs")}
${builtin.map(t => this._renderManageTemplateCard(t, true)).join("")}
`;
}
_renderManageTemplateCard(tpl, locked) {
const count = (tpl.chores || []).length;
const pts = (tpl.chores || []).reduce((s, c) => s + (c.points || 0), 0);
return `
${this._esc(tpl.name)}${locked ? ' 🔒 ' : ""}
${this._t("panel.template_pts_total", {count, points: pts})}
${locked ? "" : `${this._t("panel.btn_edit")} `}
${locked ? "" : `${this._t("panel.btn_delete")} `}
`;
}
_renderTemplatePicker() {
const templates = this._state.templates || [];
const builtin = templates.filter(t => t.builtin);
const custom = templates.filter(t => !t.builtin);
return `
${this._t("panel.btn_cancel")}
${this._t("panel.template_builtin_packs")}
${builtin.map(t => this._renderTemplatePickerCard(t)).join("")}
${custom.length > 0 ? `
${this._t("panel.template_custom_packs")}
${custom.map(t => this._renderTemplatePickerCard(t)).join("")}
` : ""}
`;
}
_renderTemplatePickerCard(tpl) {
const count = (tpl.chores || []).length;
const pts = (tpl.chores || []).reduce((s, c) => s + (c.points || 0), 0);
return `
${this._esc(tpl.name)}
${this._t("panel.template_pts_total", {count, points: pts})}
${tpl.builtin ? this._t("panel.template_builtin") : this._t("panel.template_custom")}
${(tpl.chores || []).map(c => `${this._esc(c.name)} `).join("")}
`;
}
_renderTemplatePreview() {
const tpl = this._templateSelected;
if (!tpl) return "";
const chores = this._templateChores;
const totalPts = chores.reduce((s, c) => s + (c.points || 0), 0);
return `
${tpl.builtin ? this._t("panel.template_builtin") : this._t("panel.template_custom")}
${this._t("panel.btn_back")}
${this._t("panel.template_preview_hint")}
${chores.map((c, i) => this._renderTemplateChoreCard(c, i)).join("")}
${chores.length > 0 ? `
${this._t("panel.template_confirm_bar", {count: chores.length, points: totalPts})}
${this._t("panel.template_create_btn", {count: chores.length})}
` : `${this._t("panel.empty_template_all_removed")}
`}
`;
}
_renderTemplateChoreCard(chore, idx) {
const expanded = chore._expanded;
const schedLabel = (chore.due_days || []).length === 0 ? this._t("panel.common_daily") : (chore.due_days || []).map(d => this._labelOf(DAYS, d)).join(", ");
return `
${expanded ? `
${this._t("panel.chore_time_category_label")}
${this._timeCategoryOptions().map(t => `${this._esc(t.l)} `).join("")}
${this._t("panel.template_assignment_mode_label")}
${ASSIGNMENT_MODES.map(m => `${this._t(m.lk).split(" — ")[0]} `).join("")}
${this._t("panel.template_schedule_days_label")}
${DAYS.map(d => `${this._t(d.lk)} `).join("")}
` : ""}
`;
}
_renderSaveTemplateDialog() {
if (!this._saveTemplateDialog) return "";
const chores = this._state.chores || [];
return `
${this._t("panel.template_save_title")}
✕
`;
}
_selectTemplate(templateId) {
const templates = this._state.templates || [];
const tpl = templates.find(t => t.id === templateId);
if (!tpl) return;
this._templateSelected = tpl;
this._templateChores = (tpl.chores || []).map(c => ({ ...c, _expanded: false }));
this._templateView = "preview";
this._render();
}
async _doApplyTemplate() {
const chores = this._templateChores.map(c => {
const { _expanded, ...rest } = c;
return rest;
});
if (chores.length === 0) return;
const { ok, err } = await this._callWS({ type: "taskmate/templates/apply", chores });
if (ok) {
this._showToast("success", this._t("panel.toast_created_chores", {count: chores.length}));
this._templateView = null;
this._templateSelected = null;
this._templateChores = [];
await this._fetchState();
this._activeTab = "chores";
this._render();
} else {
this._showToast("error", err || this._t("panel.toast_template_failed_apply"));
}
}
async _doSaveFromChores() {
const name = this.querySelector('[data-field="tpl_save_name"]')?.value?.trim();
const icon = this.querySelector('[data-field="tpl_save_icon"]')?.value?.trim() || "mdi:clipboard-list";
const checkboxes = this.querySelectorAll('[data-tpl-chore-check]:checked');
const chore_ids = Array.from(checkboxes).map(cb => cb.dataset.tplChoreCheck);
if (!name || chore_ids.length === 0) {
this._showToast("error", this._t("panel.toast_name_enter_select"));
return;
}
const { ok, err } = await this._callWS({ type: "taskmate/templates/save_from_chores", chore_ids, name, icon });
if (ok) {
this._showToast("success", this._t("panel.toast_template_saved", {name}));
this._saveTemplateDialog = false;
await this._fetchState();
this._render();
} else {
this._showToast("error", err || this._t("panel.toast_template_failed_save"));
}
}
async _confirmDeleteTemplate(templateId) {
const templates = this._state.templates || [];
const tpl = templates.find(t => t.id === templateId);
if (!tpl) return;
if (!confirm(this._t("panel.confirm_delete_template", {name: tpl.name}))) return;
const { ok, err } = await this._callWS({ type: "taskmate/templates/delete", template_id: templateId });
if (ok) {
this._showToast("success", this._t("panel.toast_template_deleted"));
await this._fetchState();
} else {
this._showToast("error", err || this._t("panel.toast_template_failed_delete"));
}
}
_openCreateTemplateDialog() {
this._openDialog({
kind: "create-template",
data: { name: "", icon: "mdi:clipboard-list", chores: [{ name: "", points: 5, time_category: "anytime", schedule_mode: "specific_days", due_days: [], requires_approval: false, assignment_mode: "everyone", daily_limit: 1, completion_sound: "coin" }] },
});
}
_openEditTemplateDialog(templateId) {
const templates = this._state.templates || [];
const tpl = templates.find(t => t.id === templateId);
if (!tpl) return;
this._openDialog({
kind: "edit-template",
data: { template_id: templateId, name: tpl.name, icon: tpl.icon || "mdi:clipboard-list", chores: (tpl.chores || []).map(c => ({ ...c })) },
});
}
async _doSaveCreatedTemplate() {
const d = this._dialog?.data;
if (!d || !d.name?.trim() || !d.chores?.length) { this._showToast("error", this._t("panel.toast_name_and_chore_required")); return; }
const { ok, err } = await this._callWS({ type: "taskmate/templates/create", name: d.name.trim(), icon: d.icon || "mdi:clipboard-list", chores: d.chores });
if (ok) { this._showToast("success", this._t("panel.toast_template_created", {name: d.name})); this._closeDialog(true); await this._fetchState(); }
else { this._showToast("error", err || this._t("panel.toast_template_failed_create")); }
}
async _doSaveEditedTemplate() {
const d = this._dialog?.data;
if (!d || !d.template_id || !d.name?.trim()) { this._showToast("error", this._t("panel.toast_name_required_template")); return; }
const { ok, err } = await this._callWS({ type: "taskmate/templates/update", template_id: d.template_id, name: d.name.trim(), icon: d.icon || "mdi:clipboard-list", chores: d.chores || [] });
if (ok) { this._showToast("success", this._t("panel.toast_template_updated")); this._closeDialog(true); await this._fetchState(); }
else { this._showToast("error", err || this._t("panel.toast_template_failed_update")); }
}
async _doUndoTransaction(id) {
if (!id) return;
if (!confirm(this._t("panel.activity_undo_confirm"))) return;
const { ok, err } = await this._callWS({ type: "taskmate/undo_transaction", transaction_id: id });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.activity_undone"));
}
// -- Audit tab ---------------------------------------------------------
async _doClearAudit() {
if (!confirm(this._t("panel.audit_clear_confirm"))) return;
const { ok, err } = await this._callWS({ type: "taskmate/audit/clear" });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t("panel.audit_cleared"));
}
_renderAuditTab() {
const entries = this._state.audit_log || [];
const rows = entries.map(a => {
let when = a.ts || "";
try { if (a.ts) when = new Date(a.ts).toLocaleString(); } catch (e) {}
return `
${this._esc(when)}
${this._esc(a.user_name || a.user_id || "—")}
${this._esc(a.action || "")}
${this._esc(a.target || "")}
`;
}).join("");
return `
${this._t("panel.tab_audit")} ${this._t("panel.audit_hint")}
${entries.length ? `
${this._t("panel.audit_clear")}` : ""}
${entries.length ? `
${this._t("panel.audit_when")}
${this._t("panel.audit_user")}
${this._t("panel.audit_action")}
${this._t("panel.audit_target")}
${rows}
` : `
${this._t("panel.audit_empty")}
`}
`;
}
// -- Settings tab ------------------------------------------------------
_renderSettingsTab() {
const s = this._state.settings || {};
const notifyServices = Object.keys((this._hass && this._hass.services && this._hass.services.notify) || {});
const notifyOptions = [
{ v: "", l: this._t("panel.settings_notify_none") },
...notifyServices.map(k => ({ v: `notify.${k}`, l: `notify.${k}` })),
];
return `
${this._t("panel.settings_currency_title")}
${this._t("panel.settings_currency_hint")}
${this._t("panel.settings_points_icon_label")}${this._t("panel.settings_points_icon_hint")}
${this._t("panel.settings_card_design_label")}${this._t("panel.settings_card_design_hint")}
${["classic","playroom","console","cleanpro"].map(v => `${this._esc(this._t("common.design." + v))} `).join("")}
${this._t("panel.settings_parents_title")}
${this._t("panel.settings_parents_hint")}
${(this._haUsers || []).filter(u => !u.is_admin).map(u => `
${this._esc(u.name)}
`).join("")
|| `
${this._t("panel.settings_parents_empty")}
`}
${this._t("panel.settings_history_title")}
${this._t("panel.settings_streak_reset_label")}${this._t("panel.settings_streak_reset_hint")}
${STREAK_MODES.map(m => `${this._esc(this._t(m.lk))} `).join("")}
${this._t("panel.settings_streak_all_chores_label")}${this._t("panel.settings_streak_all_chores_hint")}
${this._t("panel.settings_surprise_enabled_label")}${this._t("panel.settings_surprise_enabled_hint")}
${this._t("panel.settings_unlock_allowlist")}${this._t("panel.settings_unlock_allowlist_hint")}
${(s.unlock_allowlist || []).map(e => `
${this._esc(e)}
✕
`).join("") || `${this._t("panel.settings_unlock_allowlist_empty")} `}
${this._entityPickerField("", "_allowlist_pick", "",
["switch", "light", "media_player", "input_boolean", "automation", "script", "fan", "climate"],
this._t("panel.settings_unlock_allowlist_add"))}
${this._t("panel.settings_roulette_enabled")}${this._t("panel.settings_roulette_enabled_hint")}
${this._t("panel.settings_decay_enabled_label")}${this._t("panel.settings_decay_enabled_hint")}
${this._t("panel.settings_allow_negative_label")}${this._t("panel.settings_allow_negative_hint")}
${this._t("panel.settings_spendcap_enabled_label")}${this._t("panel.settings_spendcap_enabled_hint")}
${this._t("panel.settings_interest_enabled_label")}${this._t("panel.settings_interest_enabled_hint")}
${this._renderTimePeriodsSection()}
${this._renderVacationSection()}
${this._t("panel.settings_bonuses_title")}
${this._t("panel.settings_streak_milestones_label")}${this._t("panel.settings_streak_milestones_hint")}
${this._t("panel.settings_perfect_week_label")}${this._t("panel.settings_perfect_week_hint")}
${this._t("panel.settings_perfect_week_all_chores_label")}${this._t("panel.settings_perfect_week_all_chores_hint")}
${this._t("panel.settings_celebration_notify_label")}${this._t("panel.settings_celebration_notify_hint")}
${this._t("panel.settings_celebration_tier_label")}${this._t("panel.settings_celebration_tier_hint")}
${this._t("panel.settings_celebration_tier_1")}
${this._t("panel.settings_celebration_tier_2")}
${this._t("panel.settings_celebration_tier_3")}
${this._t("panel.settings_avatars_title")}
${this._t("panel.settings_avatars_label")}${this._t("panel.settings_avatars_hint", {count: (this._state.avatar_catalog || []).length})}
${this._t("panel.settings_avatars_btn")}
${this._t("panel.settings_chore_rotation_title")}
${this._t("panel.settings_skip_confirm_label")}${this._t("panel.settings_skip_confirm_hint")}
${this._t("panel.settings_notifications_title")}
${this._t("panel.settings_notify_label")}${this._t("panel.settings_notify_hint")}
${notifyOptions.map(o => `${this._esc(o.l)} `).join("")}
${this._t("panel.settings_show_ids_label")} ${this._t("panel.settings_show_ids_hint")}
${this._t("panel.settings_show_ids_label")}
${this._t("panel.backup_title")} ${this._t("panel.backup_hint")}
${this._t("panel.family_goal_title")} ${this._t("panel.family_goal_hint")}
${this._t("panel.family_goal_enabled")}
${this._t("panel.allowance_title")} ${this._t("panel.allowance_hint")}
${this._t("panel.allowance_enabled")}
${(this._state.allowance_payouts || []).length ? `
${this._t("panel.allowance_ledger")}
${(this._state.allowance_payouts || []).slice(0, 10).map(p => `
${this._esc((p.date || "").slice(0, 10))}
${this._esc(p.child_name || "")}
${p.points} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}
`).join("")}
` : ""}
${this._t("panel.ics_title")} ${this._t("panel.ics_hint")}
${this._icsUrl ? `
${this._t("panel.ics_copy")}
${this._t("panel.ics_regenerate")}
` : `
${this._t("panel.ics_show")}
`}
`;
}
// -- Notifications tab -------------------------------------------------
_renderNotificationsTab() {
if (!this._notifState) {
return `${this._t("panel.notif_loading")}
`;
}
const ns = this._notifState;
const services = this._notifyServices || [];
// Build full recipient list (children + parents) used by matrix columns + chip picker
const recipients = [
...ns.recipients.children.map(c => ({ id: c.id, name: c.name, kind: "child", notify_service: c.notify_service })),
...ns.recipients.parents.map(p => ({ id: p.id, name: p.name, kind: "parent", notify_service: p.notify_service, enabled: p.enabled })),
];
return `
${this._renderNotifRecipientsSection(ns, services)}
${this._renderNotifMatrixSection(ns, recipients)}
${this._renderNotifCustomSection(ns, recipients)}
${this._t("panel.notif_power_user_note_title")}
${this._t("panel.notif_power_user_note_body")}
`;
}
_renderNotifRecipientsSection(ns, services) {
const optTags = (selected) => {
const opts = [`${this._t("panel.notif_none")} `];
for (const s of services) opts.push(`${this._esc(s)} `);
return opts.join("");
};
return `
${this._t("panel.notif_section_recipients")}
${this._t("panel.notif_section_recipients_desc")}
${this._t("panel.notif_recipients_children")}
${ns.recipients.children.map(c => {
const cid = c.id.replace(/^child:/, "");
return `
`;}).join("")}
${this._t("panel.notif_recipients_parents")}
${ns.recipients.parents.map(p => `
${optTags(p.notify_service)}
×
`).join("")}
+ ${this._t("panel.notif_add_parent")}
`;
}
_renderNotifMatrixSection(ns, recipients) {
return `
${this._t("panel.notif_section_matrix")}
${this._t("panel.notif_section_matrix_desc")}
${this._t("panel.notif_matrix_col_notification")}
${recipients.map(r => `${this._esc(r.name)} `).join("")}
${ns.types.map(t => this._renderNotifMatrixRow(t, ns.config[t.id] || { master_enabled: false, routes: {} }, recipients, ns.settings)).join("")}
`;
}
_renderNotifMatrixRow(t, c, recipients, settings) {
const active = !!c.master_enabled;
return `
${recipients.map(r => this._renderNotifMatrixCell(t, c, r)).join("")}
`;
}
_renderNotifMatrixCell(t, c, r) {
const valid =
t.audience === "both" ||
(t.audience === "child" && r.kind === "child") ||
(t.audience === "parent" && r.kind === "parent");
if (!valid) return `— `;
const route = c.routes[r.id] || { enabled: false, time: null };
return `
${t.per_recipient_time ? `
` : ""}
`;
}
_renderNotifCustomSection(ns, recipients) {
return `
${this._t("panel.notif_section_custom")}
+ ${this._t("panel.notif_add_custom")}
${this._t("panel.notif_section_custom_desc")}
${(ns.custom || []).length === 0 ? `
${this._t("panel.notif_custom_empty")}
` : ""}
${(ns.custom || []).map(n => this._renderNotifCustomCard(n, recipients)).join("")}
`;
}
_renderNotifCustomCard(n, recipients) {
const days = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
return `
`;
}
// -- Dialogs -----------------------------------------------------------
_openGiftDialog() {
const children = this._state.children || [];
if (children.length < 2) return;
this._openDialog({ kind: "gift", data: { from: children[0].id, to: children[1].id, points: 10 } });
}
_renderGiftDialog() {
const d = this._dialog.data;
const opts = (this._state.children || []).map(c => ({ v: c.id, l: c.name }));
return this._dialogShell(this._t("panel.gift_dialog_title"),
[
this._select(this._t("panel.gift_from"), "from", d.from, opts),
this._select(this._t("panel.gift_to"), "to", d.to, opts),
this._field(this._t("panel.gift_amount"), "points", d.points, "number"),
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.gift_send")} `
);
}
async _doGiftPoints() {
const d = this._dialog.data;
const pts = Number(d.points) || 0;
if (!d.from || !d.to || d.from === d.to) { this._showToast("err", this._t("panel.gift_err_same")); return; }
if (pts < 1) { this._showToast("err", this._t("panel.gift_err_amount")); return; }
const { ok, err } = await this._callWS({ type: "taskmate/gift_points", from_child_id: d.from, to_child_id: d.to, points: pts });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t("panel.gift_sent"));
}
_openSwapDialog(choreId) {
const c = (this._state.chores || []).find(x => x.id === choreId);
if (!c) return;
const cur = c.assignment_current_child_id || "";
const opts = (this._state.children || []).filter(ch => ch.id !== cur);
this._openDialog({ kind: "swap", data: { chore_id: choreId, chore_name: c.name, requester: (opts[0] && opts[0].id) || "" } });
}
_renderSwapDialog() {
const d = this._dialog.data;
const c = (this._state.chores || []).find(x => x.id === d.chore_id) || {};
const cur = c.assignment_current_child_id || "";
const opts = (this._state.children || []).filter(ch => ch.id !== cur).map(ch => ({ v: ch.id, l: ch.name }));
return this._dialogShell(this._t("panel.swap_dialog_title", { chore: d.chore_name }),
this._select(this._t("panel.swap_requester"), "requester", d.requester, opts),
`${this._t("panel.btn_cancel")}
${this._t("panel.swap_create")} `
);
}
async _doRequestSwap() {
const d = this._dialog.data;
if (!d.requester) { this._showToast("err", this._t("panel.swap_pick_child")); return; }
const { ok, err } = await this._callWS({ type: "taskmate/request_swap", chore_id: d.chore_id, requester_id: d.requester });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
this._closeDialog(true); await this._fetchState();
this._showToast("ok", this._t("panel.swap_requested"));
}
async _doSwap(action, reqId) {
const type = action === "approve" ? "taskmate/approve_swap" : "taskmate/reject_swap";
const { ok, err } = await this._callWS({ type, request_id: reqId });
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
await this._fetchState();
this._showToast("ok", this._t(action === "approve" ? "panel.swap_approved" : "panel.swap_rejected"));
}
_renderDialog() {
if (this._dialog.kind === "swap") return this._renderSwapDialog();
if (this._dialog.kind === "gift") return this._renderGiftDialog();
if (this._dialog.kind === "child") return this._renderChildDialog();
if (this._dialog.kind === "chore") return this._renderChoreDialog();
if (this._dialog.kind === "reward") return this._renderRewardDialog();
if (this._dialog.kind === "penalty") return this._renderPenBonDialog("penalty");
if (this._dialog.kind === "bonus") return this._renderPenBonDialog("bonus");
if (this._dialog.kind === "group") return this._renderGroupDialog();
if (this._dialog.kind === "quest") return this._renderQuestDialog();
if (this._dialog.kind === "avatar-catalog") return this._renderAvatarCatalogDialog();
if (this._dialog.kind === "challenge") return this._renderChallengeDialog();
if (this._dialog.kind === "apply-penalty") return this._renderApplyDialog("penalty");
if (this._dialog.kind === "apply-bonus") return this._renderApplyDialog("bonus");
if (this._dialog.kind === "bulk-chore") return this._renderBulkChoreDialog();
if (this._dialog.kind === "reorder") return this._renderReorderDialog();
if (this._dialog.kind === "create-template" || this._dialog.kind === "edit-template") return this._renderCreateEditTemplateDialog();
if (this._dialog.kind === "badge") return this._renderBadgeDialog();
if (this._dialog.kind === "award-badge") return this._renderAwardDialog();
return "";
}
_renderCreateEditTemplateDialog() {
const d = this._dialog.data;
const isEdit = this._dialog.kind === "edit-template";
return `
${isEdit ? this._t("panel.template_edit_title") : this._t("panel.template_create_title")}
✕
${this._t("panel.template_chores_in_template")}
${(d.chores || []).map((c, i) => `
${this._esc(c.name || this._t("panel.template_unnamed"))}
${this._t("panel.pts_display", {count: c.points || 0})}
✕
${this._t("panel.chore_time_category_label")}
${this._timeCategoryOptions().map(t => `${this._esc(t.l)} `).join("")}
${this._t("panel.template_schedule_label")}
${SCHEDULE_MODES.map(s => `${this._t(s.lk)} `).join("")}
`).join("")}
${this._t("panel.btn_add_chore_template")}
`;
}
_renderChildDialog() {
const d = this._dialog.data;
const hasAvail = !!(d.availability_entity && d.availability_entity.trim());
const hasAway = hasAvail || !!(d.unavailability_entity && d.unavailability_entity.trim());
return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_child") : this._t("panel.dialog_edit_child"),
[
this._field(this._t("panel.child_name_label"), "name", d.name, "text", this._t("panel.child_name_placeholder")),
this._iconPickerField(this._t("panel.child_avatar_label"), "avatar", d.avatar),
this._entityPickerField(this._t("panel.child_availability_label"), "availability_entity", d.availability_entity, ["binary_sensor", "sensor", "input_boolean", "person"],
this._t("panel.child_availability_hint")),
hasAvail ? this._switch(this._t("panel.child_invert_label"), "availability_inverted", d.availability_inverted,
this._t("panel.child_invert_hint")) : "",
this._entityPickerField(this._t("panel.child_unavailability_label"), "unavailability_entity", d.unavailability_entity, ["binary_sensor", "sensor", "input_boolean", "person", "calendar"],
this._t("panel.child_unavailability_hint")),
hasAway ? this._switch(this._t("panel.child_pause_streak_label"), "pause_streak_when_unavailable", d.pause_streak_when_unavailable,
this._t("panel.child_pause_streak_hint")) : "",
this._select(
this._t("panel.child_link_user_label"), "linked_user_id", d.linked_user_id || "",
[{ v: "", l: this._t("panel.child_link_user_none") }].concat(
(this._haUsers || []).map(u => ({ v: u.id, l: u.is_admin ? `${u.name} (admin)` : u.name }))
),
this._t("panel.child_link_user_hint")),
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save")} `
);
}
_renderChoreDialog() {
const d = this._dialog.data;
const children = this._state.children || [];
const groups = this._state.task_groups || [];
const memberInGroup = (d.id && groups.find(g => (g.chore_ids || []).includes(d.id))) || null;
const showSpecificDays = d.schedule_mode === "specific_days";
const showRecurring = d.schedule_mode === "recurring";
const showRotation = ["alternating", "random", "balanced"].includes(d.assignment_mode);
const isUnassigned = d.assignment_mode === "unassigned";
const calendarEntities = Object.keys((this._hass && this._hass.states) || {})
.filter(id => id.startsWith("calendar."))
.sort();
const isTimedTask = d.task_type === "timed";
return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_chore") : this._t("panel.dialog_edit_chore"),
[
this._field(this._t("panel.chore_name_label"), "name", d.name, "text"),
`
${this._field(this._t("panel.chore_description_label"), "description", d.description, "text")}
${this._iconPickerField(this._t("panel.chore_icon_label"), "icon", d.icon)}
`,
this._select(this._t("panel.chore_task_type_label"), "task_type", d.task_type || "standard", [
{ v: "standard", l: this._t("panel.chore_task_type_standard") },
{ v: "timed", l: this._t("panel.chore_task_type_timed") },
], "", true),
isTimedTask ? `
${this._field(this._t("panel.chore_points_per_window_label"), "timed_rate_points", d.timed_rate_points || 10, "number")}
${this._field(this._t("panel.chore_window_minutes_label"), "timed_rate_minutes", d.timed_rate_minutes || 5, "number")}
${this._field(this._t("panel.chore_daily_cap_label"), "timed_max_daily_minutes", d.timed_max_daily_minutes || 0, "number")}
${this._field(this._t("panel.chore_daily_limit_label"), "daily_limit", d.daily_limit, "number")}
` : `
${this._field(this._t("panel.chore_points_label"), "points", d.points, "number")}
${d.assignment_mode === "first_come" ? "" : this._field(this._t("panel.chore_daily_limit_label"), "daily_limit", d.daily_limit, "number")}
`,
this._select(this._t("panel.chore_assignment_mode_label"), "assignment_mode", d.assignment_mode, ASSIGNMENT_MODES,
this._t("panel.chore_assignment_mode_hint"), true),
!isUnassigned ? (children.length > 0 ? `
${this._t("panel.chore_assign_label")}
${children.map(c => `
${this._esc(c.name)}
`).join("")}
${this._t("panel.chore_assign_hint")}
` : `${this._t("panel.chore_assign_no_children")}
`) : "",
(this._state.chores || []).filter(x => x.id !== d.id).length > 0 ? `
${this._t("panel.chore_depends_label")}
${(this._state.chores || []).filter(x => x.id !== d.id).map(c => `
${this._esc(c.name)}
`).join("")}
${this._t("panel.chore_depends_hint")}
` : "",
showRotation && children.length > 0 ? this._select(
this._t("panel.chore_rotation_label"), "manual_start_child_id", d.manual_start_child_id,
[{ v: "", l: this._t("panel.chore_rotation_no_override") }, ...children.map(c => ({ v: c.id, l: c.name }))],
this._t("panel.chore_rotation_hint")
) : "",
this._select(this._t("panel.chore_time_category_label"), "time_category", d.time_category, this._timeCategoryOptions()),
this._field(this._t("panel.chore_claim_allowance_label"), "claim_allowance_minutes", d.claim_allowance_minutes, "number",
this._t("panel.chore_claim_allowance_hint")),
this._select(this._t("panel.chore_schedule_mode_label"), "schedule_mode", d.schedule_mode, SCHEDULE_MODES, "", true),
showSpecificDays ? `
${this._t("panel.chore_days_label")}
${DAYS.map(day => `
${this._t(day.lk)}
`).join("")}
` : "",
showRecurring ? `
${this._select(this._t("panel.chore_recurrence_label"), "recurrence", d.recurrence, RECURRENCES)}
${this._field(this._t("panel.chore_recurrence_day_label"), "recurrence_day", d.recurrence_day, "text", this._t("panel.chore_recurrence_day_hint"))}
${this._dateField(this._t("panel.chore_recurrence_start_label"), "recurrence_start", d.recurrence_start, this._t("panel.chore_recurrence_start_hint"))}
${this._select(this._t("panel.chore_first_occurrence_label"), "first_occurrence_mode", d.first_occurrence_mode, FIRST_OCCURRENCE)}
` : "",
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
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") },
{ v: "hard", l: this._t("panel.difficulty_hard") },
]),
this._dateField(this._t("panel.chore_expires_label"), "expires_on", d.expires_on, this._t("panel.chore_expires_hint")),
`
${this._field(this._t("panel.chore_due_time_label"), "due_time", d.due_time, "time", this._t("panel.chore_due_time_hint"))}
${this._field(this._t("panel.chore_early_bonus_label"), "early_bonus", d.early_bonus, "number")}
${this._field(this._t("panel.chore_late_penalty_label"), "late_penalty", d.late_penalty, "number")}
`,
this._switch(this._t("panel.chore_approval_label"), "requires_approval", d.requires_approval),
this._switch(this._t("panel.chore_require_photo_label"), "require_photo", d.require_photo,
this._t("panel.chore_require_photo_hint")),
this._switch(this._t("panel.chore_require_availability"), "require_availability", d.require_availability,
this._t("panel.chore_require_availability_hint")),
this._switch(this._t("panel.chore_mandatory_label"), "mandatory", d.mandatory,
this._t("panel.chore_mandatory_hint"), true),
d.mandatory ? this._field(this._t("panel.chore_mandatory_penalty_label"), "mandatory_penalty_points",
d.mandatory_penalty_points, "number", this._t("panel.chore_mandatory_penalty_hint")) : "",
memberInGroup ? `
${this._t("panel.chore_group_hint", {name: this._esc(memberInGroup.name), policy: memberInGroup.policy})}
` : "",
`
${this._t("panel.chore_advanced_bonus_subtasks")}
${this._t("panel.chore_advanced_bonus_subtasks_hint")}
${(d.bonus_subtasks || []).map((b, idx) => `
`).join("")}
${this._t("panel.btn_add_bonus_subtask")}
`,
`
${this._t("panel.chore_advanced_visibility")}
${this._entityPickerField(this._t("panel.chore_vis_entity_label"), "visibility_entity", d.visibility_entity, ["binary_sensor", "sensor", "switch", "input_boolean", "input_select"],
this._t("panel.chore_vis_entity_hint"))}
${this._select(this._t("panel.chore_vis_operator_label"), "visibility_operator", d.visibility_operator, VISIBILITY_OPS)}
${this._field(this._t("panel.chore_vis_value_label"), "visibility_state", d.visibility_state, "text", this._t("panel.chore_vis_value_hint"))}
${this._t("panel.chore_calendar_label")}
${calendarEntities.length === 0 ? `
${this._t("panel.chore_calendar_no_entities")} ` : `
${calendarEntities.map(cid => `
${this._esc(cid)}
`).join("")}
${this._t("panel.chore_calendar_hint")}
`}
${this._switch(this._t("panel.chore_enabled_label"), "enabled", d.enabled !== false)}
`,
this._dialog.mode === "edit" ? this._renderScheduledChanges(d) : "",
`
${this._t("panel.chore_advanced_weather")}
${this._t("panel.chore_weather_intro")}
${this._entityPickerField(this._t("panel.chore_weather_entity_label"), "weather_entity", d.weather_entity, ["weather"],
this._t("panel.chore_weather_entity_hint"))}
${d.weather_entity ? `
${this._t("panel.chore_weather_conditions_label")}
${WEATHER_CONDITIONS.map(w => `
${this._t("weather.condition_" + w.v.replace(/-/g, "_"))}
`).join("")}
${this._t("panel.chore_weather_conditions_hint")}
${this._field(this._t("panel.chore_weather_temp_min_label"), "weather_temp_min", d.weather_temp_min, "number", this._t("panel.chore_weather_temp_min_hint"))}
${this._field(this._t("panel.chore_weather_temp_max_label"), "weather_temp_max", d.weather_temp_max, "number", this._t("panel.chore_weather_temp_max_hint"))}
${this._field(this._t("panel.chore_weather_wind_max_label"), "weather_wind_max", d.weather_wind_max, "number", this._t("panel.chore_weather_wind_max_hint"))}
${this._t("panel.chore_weather_failopen_hint")}
` : ""}
`,
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save")} `
);
}
/**
* Scheduled config changes (#675) — queue an edit for a future date.
* Edit-only: a chore has to exist before a change can be queued against it.
*/
_renderScheduledChanges(d) {
const pending = (this._state.scheduled_changes || [])
.filter(c => c.chore_id === d.id && !c.applied)
.sort((a, b) => a.apply_on.localeCompare(b.apply_on));
const applied = (this._state.scheduled_changes || [])
.filter(c => c.chore_id === d.id && c.applied)
.sort((a, b) => b.apply_on.localeCompare(a.apply_on))
.slice(0, 5);
const draft = this._dialog._schedDraft || {};
const open = this._dialog._openAdvanced?.has("scheduled");
const children = this._state.children || [];
const describe = (changes) => Object.entries(changes || {})
.map(([f, v]) => {
const label = this._t("panel.sched_field_" + f);
const value = f === "assigned_to"
? (v || []).map(id => this._esc((children.find(c => c.id === id) || {}).name || id)).join(", ")
|| this._t("panel.sched_value_nobody")
: Array.isArray(v) ? v.join(", ")
: typeof v === "boolean" ? this._t(v ? "panel.sched_value_yes" : "panel.sched_value_no")
: this._esc(String(v));
return `${label}: ${value} `;
}).join(" · ");
return `
${this._t("panel.chore_advanced_scheduled")}${pending.length ? ` ${pending.length} ` : ""}
${this._t("panel.chore_scheduled_intro")}
${pending.length ? `
${pending.map(c => `
${this._esc(c.apply_on)}
${describe(c.changes)}${c.note ? `
${this._esc(c.note)}
` : ""}
✕
`).join("")}
` : `
${this._t("panel.chore_scheduled_none")} `}
${this._dateField(this._t("panel.sched_date_label"), "_sched_apply_on", draft.apply_on || "")}
${this._select(this._t("panel.sched_field_label"), "_sched_field", draft.field || "points",
SCHEDULED_FIELDS.map(f => ({ v: f.v, lk: "panel.sched_field_" + f.v })))}
${this._renderScheduledValueInput(draft, children)}
${this._field(this._t("panel.sched_note_label"), "_sched_note", draft.note || "", "text",
this._t("panel.sched_note_hint"))}
${this._t("panel.btn_add_scheduled")}
${applied.length ? `
${this._t("panel.chore_scheduled_history")}
${applied.map(c => `
${this._esc(c.apply_on)}
${describe(c.changes)}
✓
`).join("")}
` : ""}
`;
}
/** The value control depends on which field is being scheduled. */
_renderScheduledValueInput(draft, children) {
const field = draft.field || "points";
const spec = SCHEDULED_FIELDS.find(f => f.v === field) || SCHEDULED_FIELDS[0];
if (spec.kind === "children") {
const picked = draft.value_list || [];
return `
${this._t("panel.sched_value_label")}
${children.map(c => `
${this._esc(c.name)}
`).join("")}
`;
}
if (spec.kind === "days") {
const picked = draft.value_list || [];
return `
${this._t("panel.sched_value_label")}
${DAYS.map(day => `
${this._t(day.lk)}
`).join("")}
`;
}
if (spec.kind === "bool") {
return this._select(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? "true",
[{ v: "true", lk: "panel.sched_value_yes" }, { v: "false", lk: "panel.sched_value_no" }]);
}
if (spec.kind === "select") {
return this._select(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? spec.options[0].v, spec.options);
}
return this._field(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? "",
spec.kind === "number" ? "number" : "text");
}
_schedDraft() {
if (!this._dialog._schedDraft) this._dialog._schedDraft = { field: "points", value_list: [] };
return this._dialog._schedDraft;
}
_toggleScheduledListValue(value) {
const draft = this._schedDraft();
const list = [...(draft.value_list || [])];
const i = list.indexOf(value);
if (i >= 0) list.splice(i, 1); else list.push(value);
draft.value_list = list;
this._render();
}
async _saveAllowlist(list) {
const { ok, err } = await this._callWS({ type: "taskmate/update_settings", unlock_allowlist: list });
if (!ok) { this._showToast("err", err); return; }
await this._fetchState();
this._render();
}
_removeFromAllowlist(entityId) {
const current = (this._state.settings || {}).unlock_allowlist || [];
this._saveAllowlist(current.filter(e => e !== entityId));
}
_addToAllowlist(entityId) {
const current = (this._state.settings || {}).unlock_allowlist || [];
if (!entityId || current.includes(entityId)) return;
this._saveAllowlist([...current, entityId]);
}
async _addScheduledChange() {
const d = this._dialog.data;
const draft = this._schedDraft();
const spec = SCHEDULED_FIELDS.find(f => f.v === (draft.field || "points")) || SCHEDULED_FIELDS[0];
if (!draft.apply_on) { this._showToast("err", this._t("panel.toast_sched_date_required")); return; }
let value;
if (spec.kind === "children" || spec.kind === "days") {
value = draft.value_list || [];
} else if (spec.kind === "bool") {
value = (draft.value ?? "true") === "true";
} else if (spec.kind === "number") {
if (draft.value === "" || draft.value == null) { this._showToast("err", this._t("panel.toast_sched_value_required")); return; }
value = Number(draft.value);
} else {
value = draft.value ?? "";
}
const { ok, err } = await this._callWS({
type: "taskmate/scheduled/add",
chore_id: d.id,
apply_on: draft.apply_on,
changes: { [spec.v]: value },
note: draft.note || "",
});
if (!ok) { this._showToast("err", err); return; }
this._dialog._schedDraft = { field: spec.v, value_list: [] };
this._showToast("ok", this._t("panel.toast_sched_added"));
await this._fetchState();
this._render();
}
async _removeScheduledChange(changeId) {
const { ok, err } = await this._callWS({ type: "taskmate/scheduled/remove", change_id: changeId });
if (!ok) { this._showToast("err", err); return; }
this._showToast("ok", this._t("panel.toast_sched_removed"));
await this._fetchState();
this._render();
}
_renderRewardDialog() {
const d = this._dialog.data;
const children = this._state.children || [];
const pointsName = this._state.settings.points_name || this._t("common.points");
return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_reward") : this._t("panel.dialog_edit_reward"),
[
this._field(this._t("panel.chore_name_label"), "name", d.name, "text"),
this._field(this._t("panel.chore_description_label"), "description", d.description, "text"),
`
${this._field(this._t("panel.reward_cost_label", {points_name: pointsName}), "cost", d.cost, "number")}
${this._iconPickerField(this._t("panel.reward_icon_label"), "icon", d.icon)}
`,
children.length > 0 ? `
${this._t("panel.reward_assigned_label")}
${children.map(c => `
${this._esc(c.name)}
`).join("")}
${this._t("panel.reward_assigned_hint")}
` : "",
// Jackpot first; toggling it re-renders so the Pool switch below can be
// hidden — jackpots are always pool-mode (#552), so the separate Pool
// toggle would be redundant and confusing.
this._switch(this._t("panel.reward_is_jackpot_label"), "is_jackpot", d.is_jackpot,
this._t("panel.reward_is_jackpot_hint"), true),
d.is_jackpot ? "" : this._switch(this._t("panel.reward_pool_label"), "pool_enabled", d.pool_enabled,
this._t("panel.reward_pool_hint")),
`
${this._field(this._t("panel.reward_quantity_label"), "quantity_str", d.quantity_str, "text", this._t("panel.reward_quantity_hint"))}
${this._dateField(this._t("panel.reward_expires_label"), "expires_at", d.expires_at, this._t("panel.reward_expires_hint"))}
`,
this._switch(this._t("panel.reward_restock_label"), "restock_enabled", d.restock_enabled,
this._t("panel.reward_restock_hint")),
`
${this._field(this._t("panel.reward_restock_amount"), "restock_amount", d.restock_amount, "number")}
${this._select(this._t("panel.reward_restock_period"), "restock_period", d.restock_period || "weekly", [
{ v: "daily", l: this._t("panel.reward_restock_daily") },
{ v: "weekly", l: this._t("panel.reward_restock_weekly") },
{ v: "monthly", l: this._t("panel.reward_restock_monthly") },
])}
`,
this._renderRewardUnlock(d),
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save")} `
);
}
/**
* Timed unlock (#678). Only offers entities the parent has allowlisted in
* Settings — a reward must not be able to reach arbitrary devices.
*/
_renderRewardUnlock(d) {
const allow = (this._state.settings || {}).unlock_allowlist || [];
const open = this._dialog._openAdvanced?.has("unlock");
if (!allow.length) {
return `
${this._t("panel.reward_unlock_section")}
${this._t("panel.reward_unlock_no_allowlist")}
`;
}
const options = [{ v: "", lk: "panel.reward_unlock_none" }]
.concat(allow.map(e => ({ v: e, l: e })));
return `
${this._t("panel.reward_unlock_section")}
${this._t("panel.reward_unlock_intro")}
${this._select(this._t("panel.reward_unlock_entity"), "unlock_entity", d.unlock_entity || "", options)}
${d.unlock_entity ? this._field(this._t("panel.reward_unlock_minutes"), "unlock_minutes",
d.unlock_minutes ?? 30, "number", this._t("panel.reward_unlock_minutes_hint")) : ""}
`;
}
_renderAvatarCatalogDialog() {
const rows = (this._dialog.data.catalog || []);
const typeOpts = [
{ v: "free", l: this._t("panel.avatar_unlock_free") },
{ v: "level", l: this._t("panel.avatar_unlock_level") },
{ v: "points", l: this._t("panel.avatar_unlock_points") },
{ v: "streak", l: this._t("panel.avatar_unlock_streak") },
];
const rowHtml = rows.map((a, i) => `
`).join("");
return this._dialogShell(this._t("panel.avatar_dialog_title"),
[
`${this._t("panel.avatar_dialog_hint")}
`,
rows.length ? `${rowHtml}
` : `${this._t("panel.avatar_empty")}
`,
`+ ${this._t("panel.avatar_add_row")} `,
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save")} `
);
}
_renderQuestDialog() {
const d = this._dialog.data;
const children = this._state.children || [];
const pointsName = this._state.settings.points_name || this._t("common.points");
const chores = this._state.chores || [];
const choreById = {};
chores.forEach(c => { choreById[c.id] = c; });
const steps = d.steps || [];
// Ordered step list with move/remove controls
const stepRows = steps.map((sid, i) => `
${i + 1}
${this._esc(choreById[sid] ? choreById[sid].name : this._t("panel.quest_missing_chore"))}
▲
▼
✕
`).join("");
return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_quest") : this._t("panel.dialog_edit_quest"),
[
this._field(this._t("panel.chore_name_label"), "name", d.name, "text"),
this._field(this._t("panel.chore_description_label"), "description", d.description, "text"),
`
${this._field(this._t("panel.quest_bonus_label", {points_name: pointsName}), "bonus_points", d.bonus_points, "number")}
${this._iconPickerField(this._t("panel.reward_icon_label"), "icon", d.icon)}
`,
`
${this._t("panel.quest_steps_label")}
${this._t("panel.quest_steps_hint")}
${steps.length ? `
${stepRows}
` : `
${this._t("panel.quest_no_steps")}
`}
${chores.map(c => `
${this._esc(c.name)}
`).join("")}
`,
children.length > 0 ? `
${this._t("panel.quest_assigned_label")}
${children.map(c => `
${this._esc(c.name)}
`).join("")}
${this._t("panel.quest_assigned_hint")}
` : "",
this._switch(this._t("panel.quest_repeatable_label"), "repeatable", d.repeatable,
this._t("panel.quest_repeatable_hint")),
this._switch(this._t("panel.quest_active_label"), "active", d.active !== false,
this._t("panel.quest_active_hint")),
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save")} `
);
}
_renderPenBonDialog(kind) {
const d = this._dialog.data;
const children = this._state.children || [];
const isPenalty = kind === "penalty";
return this._dialogShell(this._dialog.mode === "add" ? (isPenalty ? this._t("panel.dialog_add_penalty") : this._t("panel.dialog_add_bonus")) : (isPenalty ? this._t("panel.dialog_edit_penalty") : this._t("panel.dialog_edit_bonus")),
[
this._field(this._t("panel.chore_name_label"), "name", d.name, "text"),
this._field(this._t("panel.chore_description_label"), "description", d.description, "text"),
`
${this._field(this._t(isPenalty ? "panel.penbon_points_deduct" : "panel.penbon_points_award"), "points", d.points, "number")}
${this._iconPickerField(this._t("panel.reward_icon_label"), "icon", d.icon)}
`,
children.length > 0 ? `
${this._t("panel.penbon_applies_to")}
${children.map(c => `
${this._esc(c.name)}
`).join("")}
${this._t("panel.penbon_applies_to_hint")}
` : "",
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save")} `
);
}
_renderApplyDialog(kind) {
const d = this._dialog.data;
const item = d.item;
const children = this._state.children || [];
const isPenalty = kind === "penalty";
const eligible = (item.assigned_to || []).length === 0
? children
: children.filter(c => (item.assigned_to || []).includes(c.id));
return this._dialogShell(isPenalty ? this._t("panel.dialog_apply_penalty", {name: item.name}) : this._t("panel.dialog_apply_bonus", {name: item.name}),
`${isPenalty ? this._t("panel.penalty_apply_text", {points: item.points}) : this._t("panel.bonus_apply_text", {points: item.points})}
${eligible.length === 0 ? `${this._t("panel.penbon_no_eligible", {kind})}
` : `
${eligible.map(c => `
${this._esc(c.name)}
`).join("")}
`}`,
`${this._t("panel.btn_close")} `
);
}
_renderGroupDialog() {
const d = this._dialog.data;
const chores = (this._state.chores || []).filter(c => c.assignment_mode && !["everyone", "first_come"].includes(c.assignment_mode));
return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_group") : this._t("panel.dialog_edit_group"),
[
this._field(this._t("panel.common_name"), "name", d.name, "text"),
this._select(this._t("panel.group_policy_label"), "policy", d.policy, TASK_GROUP_POLICIES,
this._t("panel.group_policy_hint")),
chores.length === 0 ? `
${this._t("panel.group_no_rotation_chores")}
` : `
${this._t("panel.group_chores_label")}
${chores.map(c => `
${this._esc(c.name)}
`).join("")}
`,
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save")} `
);
}
_renderBulkChoreDialog() {
const d = this._dialog.data;
const children = this._state.children || [];
return this._dialogShell(this._t("panel.bulk_title"),
[
`
${this._t("panel.bulk_chore_names_label")}
${this._t("panel.bulk_chore_names_hint")}
`,
`
${this._field(this._t("panel.bulk_points_label"), "points", d.points, "number")}
${this._field(this._t("panel.chore_daily_limit_label"), "daily_limit", d.daily_limit, "number")}
`,
children.length > 0 ? `
${this._t("panel.bulk_assigned_to")}
${children.map(c => `
${this._esc(c.name)}
`).join("")}
${this._t("panel.bulk_leave_empty_hint")}
` : "",
this._select(this._t("panel.chore_time_category_label"), "time_category", d.time_category, this._timeCategoryOptions()),
this._select(this._t("panel.chore_schedule_mode_label"), "schedule_mode", d.schedule_mode, SCHEDULE_MODES, "", true),
d.schedule_mode === "specific_days" ? `
${this._t("panel.bulk_days_label")}
${DAYS.map(day => `
${this._t(day.lk)}
`).join("")}
` : "",
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
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") },
{ v: "hard", l: this._t("panel.difficulty_hard") },
]),
this._dateField(this._t("panel.chore_expires_label"), "expires_on", d.expires_on, this._t("panel.chore_expires_hint")),
`
${this._field(this._t("panel.chore_due_time_label"), "due_time", d.due_time, "time", this._t("panel.chore_due_time_hint"))}
${this._field(this._t("panel.chore_early_bonus_label"), "early_bonus", d.early_bonus, "number")}
${this._field(this._t("panel.chore_late_penalty_label"), "late_penalty", d.late_penalty, "number")}
`,
this._switch(this._t("panel.chore_approval_label"), "requires_approval", d.requires_approval),
this._switch(this._t("panel.chore_require_photo_label"), "require_photo", d.require_photo,
this._t("panel.chore_require_photo_hint")),
].join(""),
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_create_chores")} `
);
}
_renderReorderDialog() {
const d = this._dialog.data;
const choreById = Object.fromEntries((this._state.chores || []).map(c => [c.id, c]));
const title = d.global ? this._t("panel.reorder_global_title") : this._t("panel.reorder_title", {name: d.name});
const hint = d.global ? this._t("panel.reorder_global_hint") : this._t("panel.reorder_hint");
return this._dialogShell(title,
`${hint}
${(d.order || []).map((id, idx, arr) => {
const c = choreById[id];
if (!c) return "";
return `
⠿
${this._esc(c.name)}
${c.points}
▲
▼
`;
}).join("")}
`,
`${this._t("panel.btn_cancel")}
${this._t("panel.btn_save_order")} `
);
}
// ---- form helpers ----------------------------------------------------
_dialogShell(title, body, footer) {
return `
`;
}
_field(label, name, value, type = "text", hint = "") {
return `
${this._esc(label)}
${hint ? `${hint} ` : ""}
`;
}
_dateField(label, name, value, hint = "") {
return `
${this._esc(label)}
${hint ? `${hint} ` : ""}
`;
}
_select(label, name, value, options, hint = "", rerender = false) {
return `
${this._esc(label)}
${options.map(o => `${this._esc(o.lk ? this._t(o.lk) : o.l)} `).join("")}
${hint ? `${hint} ` : ""}
`;
}
_switch(label, name, checked, hint = "", rerender = false) {
return `
${this._esc(label)}
${hint ? `
${hint} ` : ""}
`;
}
_iconPickerField(label, name, value) {
return `
${this._esc(label)}
`;
}
_entityDomainIcon(entityId) {
const domain = (entityId || "").split(".")[0];
const map = {
binary_sensor: "mdi:checkbox-marked-circle-outline",
sensor: "mdi:eye",
input_boolean: "mdi:toggle-switch",
person: "mdi:account",
calendar: "mdi:calendar",
switch: "mdi:light-switch",
input_select: "mdi:form-dropdown",
};
return map[domain] || "mdi:puzzle";
}
// Route an entity-picker change to the open dialog, or to the settings-page
// entity draft when no dialog is open (settings entity pickers).
_setEntityField(field, value) {
if (this._dialog?.data) { this._dialog.data[field] = value; }
else { (this._settingsEntityDraft = this._settingsEntityDraft || {})[field] = value; }
}
_entityPickerField(label, name, value, domains, hint = "") {
const friendly = value && this._hass?.states?.[value]?.attributes?.friendly_name || "";
const icon = value ? (this._hass?.states?.[value]?.attributes?.icon || this._entityDomainIcon(value)) : "";
return `
${this._esc(label)}
${value
? `
${this._esc(friendly || value)}
✕
`
: `
`
}
${hint ? `
${hint} ` : ""}
`;
}
_openEntityDropdown(input) {
this._closeEntityDropdown();
const picker = input.closest(".tm-ep");
if (!picker || !this._hass?.states) return;
const field = picker.dataset.epField;
const domains = (picker.dataset.epDomains || "").split(",").filter(Boolean);
const query = input.value.toLowerCase();
const entities = Object.entries(this._hass.states)
.filter(([id, st]) => {
if (domains.length && !domains.some(d => id.startsWith(d + "."))) return false;
if (!query) return true;
const fn = st.attributes?.friendly_name || "";
return id.toLowerCase().includes(query) || fn.toLowerCase().includes(query);
})
.sort(([a], [b]) => a.localeCompare(b))
.slice(0, 50);
if (!entities.length) return;
const dd = document.createElement("div");
dd.className = "tm-ep-dropdown";
entities.forEach(([id, st]) => {
const fn = st.attributes?.friendly_name || "";
const stateIcon = st.attributes?.icon || this._entityDomainIcon(id);
const opt = document.createElement("div");
opt.className = "tm-ep-option";
opt.dataset.act = "pick-entity";
opt.dataset.field = field;
opt.dataset.value = id;
const iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", stateIcon);
iconEl.className = "tm-ep-option-icon";
opt.appendChild(iconEl);
const textWrap = document.createElement("div");
textWrap.className = "tm-ep-option-text";
const idDiv = document.createElement("div");
idDiv.className = "tm-ep-id";
idDiv.textContent = fn || id;
textWrap.appendChild(idDiv);
if (fn) {
const nameDiv = document.createElement("div");
nameDiv.className = "tm-ep-name";
nameDiv.textContent = id;
textWrap.appendChild(nameDiv);
}
opt.appendChild(textWrap);
dd.appendChild(opt);
});
picker.appendChild(dd);
}
_closeEntityDropdown() {
const dd = this.querySelector(".tm-ep-dropdown");
if (dd) dd.remove();
}
// ---- chore row action menu (kebab) -----------------------------------
_toggleRowMenu(btn) {
const id = btn.dataset.id;
const wasOpen = this._rowMenuEl && this._rowMenuForId === id;
this._closeRowMenu();
if (!wasOpen) this._openRowMenu(btn, id);
}
_openRowMenu(btn, id) {
const c = (this._state.chores || []).find(x => x.id === id);
if (!c) return;
const esc = this._esc.bind(this);
const items = [];
if (this._state.parent_completable && this._state.parent_completable[id])
items.push({ act: "parent-complete-chore", icon: "👤✓", label: this._t("panel.parent_complete_tooltip") });
if (["alternating", "random", "balanced"].includes(c.assignment_mode))
items.push({ act: "skip-chore", icon: "⏭", label: this._t("panel.btn_skip_chore") });
if (["alternating", "random", "balanced", "first_come"].includes(c.assignment_mode))
items.push({ act: "request-swap", icon: "⇄", label: this._t("panel.swap_btn") });
items.push({ act: "toggle-chore-active", icon: c.enabled === false ? "▶" : "⏸", label: c.enabled === false ? this._t("panel.btn_activate_chore") : this._t("panel.btn_deactivate_chore") });
items.push({ act: "clone-chore", icon: "⧉", label: this._t("panel.btn_clone_chore") });
items.push({ act: "delete-chore", icon: "🗑", label: this._t("panel.btn_delete"), danger: true });
const menu = document.createElement("div");
menu.className = "tm-row-menu";
menu.setAttribute("role", "menu");
menu.style.visibility = "hidden";
menu.innerHTML = items.map(it =>
``).join("");
this.appendChild(menu);
// Position fixed at the kebab, right-aligned, flipping up / clamping to viewport.
const rect = btn.getBoundingClientRect();
const mw = menu.offsetWidth, mh = menu.offsetHeight, gap = 4, pad = 8;
let left = rect.right - mw;
if (left < pad) left = pad;
if (left + mw > window.innerWidth - pad) left = window.innerWidth - pad - mw;
let top = rect.bottom + gap;
if (top + mh > window.innerHeight - pad) top = Math.max(pad, rect.top - gap - mh);
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.visibility = "visible";
this._rowMenuEl = menu;
this._rowMenuForId = id;
btn.setAttribute("aria-expanded", "true");
// Detach if the page scrolls or resizes under the (fixed) menu.
this._onRowMenuDismiss = () => this._closeRowMenu();
window.addEventListener("resize", this._onRowMenuDismiss, true);
window.addEventListener("scroll", this._onRowMenuDismiss, true);
}
_closeRowMenu() {
if (this._onRowMenuDismiss) {
window.removeEventListener("resize", this._onRowMenuDismiss, true);
window.removeEventListener("scroll", this._onRowMenuDismiss, true);
this._onRowMenuDismiss = null;
}
if (this._rowMenuEl) { this._rowMenuEl.remove(); this._rowMenuEl = null; }
if (this._rowMenuForId) {
const btn = this.querySelector(`[data-act="toggle-row-menu"][data-id="${CSS.escape(this._rowMenuForId)}"]`);
if (btn) btn.setAttribute("aria-expanded", "false");
this._rowMenuForId = null;
}
}
_emptyState(icon, title, copy, action, label) {
return `
${icon}
${this._esc(title)}
${this._esc(copy)}
${this._esc(label)}
`;
}
_idBadge(id) {
if (!this._showIds || !id) return "";
return `${this._esc(id)} `;
}
_labelOf(arr, val) {
const item = arr.find(x => x.v === val);
if (!item) return val || "";
return item.lk ? this._t(item.lk) : (item.l || val || "");
}
_esc(str) {
return String(str == null ? "" : str)
.replace(/&/g, "&").replace(//g, ">")
.replace(/"/g, """).replace(/'/g, "'");
}
// Optional numeric field: "" clears the limit (sent as null). 0 is a real
// threshold — a 0 °C minimum is meaningful — so it can't stand in for "unset".
_optNum(value) {
if (value === "" || value === null || value === undefined) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
// Security defense-in-depth: only ever emit our own auth-gated photo endpoint
// into an href/src. _esc() escapes HTML metacharacters but does NOT neutralize
// a javascript:/external scheme, so guard the URL itself. Signed URLs keep the
// /api/taskmate/photo/ prefix (the ?authSig= is appended), so they still pass.
_safePhotoUrl(u) {
return typeof u === "string" && u.startsWith("/api/taskmate/photo/") ? u : "";
}
_fmtNum(n) {
if (n == null) return "0";
return new Intl.NumberFormat().format(n);
}
_mdi(name) {
return ` `;
}
_styles() {
return ``;
}
}
// Guard the define: after a version bump the browser can briefly hold both the
// old (?v=prev) and new (?v=current) panel modules, and a second unguarded
// define() throws "name already used", aborting module evaluation.
if (!customElements.get("taskmate-panel")) {
customElements.define("taskmate-panel", TaskMatePanel);
}