/** * 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: "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" }, ]; 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._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._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 === "close-dialog") { this._closeDialog(); return; } if (act === "clear-field") { this._setEntityField(t.dataset.field, ""); this._render(); return; } if (act === "pick-entity") { 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 === "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; } // 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", }); 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, visibility_entity: "", visibility_state: "on", visibility_operator: "none", 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", 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", 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" }; 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, 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 => ` `).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(""), ` ` ); } // ---- 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 `
${used ? `` : ``} ${used ? `
${this._esc(this._t("panel.tp_in_use_hint", { count: used }))}
` : ""}
`; }).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")}
`; } // 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._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._renderBody()}
${this._dialog ? this._renderDialog() : ""}
${this._renderSaveTemplateDialog()}
`; if (existingToast) this.appendChild(existingToast); this._shellReady = true; this._zoneCache = {}; this._applyDesign(); this._bindHaPickers(); this._scrollActiveMobileTabIntoView(); 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; } const mtabsZone = this.querySelector('[data-zone="mtabs"]'); const existingMtabs = mtabsZone ? mtabsZone.querySelector(".tm-mobile-tabs") : null; const savedMtabsScroll = existingMtabs ? existingMtabs.scrollLeft : 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; } if (savedMtabsScroll) { const newMtabs = this.querySelector(".tm-mobile-tabs"); if (newMtabs) newMtabs.scrollLeft = savedMtabsScroll; } this._applyDesign(); this._scrollActiveMobileTabIntoView(); } // 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"); } _scrollActiveMobileTabIntoView() { const nav = this.querySelector(".tm-mobile-tabs"); if (!nav) return; const active = nav.querySelector(".tm-mtab-active"); if (!active) return; const navRect = nav.getBoundingClientRect(); const btnRect = active.getBoundingClientRect(); if (btnRect.left < navRect.left || btnRect.right > navRect.right) { const target = active.offsetLeft - (nav.clientWidth - active.offsetWidth) / 2; nav.scrollTo({ left: Math.max(0, target), behavior: "smooth" }); } } _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" }, ]}, { 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" }, { 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" ? ` ` : ""}
`; } _mobileTabs() { const groups = this._sidebarGroups(); return ` `; } _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(" · ")})}
`; } _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)}

`; if (!this._state) return `
${this._t("panel.error_no_state")}
`; switch (this._activeTab) { case "children": return this._renderChildrenTab(); case "activity": return this._renderActivityTab(); 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")}
`; } } _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 `

${this._t("panel.child_title")} ${all.length}

${all.length > 0 ? this._searchBox(this._t("panel.search_children")) : ""} ${all.length >= 2 ? `` : ""}
${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("")}
`} `; } _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_title")}

${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 ? `` : ""}

${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")}
`; }).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")}` : ""}
`; }).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)}
`; }).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")}

` : `
${[...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 ` `; }).join("")}
${this._t("panel.activity_table_when")}${this._t("panel.activity_table_child")}${this._t("panel.activity_table_points")}${this._t("panel.activity_table_reason")}
${this._esc(this._timeAgo(t.created_at))} ${this._esc((child && child.name) || "?")} ${t.points >= 0 ? '+' : ''}${t.points} ${this._esc(this._translateReason(t.reason) || "—")} ${undoable ? `` : ""}
`}
`; } // -- 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 `

${this._t("panel.chore_title")} ${all.length}

${all.length > 0 ? this._searchBox(this._t("panel.search_chores")) : ""} ${all.length > 0 ? `` : ""}
${this._bulkMode && all.length > 0 ? `
${this._t("panel.bulk_selected", { count: this._bulkSel.size })}
` : ""} ${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 ? "" : ""} ${chores.map(c => this._renderChoreRow(c, childById)).join("")}
${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")}

${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 `

${this._t("panel.reward_title")} ${all.length}

${all.length > 0 ? this._searchBox(this._t("panel.search_rewards")) : ""}
${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("")}
`} `; } _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 `

${this._t("panel.quest_title")} ${all.length}

${all.length > 0 ? this._searchBox(this._t("panel.search_quests")) : ""} ${haveChores ? `` : ""}
${!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("")}
`} `; } _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 `

    ${this._t("panel.challenge_title")} ${all.length}

    ${all.length > 0 ? this._searchBox(this._t("panel.search_challenges")) : ""}
    ${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("")}
    `} `; } _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 `

    ${this._t("panel.badge_tab_title")} ${allBadges.length}

    ${sub === "custom" ? `` : ""}
    ${subTabs.map(s => ``).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 `
    ${builtins.map(b => ` `).join("")}
    ${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")}
    ${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}` : "—"}
    `; } _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")}

    `; return `
    ${customs.map(b => ` `).join("")}
    ${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")}
    ${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}` : "—"}
    `; } _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 = ``; if (!awards.length) return `
    ${rebuildBtn}

    ${this._t("panel.badge_empty_history")}

    `; return `
    ${rebuildBtn}
    ${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 ` `; }).join("")}
    ${this._t("panel.badge_table_badge")}${this._t("panel.badge_table_child")}${this._t("panel.badge_table_when")}${this._t("panel.badge_table_source")}
    ${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}
    `; } // 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 => ` `).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 `
    ${isBool ? `=` : ` `} ${isBool ? `true` : ``}
    `; }).join("")} ${criteria.length >= 2 ? `
    ${this._t("panel.badge_combinator_label")}
    ` : ""}
    ` : "", ].join(""), ` ` ); } _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")}
    `, ` ` ); } // -- 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 `

    ${labels.plural} ${allItems.length}

    ${allItems.length > 0 ? this._searchBox(kind === "penalty" ? this._t("panel.search_penalties") : this._t("panel.search_bonuses")) : ""}
    ${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)})}

    ` : `
    ${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 ` `; }).join("")}
    ${this._t("panel.penbon_table_name")}${this._t("panel.penbon_table_points")}${this._t("panel.penbon_table_assigned")}
    ${this._mdi(item.icon)}${this._esc(item.name)}${this._idBadge(item.id)}${item.description ? `
    ${this._esc(item.description)}
    ` : ""}
    ${kind === "penalty" ? "−" : "+"}${item.points} ${assignedNames}
    `} `; } // -- 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 `

    ${this._t("panel.group_title")} ${all.length}

    ${all.length > 0 ? this._searchBox(this._t("panel.search_groups")) : ""}
    ${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("")}
    `} `; } // -- 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.template_title")} ${templates.length}

    ${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 ? "" : ``} ${locked ? "" : ``}
    `; } _renderTemplatePicker() { const templates = this._state.templates || []; const builtin = templates.filter(t => t.builtin); const custom = templates.filter(t => !t.builtin); return `

    ${this._t("panel.template_picker_title")}

    ${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 `

    ${this._esc(tpl.name)}

    ${tpl.builtin ? this._t("panel.template_builtin") : this._t("panel.template_custom")}

    ${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.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 `
    ${this._esc(chore.name)} ${this._t("panel.pts_display", {count: chore.points || 0})} ${schedLabel} ${this._esc(this._timeCategoryLabel(chore.time_category))}
    ${expanded ? `
    ${this._t("panel.common_name")}
    ${this._t("panel.common_points")}
    ${this._t("panel.chore_time_category_label")}
    ${this._t("panel.template_assignment_mode_label")}
    ${this._t("panel.template_schedule_days_label")}
    ${DAYS.map(d => `${this._t(d.lk)}`).join("")}
    ${this._t("panel.template_requires_approval_label")}
    ${this._t("panel.template_daily_limit_label")}
    ` : ""}
    `; } _renderSaveTemplateDialog() { if (!this._saveTemplateDialog) return ""; const chores = this._state.chores || []; return `

    ${this._t("panel.template_save_title")}

    ${this._t("panel.template_save_name_label")}
    ${this._t("panel.template_save_icon_label")}
    ${chores.map(c => ` `).join("")}
    `; } _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 ? `` : ""}
    ${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_title")}

    ${this._t("panel.settings_currency_title")}

    ${this._t("panel.settings_currency_hint")}

    ${this._t("panel.settings_points_name_label")}${this._t("panel.settings_points_name_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")}

    ${this._t("panel.settings_parents_title")}

    ${this._t("panel.settings_parents_hint")}

    ${(this._haUsers || []).filter(u => !u.is_admin).map(u => ` `).join("") || `

    ${this._t("panel.settings_parents_empty")}

    `}

    ${this._t("panel.settings_history_title")}

    ${this._t("panel.settings_retention_label")}${this._t("panel.settings_retention_hint")}
    ${this._t("panel.settings_streak_reset_label")}${this._t("panel.settings_streak_reset_hint")}
    ${this._t("panel.settings_streak_all_chores_label")}${this._t("panel.settings_streak_all_chores_hint")}
    ${this._t("panel.settings_weekend_multiplier_label")}${this._t("panel.settings_weekend_multiplier_hint")}
    ${this._t("panel.settings_difficulty_multipliers_label")}${this._t("panel.settings_difficulty_multipliers_hint")}
    ${this._t("panel.settings_calendar_projection_label")}${this._t("panel.settings_calendar_projection_hint")}
    ${this._t("panel.settings_surprise_enabled_label")}${this._t("panel.settings_surprise_enabled_hint")}
    ${this._t("panel.settings_surprise_params_label")}${this._t("panel.settings_surprise_params_hint")}
    ${this._t("panel.settings_decay_enabled_label")}${this._t("panel.settings_decay_enabled_hint")}
    ${this._t("panel.settings_decay_params_label")}${this._t("panel.settings_decay_params_hint")}
    ${this._t("panel.settings_allow_negative_label")}${this._t("panel.settings_allow_negative_hint")}
    ${this._t("panel.settings_level_step_label")}${this._t("panel.settings_level_step_hint")}
    ${this._t("panel.settings_spendcap_enabled_label")}${this._t("panel.settings_spendcap_enabled_hint")}
    ${this._t("panel.settings_spendcap_params_label")}${this._t("panel.settings_spendcap_params_hint")}
    ${this._t("panel.settings_interest_enabled_label")}${this._t("panel.settings_interest_enabled_hint")}
    ${this._t("panel.settings_interest_params_label")}${this._t("panel.settings_interest_params_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_perfect_week_bonus_label")}${this._t("panel.settings_perfect_week_bonus_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_avatars_title")}

    ${this._t("panel.settings_avatars_label")}${this._t("panel.settings_avatars_hint", {count: (this._state.avatar_catalog || []).length})}

    ${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")}

    ${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.family_goal_name")}
    ${this._t("panel.family_goal_target")}${this._t("panel.family_goal_target_hint")}
    ${this._t("panel.family_goal_reward")}

    ${this._t("panel.allowance_title")}

    ${this._t("panel.allowance_hint")}

    ${this._t("panel.allowance_enabled")}
    ${this._t("panel.allowance_rate")}${this._t("panel.allowance_rate_hint")}
    ${this._t("panel.allowance_currency")}
    ${(this._state.allowance_payouts || []).length ? `

    ${this._t("panel.allowance_ledger")}

    ${(this._state.allowance_payouts || []).slice(0, 10).map(p => ` `).join("")}
    ${this._esc((p.date || "").slice(0, 10))} ${this._esc(p.child_name || "")} ${p.points} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}
    ` : ""}

    ${this._t("panel.ics_title")}

    ${this._t("panel.ics_hint")}

    ${this._icsUrl ? `
    ` : `
    `}
    `; } // -- 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._t("panel.notif_tab_title")}

    ${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 = [``]; for (const s of services) opts.push(``); 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 `
    ${this._esc(c.name)}
    ${this._t("panel.notif_quiet_hours_label")}
    `;}).join("")}

    ${this._t("panel.notif_recipients_parents")}

    ${ns.recipients.parents.map(p => `
    `).join("")}
    `; } _renderNotifMatrixSection(ns, recipients) { return `

    ${this._t("panel.notif_section_matrix")}

    ${this._t("panel.notif_section_matrix_desc")}

    ${recipients.map(r => ``).join("")} ${ns.types.map(t => this._renderNotifMatrixRow(t, ns.config[t.id] || { master_enabled: false, routes: {} }, recipients, ns.settings)).join("")}
    ${this._t("panel.notif_matrix_col_notification")}${this._esc(r.name)}
    `; } _renderNotifMatrixRow(t, c, recipients, settings) { const active = !!c.master_enabled; return `
    ${this._t("notification." + t.id + ".name")}
    ${this._t("notification." + t.id + ".description")}
    ${t.id === "streak_at_risk" ? `
    ${this._t("panel.notif_streak_cutoff_label")}
    ` : ""} ${t.id === "mandatory_reminder" ? `
    ${this._t("panel.notif_escalation_reminder_label")} ${this._t("panel.notif_escalation_minutes_suffix")}
    ` : ""} ${t.id === "mandatory_parent_alert" ? `
    ${this._t("panel.notif_escalation_parent_label")} ${this._t("panel.notif_escalation_minutes_suffix")}
    ` : ""}
    ${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_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 `
    ${days.map((d, i) => { const on = !!(n.day_mask & (1 << i)); return ` `; }).join("")}
    ${recipients.map(r => { const on = n.recipient_ids.includes(r.id); const name = (r.name && r.name.trim()) || this._t("panel.notif_unnamed_recipient"); return ` `; }).join("")}
    `; } // -- 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(""), ` ` ); } 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), ` ` ); } 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_name_label")}
    ${this._t("panel.template_icon_label")}
    ${(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.common_name")}
    ${this._t("panel.common_points")}
    ${this._t("panel.chore_time_category_label")}
    ${this._t("panel.template_schedule_label")}
    `).join("")}
    `; } _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(""), ` ` ); } _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._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 => ` `).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 => ` `).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 => ` `).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.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 => ` `).join("")}
    ${this._t("panel.chore_calendar_hint")} `}
    ${this._switch(this._t("panel.chore_enabled_label"), "enabled", d.enabled !== false)}
    `, ].join(""), ` ` ); } _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 => ` `).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") }, ])}
    `, ].join(""), ` ` ); } _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) => `
    ${this._mdi(a.icon || "mdi:account-circle")}
    `).join(""); return this._dialogShell(this._t("panel.avatar_dialog_title"), [ `

    ${this._t("panel.avatar_dialog_hint")}

    `, rows.length ? `
    ${rowHtml}
    ` : `
    ${this._t("panel.avatar_empty")}
    `, ``, ].join(""), ` ` ); } _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 => ` `).join("")}
    `, children.length > 0 ? `
    ${this._t("panel.quest_assigned_label")}
    ${children.map(c => ` `).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(""), ` ` ); } _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 => ` `).join("")}
    ${this._t("panel.penbon_applies_to_hint")}
    ` : "", ].join(""), ` ` ); } _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 => ` `).join("")}
    `}`, `` ); } _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 => ` `).join("")}
    `, ].join(""), ` ` ); } _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 => ` `).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 => ` `).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(""), ` ` ); } _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("")}
    `, ` ` ); } // ---- form helpers ---------------------------------------------------- _dialogShell(title, body, footer) { return `

    ${this._esc(title)}

    ${body}
    ${footer}
    `; } _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)} ${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)}

    `; } _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, "'"); } // 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); }