/** * 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 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" }, { v: "birthday", lk: "badge.metric_birthday" }, ]; const BADGE_BOOL_METRICS = ["first_chore", "first_reward", "birthday"]; // Quick picks for a bounty's icon (#931); the icon picker below them takes any. const BOUNTY_ICONS = ["mdi:car-wash", "mdi:leaf", "mdi:recycle", "mdi:home-outline", "mdi:silverware-clean", "mdi:broom", "mdi:book-open-variant", "mdi:star-outline"]; // Recap frequencies (#929), in display order. Mirrors RECAP_FREQUENCIES in coord_recaps.py. const RECAP_FREQUENCIES = ["weekly", "monthly", "every_3_months", "every_6_months", "every_9_months", "yearly"]; const BADGE_OPERATORS = ["≥", "=", "≤", ">", "<", "≠"]; const BADGE_OP_VALUES = { "≥": ">=", "=": "==", "≤": "<=", ">": ">", "<": "<", "≠": "!=" }; const BADGE_TIERS = [ { v: "bronze", lk: "badge.tier_bronze" }, { v: "silver", lk: "badge.tier_silver" }, { v: "gold", lk: "badge.tier_gold" }, { v: "platinum", lk: "badge.tier_platinum" }, ]; const SCHEDULE_MODES = [ { v: "specific_days", lk: "panel.schedule_specific_days" }, { v: "recurring", lk: "panel.schedule_recurring" }, { v: "one_shot", lk: "panel.schedule_one_shot" }, ]; const RECURRENCES = [ { v: "every_2_days", lk: "panel.recurrence_every_2_days" }, { v: "weekly", lk: "panel.recurrence_weekly" }, { v: "every_2_weeks", lk: "panel.recurrence_every_2_weeks" }, { v: "monthly", lk: "panel.recurrence_monthly" }, { v: "every_3_months", lk: "panel.recurrence_every_3_months" }, { v: "every_6_months", lk: "panel.recurrence_every_6_months" }, ]; const FIRST_OCCURRENCE = [ { v: "available_immediately", lk: "panel.first_occ_available_immediately" }, { v: "wait_for_first_occurrence", lk: "panel.first_occ_wait" }, ]; const ASSIGNMENT_MODES = [ { v: "everyone", lk: "panel.assign_everyone" }, { v: "alternating", lk: "panel.assign_alternating" }, { v: "random", lk: "panel.assign_random" }, { v: "balanced", lk: "panel.assign_balanced" }, { v: "first_come", lk: "panel.assign_first_come" }, { v: "unassigned", lk: "panel.assign_unassigned" }, ]; const VISIBILITY_OPS = [ { v: "none", lk: "panel.vis_none" }, { v: "equals", lk: "panel.vis_equals" }, { v: "not_equals", lk: "panel.vis_not_equals" }, { v: "gte", lk: "panel.vis_gte" }, { v: "lte", lk: "panel.vis_lte" }, { v: "gt", lk: "panel.vis_gt" }, { v: "lt", lk: "panel.vis_lt" }, ]; // Chore fields a change can be scheduled against (#675). Must stay in step with // SCHEDULED_CHANGE_FIELDS in models.py — the backend rejects anything else. const SCHEDULED_FIELDS = [ { v: "points", kind: "number" }, { v: "assigned_to", kind: "children" }, { v: "enabled", kind: "bool" }, { v: "requires_approval", kind: "bool" }, { v: "daily_limit", kind: "number" }, { v: "due_days", kind: "days" }, { v: "mandatory", kind: "bool" }, { v: "mandatory_penalty_points", kind: "number" }, { v: "expires_on", kind: "text" }, { v: "description", kind: "text" }, { v: "time_category", kind: "select", options: [ { v: "anytime", lk: "panel.time_anytime" }, { v: "morning", lk: "panel.time_morning" }, { v: "afternoon", lk: "panel.time_afternoon" }, { v: "evening", lk: "panel.time_evening" }, { v: "night", lk: "panel.time_night" }, ] }, { v: "difficulty", kind: "select", options: [ { v: "easy", lk: "panel.difficulty_easy" }, { v: "medium", lk: "panel.difficulty_medium" }, { v: "hard", lk: "panel.difficulty_hard" }, ] }, ]; // Mirrors MAX_CHORE_TAGS in const.py — the WS schema rejects more (#923). const MAX_CHORE_TAGS = 10; // Adverse HA weather conditions a chore can be blocked by. The pleasant ones // (sunny, clear-night, partlycloudy) are deliberately omitted — nobody rains // off "mow the lawn" because it's sunny. const WEATHER_CONDITIONS = [ { v: "rainy", icon: "mdi:weather-rainy" }, { v: "pouring", icon: "mdi:weather-pouring" }, { v: "snowy", icon: "mdi:weather-snowy" }, { v: "snowy-rainy", icon: "mdi:weather-snowy-rainy" }, { v: "hail", icon: "mdi:weather-hail" }, { v: "lightning", icon: "mdi:weather-lightning" }, { v: "lightning-rainy", icon: "mdi:weather-lightning-rainy" }, { v: "fog", icon: "mdi:weather-fog" }, { v: "windy", icon: "mdi:weather-windy" }, { v: "windy-variant", icon: "mdi:weather-windy-variant" }, { v: "cloudy", icon: "mdi:weather-cloudy" }, { v: "exceptional", icon: "mdi:alert-outline" }, ]; // Built-in sounds, mirroring COMPLETION_SOUND_OPTIONS in const.py. User-uploaded // sounds are appended per render by _soundOptions() (#856). const COMPLETION_SOUNDS = [ "none", "coin", "levelup", "fanfare", "chime", "powerup", "undo", "fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7", "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" }, ]; // Setup wizard (#980). Age groups mirror AGE_GROUP_INFO in setup_catalogue.py // (youngest age, oldest age) — tests/test_setup_wizard.py keeps them in step. const WZ_AGE_GROUPS = [["3_5", 3, 5], ["6_8", 6, 8], ["9_12", 9, 12], ["13_plus", 13, 999]]; const WZ_STEPS = ["welcome", "children", "chores", "rewards", "review", "done"]; const WZ_KID_ICONS = ["mdi:star", "mdi:heart", "mdi:paw", "mdi:white-balance-sunny", "mdi:weather-night", "mdi:trophy", "mdi:leaf", "mdi:creation", "mdi:fire", "mdi:medal", "mdi:book-open-variant", "mdi:car-sports"]; const WZ_KID_COLORS = ["#ff6b9d", "#3498db", "#1abc9c", "#9b59b6", "#e67e22", "#f1c40f", "#e74c3c", "#2ecc71"]; const WZ_POINT_NAMES = [["points", "mdi:star"], ["stars", "mdi:star"], ["coins", "mdi:circle-multiple"], ["gems", "mdi:diamond-stone"]]; const WZ_DRAFT_KEY = "taskmate-setup-draft"; const WZ_SKIP_KEY = "taskmate-setup-skipped"; const WZ_DASH_PATH = "family-chores"; 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 // Today (#966) is the home page. this._activeTab = "today"; this._mobileNavOpen = false; // mobile section-picker dropdown state this._dialog = null; this._dialogInitialHash = null; // for confirm-on-leave this._filter = ""; // per-tab search/filter this._inlineRename = null; // { kind: "chore", id, value } this._rowMenuEl = null; // floating row-action menu element (chores) this._rowMenuForId = null; // chore id the open row menu belongs to this._onRowMenuDismiss = null; // scroll/resize listener while menu open this._reorderDrag = null; // tracks drag state during reorder this._templateView = null; // null | "picker" | "preview" this._templateSelected = null; // selected template object this._templateChores = []; // editable chores for preview step this._saveTemplateDialog = false; // "save as template" dialog state this._badgesSubTab = "catalogue"; // "catalogue" | "custom" | "history" this._rendered = false; this._shellReady = false; this._zoneCache = {}; this._cachedStyles = null; this._onFocusIn = this._onFocusIn.bind(this); this._onClick = this._onClick.bind(this); this._onDblClick = this._onDblClick.bind(this); this._onInput = this._onInput.bind(this); this._onChange = this._onChange.bind(this); this._onValueChanged = this._onValueChanged.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onDragStart = this._onDragStart.bind(this); this._onDragOver = this._onDragOver.bind(this); this._onDrop = this._onDrop.bind(this); this._onVisibilityChange = this._onVisibilityChange.bind(this); this._lastConnected = null; this._showIds = localStorage.getItem("taskmate-show-ids") === "true"; // Admin panel layout (#966). The child filter, collapsed nav groups and // the icon-only sidebar are remembered per browser. this._scope = localStorage.getItem("taskmate-scope") || ""; this._navClosed = new Set(this._readStoredList("taskmate-nav-closed")); this._navRail = localStorage.getItem("taskmate-nav-rail") === "true"; this._newMenuOpen = false; this._palette = null; // { q, hi } while the command search is open this._boardView = "today"; // Today page chore board: "today" | "week" this._onDocKeyDown = this._onDocKeyDown.bind(this); 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); // Follow HA's text direction (#979). The CSS is logical; the few physical // rules left have [dir="rtl"] overrides that key off this attribute. const design = window.__taskmate_design; const dir = design && design.direction ? design.direction(value) : "ltr"; if (this.getAttribute("dir") !== dir) this.setAttribute("dir", dir); 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); document.addEventListener("keydown", this._onDocKeyDown); 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); document.removeEventListener("keydown", this._onDocKeyDown); 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; } // Reason prefixes for *derived* (automatic) transactions, which can't be // undone in isolation — mirrors the backend deny-list in coord_points.py and // the copy in taskmate-activity-card.js. Everything else (penalty, bonus, // gift, manual add/remove) is reversible. // // This is a deny-list, NOT an allow-list (#761): manual adjustments carry // arbitrary or empty reasons, so they cannot be recognised positively. // tests/test_panel_undo_deny_list.py pins all three copies together. static get _UNDO_DENY_PREFIXES() { return [ "Weekend bonus", "Streak milestone bonus", "Perfect week bonus", "Allocated to pool:", "Pool refund", "Points decay", "Savings interest", "Badge", "Streak freeze", "Wish savings", "Wish refund", "Wish pledge", "Wish redeemed", ]; } _txnReversible(reason) { const r = reason || ""; return !TaskMatePanel._UNDO_DENY_PREFIXES.some(p => r.startsWith(p)); } // 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'], ['Pool refund (reward funding changed):', 'activity.reason_pool_refund_funding_changed'], ['Pool refund (reward assignment changed):', 'activity.reason_pool_refund_assignment_changed'], ['Penalty:', 'activity.reason_penalty'], ['Bonus:', 'activity.reason_bonus'], // Surprise inspections (#981): the pass bonus. ['Inspection passed:', 'activity.reason_inspection_passed'], ]; 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'); } // Manual adjustment from the admin panel (#746). An exact match rather than a // prefix: unlike "Bonus: " there is no trailing detail to extract. if (reason === 'Admin panel adjustment') { return this._t('activity.reason_admin_adjustment'); } 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] }); } // Streak freeze tokens (#925). Zero-point rows: the reason is the news. const freezeUsed = reason.match(/^Streak freeze used \((\d{4}-\d{2}-\d{2})\)$/); if (freezeUsed) { return this._t('activity.reason_streak_freeze_used', { date: freezeUsed[1] }); } const freezeEarned = reason.match(/^Streak freeze earned \((\d+) day streak!\)$/); if (freezeEarned) { return this._t('activity.reason_streak_freeze_earned', { days: freezeEarned[1] }); } const freezeAdjusted = reason.match(/^Streak freezes adjusted \(([+-]\d+)\)$/); if (freezeAdjusted) { return this._t('activity.reason_streak_freeze_adjusted', { delta: freezeAdjusted[1] }); } if (reason === 'Streak freeze reversed') { return this._t('activity.reason_streak_freeze_reversed'); } // Wishlist (#932). Pledges carry who and how much, so they need a match // rather than a prefix. const wishPrefixes = [ ['Wish savings taken back:', 'activity.reason_wish_taken_back'], ['Wish savings:', 'activity.reason_wish_savings'], ['Wish refund (declined):', 'activity.reason_wish_refund_declined'], ['Wish refund (removed):', 'activity.reason_wish_refund_removed'], ['Wish redeemed:', 'activity.reason_wish_redeemed'], ]; for (const [prefix, key] of wishPrefixes) { if (reason.startsWith(prefix)) return this._t(key, { name: reason.slice(prefix.length).trim() }); } const pledgeAdded = reason.match(/^Wish pledge from (.+) \(\+(\d+)\): (.*)$/); if (pledgeAdded) { return this._t('activity.reason_wish_pledge', { who: pledgeAdded[1], points: pledgeAdded[2], name: pledgeAdded[3] }); } const pledgeGone = reason.match(/^Wish pledge (removed|voided) \((.+), (\d+)\): (.*)$/); if (pledgeGone) { return this._t(`activity.reason_wish_pledge_${pledgeGone[1]}`, { who: pledgeGone[2], points: pledgeGone[3], name: pledgeGone[4] }); } 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(); this._wizardMaybeAutoOpen(); } 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), code: err && err.code }; } } _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]"); // The "New" menu closes on any click outside its toggle; a menu item's // own action still runs below. let closedNewMenu = false; if (this._newMenuOpen && !(t && t.dataset.act === "new-menu")) { this._newMenuOpen = false; closedNewMenu = true; } // Picking a command-search result closes the search, then runs the // result's ordinary action. if (this._palette && t && t.closest(".tm-pal-item")) this._palette = null; if (!t) { if (closedNewMenu) this._render(); return; } const act = t.dataset.act; // Setup wizard (#980) actions all start "wz-". if (act.startsWith("wz-")) { this._wizardAct(t, e); return; } if (act === "new-menu") { this._newMenuOpen = !this._newMenuOpen; this._render(); return; } if (act === "palette-open") { this._openPalette(); return; } if (act === "palette-scrim") { if (e.target === t) this._closePalette(); return; } if (act === "scope") { this._scope = t.dataset.child || ""; if (this._scope) localStorage.setItem("taskmate-scope", this._scope); else localStorage.removeItem("taskmate-scope"); this._render(); return; } if (act === "nav-group") { const key = t.dataset.grp; if (this._navClosed.has(key)) this._navClosed.delete(key); else this._navClosed.add(key); localStorage.setItem("taskmate-nav-closed", JSON.stringify([...this._navClosed])); this._render(); return; } if (act === "nav-rail") { this._navRail = !this._navRail; localStorage.setItem("taskmate-nav-rail", String(this._navRail)); this._render(); return; } if (act === "board-view") { this._boardView = t.dataset.view === "week" ? "week" : "today"; this._render(); return; } if (act === "tab") { this._activeTab = t.dataset.tab; this._filter = ""; this._mobileNavOpen = false; this._render(); // Settings tab lists HA users for the parent-role picker (#661); load then repaint. if (this._activeTab === "settings") this._ensureHaUsers().then(() => this._render()); return; } if (act === "toggle-mobile-nav") { this._mobileNavOpen = !this._mobileNavOpen; this._render(); return; } if (act === "close-mobile-nav") { this._mobileNavOpen = false; this._render(); return; } if (act === "close-dialog") { this._closeDialog(); return; } if (act === "clear-field") { this._setEntityField(t.dataset.field, ""); this._render(); return; } if (act === "pick-entity") { // The allowlist picker (#678) isn't a dialog field — picking there adds // the entity to the saved list rather than writing into _dialog.data. if (t.dataset.field === "_allowlist_pick") { this._closeEntityDropdown(); this._addToAllowlist(t.dataset.value); return; } this._setEntityField(t.dataset.field, t.dataset.value); this._closeEntityDropdown(); this._render(); return; } if (act === "scrim") { if (e.target === this.querySelector(".tm-scrim")) this._closeDialog(); return; } if (act === "retry") { this._fetchState(); return; } if (act === "switch-to-activity") { this._activeTab = "activity"; this._render(); return; } if (act === "toggle-ha-menu") { this.dispatchEvent(new CustomEvent("hass-toggle-menu", { bubbles: true, composed: true })); return; } if (act === "copy-id") { navigator.clipboard.writeText(t.dataset.id).then(() => this._showToast("ok", this._t("panel.toast_copied"))); return; } if (act === "view-photo") { if (window.__taskmate_lightbox) { e.preventDefault(); window.__taskmate_lightbox(t.dataset.photo, t.dataset.cap, this._t("common.close")); } return; } // Children if (act === "add-child") { this._openChildDialog(null); return; } if (act === "gift-points") { this._openGiftDialog(); return; } if (act === "save-gift") { this._doGiftPoints(); return; } if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; } if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; } if (act === "sound-preview-field") { const sel = t.closest(".tm-sound-row")?.querySelector('select[data-field="completion_sound"]'); if (sel) this._previewSound(sel.value); return; } if (act === "sound-preview") { this._previewSound(t.dataset.value); return; } if (act === "sound-upload") { this._pickCustomSound(); return; } if (act === "sound-rename") { this._renameCustomSound(t.dataset.file, t.dataset.name); return; } if (act === "sound-delete") { this._deleteCustomSound(t.dataset.file, t.dataset.name); return; } if (act === "chore-image-pick") { this._pickChoreImage(); return; } if (act === "chore-image-remove") { // Clears the dialog value only — the file is deleted on save by the // coordinator, so cancelling the dialog cannot destroy the picture. this._dialog.data.image_url = ""; this._render(); return; } if (act === "save-adjust-add") { this._saveAdjustDialog(1); return; } if (act === "save-adjust-remove") { this._saveAdjustDialog(-1); return; } if (act === "edit-child") { this._openChildDialog(t.dataset.id); return; } if (act === "delete-child") { this._confirmDelete("child", t.dataset.id); return; } if (act === "save-child") { this._doSaveChild(); return; } if (act === "reorder-chores-for-child") { this._openReorderDialog(t.dataset.id); return; } if (act === "reorder-move") { this._moveInReorder(t.dataset.dragId, Number(t.dataset.dir)); return; } if (act === "save-chore-order") { this._dialog && this._dialog.data && this._dialog.data.global ? this._doSaveGlobalChoreOrder() : this._doSaveChoreOrder(); return; } if (act === "reorder-chores-global") { this._openGlobalReorderDialog(); return; } // Chores if (act === "add-chore") { this._openChoreDialog(null); return; } if (act === "edit-chore") { this._openChoreDialog(t.dataset.id); return; } if (act === "toggle-row-menu") { this._toggleRowMenu(t); return; } if (act === "clone-chore") { this._doCloneChore(t.dataset.id); return; } if (act === "request-swap") { this._openSwapDialog(t.dataset.id); return; } if (act === "save-swap") { this._doRequestSwap(); return; } if (act === "approve-swap") { this._doSwap("approve", t.dataset.id); return; } if (act === "reject-swap") { this._doSwap("reject", t.dataset.id); return; } if (act === "bulk-toggle") { this._bulkMode = !this._bulkMode; this._bulkSel.clear(); this._render(); return; } if (act === "bulk-enable") { this._doBulk("enable"); return; } if (act === "bulk-disable") { this._doBulk("disable"); return; } if (act === "bulk-delete") { this._doBulk("delete"); return; } if (act === "bulk-reassign") { const sel = this.querySelector("[data-role='bulk-reassign']"); const v = sel ? sel.value : ""; if (!v) { this._showToast("err", this._t("panel.bulk_pick_target")); return; } this._doBulk("reassign", v === "__all__" ? [] : [v]); return; } if (act === "delete-chore") { this._confirmDelete("chore", t.dataset.id); return; } if (act === "parent-complete-chore") { this._doParentComplete(t.dataset.id); return; } if (act === "skip-chore") { this._doSkipChore(t.dataset.id); return; } if (act === "toggle-chore-active") { this._doToggleChoreActive(t.dataset.id); return; } if (act === "save-chore") { this._doSaveChore(); return; } if (act === "bulk-add-chore") { this._openBulkAddDialog(); return; } if (act === "save-bulk-chores") { this._doSaveBulkChores(); return; } if (act === "rename-chore-start") { this._startInlineRename("chore", t.dataset.id); return; } if (act === "rename-chore-commit") { this._commitInlineRename(); return; } if (act === "rename-chore-cancel") { this._inlineRename = null; this._render(); return; } if (act === "toggle-day") { this._toggleArrayField("due_days", t.dataset.day); return; } if (act === "toggle-bulk-day") { this._toggleArrayField("due_days", t.dataset.day); return; } if (act === "toggle-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; } if (act === "toggle-depends") { this._toggleArrayField("depends_on", t.dataset.id); return; } if (act === "toggle-calendar") { this._toggleArrayField("publish_calendar_entities", t.dataset.id); return; } if (act === "toggle-weather-condition") { this._toggleArrayField("weather_block_conditions", t.dataset.id); return; } if (act === "toggle-tag") { this._toggleArrayField("tag_ids", t.dataset.id); return; } if (act === "add-tag") { this._addTypedTag(); return; } if (act === "insights-view") { this._insightView = t.dataset.id; this._render(); return; } if (act === "health-refresh") { this._health = null; this._loadHealth(); return; } if (act === "health-goto") { // Jump straight to where the problem lives rather than describing it. const target = { chores: "chores", rewards: "rewards", children: "children", settings: "settings", activity: "activity" }[t.dataset.id]; if (target) { this._activeTab = target; this._render(); } return; } if (act === "projection-window") { this._projectionDays = Number(t.dataset.id); this._projection = null; this._loadProjection(this._projectionDays); return; } if (act === "friction-window") { this._frictionDays = Number(t.dataset.id); this._friction = null; this._loadFriction(this._frictionDays); return; } if (act === "quality-window") { this._qualityDays = Number(t.dataset.id); this._quality = null; this._loadQuality(this._qualityDays); return; } if (act === "insights-window") { this._insightDays = Number(t.dataset.id); this._fairness = null; this._loadFairness(this._insightDays); return; } if (act === "remove-allowlist") { this._removeFromAllowlist(t.dataset.id); return; } if (act === "add-scheduled") { this._addScheduledChange(); return; } if (act === "remove-scheduled") { this._removeScheduledChange(t.dataset.id); return; } if (act === "toggle-scheduled-child" || act === "toggle-scheduled-day") { this._toggleScheduledListValue(t.dataset.id); return; } if (act === "add-bonus-subtask") { this._addBonusSubtask(); return; } if (act === "remove-bonus-subtask") { this._removeBonusSubtask(Number(t.dataset.idx)); return; } // Rewards if (act === "add-reward") { this._openRewardDialog(null); return; } if (act === "edit-reward") { this._openRewardDialog(t.dataset.id); return; } if (act === "delete-reward") { this._confirmDelete("reward", t.dataset.id); return; } if (act === "save-reward") { this._doSaveReward(); return; } if (act === "toggle-reward-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; } if (act === "toggle-reward-day") { this._toggleArrayField("available_days", Number(t.dataset.day)); return; } // Quests (chore chains) if (act === "add-quest") { this._openQuestDialog(null); return; } 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; } // Bounty board (#931) if (act === "bounty-subtab") { this._bountySubTab = t.dataset.id; this._render(); return; } if (act === "add-bounty") { this._openBountyDialog(null); return; } if (act === "edit-bounty") { this._openBountyDialog(t.dataset.id); return; } if (act === "delete-bounty") { this._doRemoveBounty(t.dataset.id); return; } if (act === "release-bounty") { this._doReleaseBounty(t.dataset.id); return; } if (act === "save-bounty") { this._doSaveBounty(); return; } if (act === "bounty-points") { this._bountyDialogSet("points", Number(t.dataset.id)); return; } if (act === "bounty-icon") { this._bountyDialogSet("icon", t.dataset.id); return; } if (act === "bounty-exp") { this._bountyDialogSet("exp_mode", t.dataset.id); return; } if (act === "bounty-el-all") { this._bountyDialogSet("eligible_child_ids", []); return; } if (act === "toggle-bounty-el") { this._syncIconPickers(); this._toggleArrayField("eligible_child_ids", t.dataset.id); return; } // Chore auctions (#982) if (act === "auction-subtab") { this._auctionSubTab = t.dataset.id; this._render(); return; } if (act === "add-auction") { this._openAuctionDialog(); return; } if (act === "auction-chore-next") { this._openAuctionDialog({ choreId: t.dataset.id }); return; } if (act === "auction-reopen") { this._openAuctionDialog({ fromId: t.dataset.id }); return; } if (act === "auction-close-now") { this._doCloseAuction(t.dataset.id); return; } if (act === "auction-cancel") { this._doCancelAuction(t.dataset.id); return; } if (act === "auction-delete") { this._doDeleteAuction(t.dataset.id); return; } if (act === "auction-occ") { this._auctionDialogSet("occurrence", t.dataset.id); return; } if (act === "auction-max-step") { this._auctionDialogSet("max_step", Number(t.dataset.id)); return; } if (act === "auction-max-set") { this._auctionDialogSet("max_points", Number(t.dataset.id)); return; } if (act === "auction-close-at") { this._auctionDialogSet("close_mode", t.dataset.id); return; } if (act === "toggle-auction-el") { this._toggleArrayField("eligible_child_ids", t.dataset.id); return; } if (act === "save-auction") { this._doSaveAuction(); 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-set-nav-url-global"){ /* handled in _onChange */ return; } if (act === "notif-set-nav-url-type") { /* handled in _onChange */ return; } if (act === "notif-set-group-global") { /* handled in _onChange */ return; } if (act === "notif-set-group-type") { /* 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; } // Recaps (#929) if (act === "recap-tog") { this._recapToggle(t.dataset.who, t.dataset.freq); return; } if (act === "recap-mode") { this._recapSetMode(t.dataset.who, t.dataset.mode); return; } if (act === "recap-preview") { this._doRecapPreview(); 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 // Wishlists (#932) if (act === "wish-filter") { this._wishChild = t.dataset.id || ""; this._render(); return; } if (act === "wish-approve") { this._doApproveWish(t.dataset.id); return; } if (act === "wish-decline") { this._openDialog({ kind: "wish-decline", data: { wish_id: t.dataset.id, reason: "" } }); return; } if (act === "save-wish-decline") { this._doDeclineWish(); return; } if (act === "wish-pledge") { this._openWishPledge(t.dataset.id); return; } if (act === "wish-pledge-who") { if (this._dialog) { this._dialog.data.name = t.dataset.name || ""; this._render(); } return; } if (act === "wish-pledge-amt") { if (this._dialog) { this._dialog.data.points = Number(t.dataset.points) || 0; this._render(); } return; } if (act === "save-wish-pledge") { this._doPledgeWish(); return; } if (act === "wish-unpledge") { this._doWishService("remove_wish_pledge", { wish_id: t.dataset.id, pledge_id: t.dataset.pledge }, "panel.wish_toast_pledge_removed", "panel.wish_confirm_unpledge"); return; } if (act === "wish-fulfil") { this._doApprove("reward", t.dataset.id); return; } if (act === "wish-delete") { this._doWishService("remove_wish", { wish_id: t.dataset.id }, "panel.wish_toast_removed", "panel.wish_confirm_delete"); return; } if (act === "approve-all-chores") { this._doApproveAll(); return; } if (act === "approve-chore") { this._doApprove("chore", t.dataset.id); return; } if (act === "rate-chore") { // Quality rating (#927): pick a star, tap it again to clear. Approve // then pays the chosen rating; no pick pays 100%. const ratings = this._ratings || (this._ratings = {}); const n = Number(t.dataset.rating); if (ratings[t.dataset.id] === n) delete ratings[t.dataset.id]; else ratings[t.dataset.id] = n; this._render(); return; } // Surprise inspections (#981): flag / pass / fail / cancel + their dialogs. if (act && act.startsWith("insp-") && this._onInspectionAction(act, t)) return; // Reject asks why first (#976); an empty reason is a plain reject. if (act === "reject-chore") { this._openRejectDialog("chore", t.dataset.id); return; } if (act === "approve-reward") { this._doApprove("reward", t.dataset.id); return; } if (act === "reject-reward") { this._openRejectDialog("reward", t.dataset.id); return; } if (act === "reject-chip" && this._dialog?.kind === "reject") { const d = this._dialog.data; d.reason = d.reason === t.dataset.reason ? "" : t.dataset.reason; this._render(); return; } if (act === "save-reject" && this._dialog?.kind === "reject") { const d = this._dialog.data; this._doReject(d.kind, d.id, d.reason); 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.dataset.wzIn) { this._wizardInput(t); return; } if (t.dataset.palette === "q" && this._palette) { this._palette.q = t.value || ""; this._palette.hi = 0; this._paintPaletteList(); 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; } // Wishlist approval target (#932): keep what was typed across re-renders. if (t.dataset.wishTarget) { (this._wishTargets || (this._wishTargets = {}))[t.dataset.wishTarget] = t.value; return; } // Inline rename input if (t.dataset.field === "_inlineRename") { this._inlineRename.value = t.value; return; } // Time-of-day period editor field edits const tpField = t.dataset?.tpField; const tpIdx = t.dataset?.tpIdx; if (tpField && tpIdx != null && this._timePeriodsDraft?.[Number(tpIdx)]) { this._timePeriodsDraft[Number(tpIdx)][tpField] = t.value; return; } // Vacation editor field edits const vacField = t.dataset?.vacField; const vacIdx = t.dataset?.vacIdx; if (vacField && vacIdx != null && this._vacationDraft?.[Number(vacIdx)]) { this._vacationDraft[Number(vacIdx)][vacField] = t.value; return; } // Template preview chore field edits const tplField = t.dataset?.tplField; const tplIdx = t.dataset?.tplIdx; if (tplField && tplIdx != null && this._templateChores[Number(tplIdx)]) { let value = t.value; if (tplField === "points" || tplField === "daily_limit") value = Math.max(0, parseInt(value) || 0); if (tplField === "requires_approval") value = value === "true"; this._templateChores[Number(tplIdx)][tplField] = value; return; } // Template dialog chore field edits const dField = t.dataset?.tplDialogField; const dIdx = t.dataset?.tplDialogIdx; if (dField && dIdx != null && this._dialog?.data?.chores?.[Number(dIdx)]) { let value = t.value; if (dField === "points") value = Math.max(0, parseInt(value) || 0); this._dialog.data.chores[Number(dIdx)][dField] = value; return; } // Badge criterion field edits const bcField = t.dataset?.badgeCriterionField; const bcIdx = t.dataset?.badgeCriterionIdx; if (bcField && bcIdx != null && this._dialog?.data?.criteria?.[Number(bcIdx)]) { const val = (t.type === "number") ? (t.value === "" ? 1 : Number(t.value)) : t.value; this._dialog.data.criteria[Number(bcIdx)][bcField] = val; if (bcField === "metric") this._render(); // re-render to show/hide value input for bool metrics return; } // Scheduled-change draft (#675) lives beside the dialog data, not in it — // it is a separate record, not a property of the chore being edited. if (this._dialog && t.dataset.field && t.dataset.field.startsWith("_sched_")) { const key = t.dataset.field.slice("_sched_".length); const draft = this._schedDraft(); draft[key] = t.value; // Switching field changes which value control is shown, and any list // selection from the previous field no longer applies. if (key === "field") { draft.value = ""; draft.value_list = []; this._render(); } return; } // Dialog field if (this._dialog && t.dataset.field) { const field = t.dataset.field; // ha-switch / checkbox elements fire `input` on toggle in newer HA; their // `value` is the constant string "on", so read `.checked` instead — otherwise // a switch can never be set false (it always stores a truthy string). Mirrors _onChange. let value; if (t.type === "checkbox" || t.tagName === "HA-SWITCH") value = t.checked; else value = (t.type === "number") ? (t.value === "" ? null : Number(t.value)) : t.value; const arrMatch = field.match(/^(\w+)\[(\d+)\]\.(\w+)$/); if (arrMatch) { const [, arr, idx, prop] = arrMatch; if (this._dialog.data[arr] && this._dialog.data[arr][Number(idx)] !== undefined) { this._dialog.data[arr][Number(idx)][prop] = value; } } else { this._dialog.data[field] = value; } // Inspection pass (#997): relabel the button as the bonus is typed, // without a re-render that would steal the input's focus. if (this._dialog.kind === "insp-pass" && field === "bonus") { const btn = this.querySelector('[data-act="insp-pass-save"]'); if (btn) btn.textContent = this._inspPassLabel(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 === "auction-reveal") { this._auctionReveal = t.checked; this._render(); return; } if (t.dataset.role === "auction-chore") { this._auctionPickChore(t.value); 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-nav-url-global") { this._notifSetNavUrl(null, t.value); return; } if (t.dataset.act === "notif-set-nav-url-type") { this._notifSetNavUrl(t.dataset.typeId, t.value); return; } if (t.dataset.act === "notif-set-group-global") { this._notifSetGroup(null, t.value); return; } if (t.dataset.act === "notif-set-group-type") { this._notifSetGroup(t.dataset.typeId, t.value); return; } if (t.dataset.act === "notif-set-escalation") { this._notifSetEscalation(t.dataset.escField, t.value); return; } if (t.dataset.act === "notif-set-presence-arrival") { this._notifSetPresenceArrival(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._setDialogField(t.dataset.field, v == null ? "" : v); } // Dialog field write that understands row paths like "catalog[2].icon" (#972). _setDialogField(field, value) { const m = field.match(/^(\w+)\[(\d+)\]\.(\w+)$/); if (!m) { this._dialog.data[field] = value; return; } const row = (this._dialog.data[m[1]] || [])[Number(m[2])]; if (row) row[m[3]] = value; } _syncIconPickers() { if (!this._dialog) return; this.querySelectorAll("ha-icon-picker[data-field]").forEach(el => { if (el.value != null) this._setDialogField(el.dataset.field, el.value); }); } _onKeyDown(e) { // The wizard's tick rows are role="checkbox" divs (#980). if ((e.key === "Enter" || e.key === " ") && e.target.matches && e.target.matches('[role="checkbox"][data-act^="wz-"]')) { e.preventDefault(); e.target.click(); return; } 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; } // Deleted from inside its own editor (#968): the editor has nothing left to edit. if (this._dialog && this._dialog.data && this._dialog.data.id === id) { this._dialog = null; this._dialogInitialHash = null; } 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 msg = { type: wsType, [idField]: id }; const rating = kind === "chore" && this._ratingOn() ? (this._ratings || {})[id] : 0; if (rating) msg.rating = rating; const { ok, err } = await this._callWS(msg); if (!ok) { this._showToast("err", this._t("panel.toast_approve_failed", {error: err})); return; } if (this._ratings) delete this._ratings[id]; await this._fetchState(); this._showToast("ok", this._t("panel.toast_approved")); } // Quality rating (#927) — on only when the setting is. _ratingOn() { const v = this._state?.settings?.quality_rating_enabled; return v === true || v === "true"; } // Three-star picker for one pending completion. Each star's tooltip says // what it pays, so the multiplier is visible where the choice is made. _ratingPicker(completionId) { const s = this._state?.settings || {}; const defaults = { 1: 0.75, 2: 1.0, 3: 1.25 }; const sel = (this._ratings || {})[completionId] || 0; return `
${[1, 2, 3].map(n => { const mult = Number(s["quality_rating_multiplier_" + n] ?? defaults[n]); const percent = Math.round((Number.isFinite(mult) ? mult : defaults[n]) * 100); const label = this._t("panel.rating_star_title", { count: n, percent }); return ``; }).join("")}
`; } async _doApproveAll() { const pending = this._state?.pending_completions || []; if (pending.length === 0) return; if (this._approvingAll) return; if (!confirm(this._t("panel.activity_approve_all_confirm", { count: pending.length }))) return; // Acknowledge the tap straight away (#799). The batch is fast since #794, // but not instant, and a button that looks untouched is indistinguishable // from one that did nothing — which is how #794 came to be reported. this._approvingAll = true; this._render(); let result; try { result = await this._callWS({ type: "taskmate/approve_all_chores", completion_ids: pending.map(c => c.id), }); } finally { // Cleared the moment the call settles, whatever it did — a button stuck // spinning is worse than no busy state at all. this._approvingAll = false; } if (!result.ok) { this._showToast("err", this._t("panel.toast_approve_failed", { error: result.err })); this._render(); return; } await this._fetchState(); this._showToast("ok", this._t("panel.activity_approve_all_done", { count: pending.length })); } // Reject reasons (#976): name the item, offer quick-pick chips + free text. _openRejectDialog(kind, id) { const { choreById, rewardById } = this._activityMaps(); let name; if (kind === "chore") { const c = (this._state.pending_completions || []).find(x => x.id === id); const chore = c && choreById[c.chore_id]; const bounty = c && c.bounty_id && (this._state.bounties || []).find(b => b.id === c.bounty_id); name = (chore && chore.name) || (bounty && bounty.title) || this._t("panel.activity_deleted_chore"); } else { const c = (this._state.pending_reward_claims || []).find(x => x.id === id); const reward = c && rewardById[c.reward_id]; name = (reward && reward.name) || this._t("panel.activity_deleted_reward"); } this._openDialog({ kind: "reject", data: { kind, id, name, reason: "" } }); } _renderRejectDialog() { const d = this._dialog.data; const chips = ["reject.chip_not_finished", "reject.chip_redo", "reject.chip_not_today"].map(k => this._t(k)); const body = `
${this._t("reject.reason_label")}
${chips.map(c => ``).join("")}
${this._esc(this._t("reject.reason_hint"))}
`; return this._dialogShell( this._t("reject.title", { name: d.name }), body, ` ` ); } async _doReject(kind, id, reasonText = "") { const wsType = kind === "chore" ? "taskmate/reject_chore" : "taskmate/reject_reward"; const idField = kind === "chore" ? "completion_id" : "claim_id"; const reason = String(reasonText || "").trim().slice(0, 200); const { ok, err } = await this._callWS({ type: wsType, [idField]: id, ...(reason ? { reason } : {}) }); if (!ok) { this._showToast("err", this._t("panel.toast_reject_failed", {error: err})); return; } if (this._dialog?.kind === "reject") this._closeDialog(true); await this._fetchState(); this._showToast("ok", this._t("panel.toast_rejected")); } // ---- Surprise inspections (#981) -------------------------------------- // get_state carries the kept inspection log and which approved completions // can still be flagged. Open ones sit in "Needs you"; the magnifier on an // approved row in Recent activity starts one. _inspections() { return (this._state && this._state.inspections) || []; } _openInspections() { return this._inspections().filter(i => i.status === "open" && this._childInScope(i.child_id)); } _inspectionForCompletion(completionId) { return this._inspections().find(i => i.completion_id === completionId) || null; } // "1h 58m" / "12m" until an ISO time (never negative). _inspLeft(iso) { const ms = new Date(iso).getTime() - Date.now(); if (!Number.isFinite(ms)) return "—"; const mins = Math.max(0, Math.round(ms / 60000)); const h = Math.floor(mins / 60); return h ? `${h}h ${String(mins % 60).padStart(2, "0")}m` : `${mins}m`; } _inspClock(iso) { const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } _inspBedtime() { return (this._state && this._state.settings && this._state.settings.inspection_bedtime) || "20:00"; } _inspWindowLabel(w, bed = null) { if (w === "bed") return this._t("panel.insp_window_bed", { time: bed ? bed.time : this._inspBedtime() }); return this._t(`panel.insp_window_${w}`); } // When "Until bedtime" started at `now` really closes (#997). Mirrors the // server's inspection_until: bedtime today in HA's time zone, or the // fallback when that's under min_minutes away. Both numbers come from the // server (inspection_bed_rule) so the two can't drift. _inspBedUntil(now = Date.now()) { const rule = (this._state && this._state.inspection_bed_rule) || {}; const minMinutes = Number.isFinite(rule.min_minutes) ? rule.min_minutes : 30; const fallbackHours = Number.isFinite(rule.fallback_hours) ? rule.fallback_hours : 1; const tz = (this._hass && this._hass.config && this._hass.config.time_zone) || undefined; let fmt; try { fmt = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); } catch (_e) { fmt = new Intl.DateTimeFormat("en-US", { hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); } const wall = (ms) => { const p = {}; for (const { type, value } of fmt.formatToParts(new Date(ms))) p[type] = Number(value); return p; }; const p = wall(now); const nowSec = Math.floor(now / 1000) * 1000; // The zone's offset right now, as wall-clock minus UTC. const offset = Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second) - nowSec; const [bh, bm] = this._inspBedtime().split(":").map(Number); const bedtime = Date.UTC(p.year, p.month - 1, p.day, bh || 0, bm || 0) - offset; const fallback = bedtime - now < minMinutes * 60000; const at = fallback ? now + fallbackHours * 3600000 : bedtime; const w = wall(at); const time = `${String(w.hour % 24).padStart(2, "0")}:${String(w.minute).padStart(2, "0")}`; return { at, time, fallback, minutes: minMinutes }; } // The tag / magnifier shown beside an approved completion. _inspSlot(completionId) { const insp = this._inspectionForCompletion(completionId); if (insp) { const tags = { open: ["tm-pill-warn", "panel.insp_tag_open", { time: this._inspLeft(insp.until) }], passed: ["tm-pill-success", "panel.insp_tag_passed", { bonus: this._num(insp.bonus) }], redo: ["tm-pill-warn", "panel.insp_tag_redo", {}], failed: ["", "panel.insp_tag_failed", {}], expired: ["", "panel.insp_tag_expired", {}], }; const [cls, key, params] = tags[insp.status] || tags.expired; return `${this._esc(this._t(key, params))}`; } if ((this._state.inspectable_completions || []).includes(completionId)) { return ``; } return ""; } // Open inspections as "Needs you" rows (#981), next to approvals. _inspectionQueueHtml() { const { childById } = this._activityMaps(); return this._openInspections().map(i => { const child = childById[i.child_id]; const name = (child && child.name) || "?"; const meta = [ this._t("panel.insp_needs_meta", { bonus: this._num(i.bonus), time: this._inspLeft(i.until) }), i.tell_child ? this._t("panel.insp_told", { name }) : this._t("panel.insp_secret"), ].join(" · "); return `
${this._t("panel.insp_needs_line", { child: this._esc(name), chore: this._esc(i.chore_name || this._t("panel.activity_deleted_chore")) })}
${this._esc(meta)}
`; }).join(""); } // Inspection entries for the Recent activity timeline. _inspectionEvents() { const { childById } = this._activityMaps(); const out = []; for (const i of this._inspections()) { if (!this._childInScope(i.child_id)) continue; const child = (childById[i.child_id] || {}).name || "?"; const chore = i.chore_name || this._t("panel.activity_deleted_chore"); const push = (ts, key, points = null, note = "") => out.push({ ts, kind: "inspection", child_id: i.child_id, child, label: this._t(key, { chore }), points, note, }); if (i.tell_child || i.status !== "open") push(i.started_at, "panel.insp_event_started"); // A pass with a bonus is its points transaction ("Inspection passed: …"). if (i.status === "passed" && !i.bonus_txn_id) push(i.decided_at, "panel.insp_event_passed", 0, i.note); if (i.status === "failed") push(i.decided_at, "panel.insp_event_failed", null, i.note); if (i.status === "redo") push(i.decided_at, "panel.insp_event_redo", null, i.note); if (i.status === "expired") push(i.decided_at, "panel.insp_event_expired"); } return out; } _inspDialogCtx(d) { const pointsName = (this._state.settings && this._state.settings.points_name) || this._t("common.points"); const child = (this._state.children || []).find(c => c.id === d.child_id); const when = d.approved_at ? this._inspClock(d.approved_at) : ""; return `
${this._esc(d.chore_name)} · ${this._esc(d.child_name)}
${this._esc(this._t("panel.insp_ctx", { time: when, points: this._num(d.points), points_name: pointsName }))}
`; } _inspStepper(value) { return `
`; } _openInspectionStart(completionId) { const c = (this._state.completions || []).find(x => x.id === completionId); if (!c) return; const { childById, choreById } = this._activityMaps(); const s = this._state.settings || {}; this._openDialog({ kind: "insp-start", data: { completion_id: c.id, child_id: c.child_id, child_name: (childById[c.child_id] || {}).name || "?", chore_name: (choreById[c.chore_id] || {}).name || this._t("panel.activity_deleted_chore"), approved_at: c.approved_at || c.completed_at, points: c.points_awarded || 0, window: ["1h", "2h", "4h", "bed"].includes(s.inspection_window) ? s.inspection_window : "2h", bonus: Number.isFinite(Number(s.inspection_bonus)) && s.inspection_bonus !== undefined ? Number(s.inspection_bonus) : 10, tell: s.inspection_tell_child !== false, } }); } _openInspectionDecision(kind, inspectionId) { const i = this._inspections().find(x => x.id === inspectionId); if (!i) return; const c = (this._state.completions || []).find(x => x.id === i.completion_id); const child = (this._state.children || []).find(x => x.id === i.child_id); const failMode = (this._state.settings || {}).inspection_fail_mode; this._openDialog({ kind, data: { inspection_id: i.id, child_id: i.child_id, child_name: (child && child.name) || "?", chore_name: i.chore_name || this._t("panel.activity_deleted_chore"), approved_at: c ? (c.approved_at || c.completed_at) : "", points: i.points || 0, bonus: i.bonus, note: "", can_redo: !!i.can_redo, mode: failMode === "redo" && i.can_redo ? "redo" : "note", } }); } _renderInspectionStartDialog() { const d = this._dialog.data; const bed = this._inspBedUntil(); const bedSoon = d.window === "bed" && bed.fallback ? `

${this._esc(this._t("panel.insp_window_bed_soon", { bedtime: this._inspBedtime(), minutes: bed.minutes, time: bed.time }))}

` : ""; const body = ` ${this._inspDialogCtx(d)}
${this._t("panel.insp_window_label")}
${["1h", "2h", "4h", "bed"].map(w => ``).join("")}
${bedSoon}
${this._t("panel.insp_bonus_label")} ${this._inspStepper(d.bonus)}
${this._switch(this._t("panel.insp_tell_label", { name: d.child_name }), "tell", d.tell, this._esc(this._t("panel.insp_tell_hint", { name: d.child_name })))}`; return this._dialogShell( this._t("panel.insp_start_title"), body, ` ` ); } _renderInspectionPassDialog() { const d = this._dialog.data; const body = ` ${this._inspDialogCtx(d)}
${this._t("panel.insp_bonus")} ${this._inspStepper(d.bonus)}
${this._esc(this._t("panel.insp_note_pass_label", { name: d.child_name }))}
`; return this._dialogShell( this._t("panel.insp_pass_title"), body, ` ` ); } // The pass button names the bonus that will be paid — the same rounding // and clamp insp-pass-save sends, so it can't show a stale amount (#997). _inspPassLabel(bonus) { return this._t("panel.insp_pass_btn", { bonus: this._num(Math.max(0, Math.min(10000, Math.round(Number(bonus) || 0)))) }); } _renderInspectionFailDialog() { const d = this._dialog.data; const pointsName = (this._state.settings && this._state.settings.points_name) || this._t("common.points"); const opt = (mode, titleKey, hintKey, disabled = false) => ` `; const body = ` ${this._inspDialogCtx(d)}
${opt("note", "panel.insp_fail_note_label", "panel.insp_fail_note_hint")} ${opt("redo", "panel.insp_fail_redo_label", "panel.insp_fail_redo_hint", !d.can_redo)}
${this._esc(this._t("panel.insp_fail_fix_label", { name: d.child_name }))}

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

`; return this._dialogShell( this._t("panel.insp_fail_title"), body, ` ` ); } async _inspectionCall(payload, toastKey, params = {}) { const { ok, err } = await this._callWS(payload); if (!ok) { this._showToast("err", this._t("panel.insp_toast_failed", { error: err })); return false; } if (this._dialog && String(this._dialog.kind).startsWith("insp-")) this._closeDialog(true); await this._fetchState(); this._showToast("ok", this._t(toastKey, params)); return true; } _onInspectionAction(act, t) { const d = this._dialog && this._dialog.data; if (act === "insp-flag") { this._openInspectionStart(t.dataset.id); return true; } if (act === "insp-pass") { this._openInspectionDecision("insp-pass", t.dataset.id); return true; } if (act === "insp-fail") { this._openInspectionDecision("insp-fail", t.dataset.id); return true; } if (act === "insp-kid") { const on = !t.classList.contains("tm-chip-on"); t.classList.toggle("tm-chip-on", on); t.setAttribute("aria-pressed", String(on)); return true; } if (act === "insp-cancel") { if (!confirm(this._t("panel.insp_cancel_confirm"))) return true; this._inspectionCall({ type: "taskmate/inspection/cancel", inspection_id: t.dataset.id }, "panel.insp_toast_cancelled"); return true; } if (!d) return false; if (act === "insp-window") { d.window = t.dataset.window; this._render(); return true; } if (act === "insp-bonus") { d.bonus = Math.max(0, Math.min(10000, (Number(d.bonus) || 0) + Number(t.dataset.d))); this._render(); return true; } if (act === "insp-fail-mode") { d.mode = t.dataset.mode === "redo" && d.can_redo ? "redo" : "note"; this._render(); return true; } const bonus = Math.max(0, Math.min(10000, Math.round(Number(d.bonus) || 0))); if (act === "insp-start-save") { this._inspectionCall( { type: "taskmate/inspection/start", completion_id: d.completion_id, bonus, window: d.window, tell_child: !!d.tell }, d.tell ? "panel.insp_toast_started_told" : "panel.insp_toast_started", { name: d.child_name }); return true; } if (act === "insp-pass-save") { const note = String(d.note || "").trim().slice(0, 200); this._inspectionCall({ type: "taskmate/inspection/pass", inspection_id: d.inspection_id, bonus, note }, "panel.insp_toast_passed", { name: d.child_name, bonus: this._num(bonus) }); return true; } if (act === "insp-fail-save") { const note = String(d.note || "").trim().slice(0, 200); const redo = d.mode === "redo"; this._inspectionCall({ type: "taskmate/inspection/fail", inspection_id: d.inspection_id, redo, note }, redo ? "panel.insp_toast_sent_back" : "panel.insp_toast_noted", { name: d.child_name }); return true; } return false; } _renderInspectionsSection() { const s = (this._state && this._state.settings) || {}; const children = (this._state && this._state.children) || []; const picked = Array.isArray(s.inspection_pick_children) ? s.inspection_pick_children : []; const win = ["1h", "2h", "4h", "bed"].includes(s.inspection_window) ? s.inspection_window : "2h"; const fail = ["note", "redo", "ask"].includes(s.inspection_fail_mode) ? s.inspection_fail_mode : "note"; return `

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

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

${this._t("panel.settings_insp_enabled_label")}${this._t("panel.settings_insp_enabled_hint")}
${this._t("panel.settings_insp_bonus_label")}${this._t("panel.settings_insp_bonus_hint")}
${this._t("panel.settings_insp_window_label")}${this._t("panel.settings_insp_window_hint")}
${this._t("panel.settings_insp_bedtime_label")}${this._t("panel.settings_insp_bedtime_hint")}
${this._t("panel.settings_insp_fail_label")}${this._t("panel.settings_insp_fail_hint")}
${this._t("panel.settings_insp_tell_label")}${this._t("panel.settings_insp_tell_hint")}
${this._t("panel.settings_insp_pick_label")}${this._t("panel.settings_insp_pick_hint")}
${this._t("panel.settings_insp_pick_when_label")}${this._t("panel.settings_insp_pick_when_hint")}
${children.length ? `
${this._t("panel.settings_insp_pick_children_label")}${this._t("panel.settings_insp_pick_children_hint")}
${children.map(c => { const on = picked.includes(c.id); return ``; }).join("")}
` : ""}
`; } // ---- 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 || "", birthday: c.birthday || "", age_group: c.age_group || "", presence_entity: c.presence_entity || "", // Kiosk PIN (#930): write-only. The panel only knows whether one is set. kiosk_pin: "", kiosk_pin_clear: false, has_kiosk_pin: (this._state.kiosk_pin_children || []).includes(c.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: "", presence_entity: "", birthday: "", age_group: "", kiosk_pin: "", kiosk_pin_clear: false, has_kiosk_pin: false, } }); } } /** HA's tag registry for the chore editor's NFC / QR picker (#923). */ async _ensureHaTags() { if (this._haTags) return; // tag/list fails when HA's tag integration isn't loaded — the picker then // offers typed ids only. const { ok, res } = await this._callWS({ type: "tag/list" }); this._haTags = (ok && Array.isArray(res) ? res : []) .map(tag => ({ id: String(tag.id || tag.tag_id || ""), name: tag.name || "" })) .filter(tag => tag.id); } _addTypedTag() { if (!this._dialog || !this._dialog.data) return; const input = this.querySelector("[data-role='tag-input']"); const value = input ? input.value.trim() : ""; if (!value) return; const tags = this._dialog.data.tag_ids || []; if (tags.includes(value)) { input.value = ""; return; } if (tags.length >= MAX_CHORE_TAGS) { this._showToast("err", this._t("panel.chore_tags_limit", {max: MAX_CHORE_TAGS})); return; } this._toggleArrayField("tag_ids", value); } 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 kioskPin = String(d.kiosk_pin || "").trim(); if (kioskPin && !/^[0-9]{4}$/.test(kioskPin)) { this._showToast("err", this._t("panel.toast_kiosk_pin_invalid")); 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 || "", birthday: (d.birthday || "").trim(), age_group: d.age_group || "", presence_entity: d.presence_entity || "" } : { 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 || "", birthday: (d.birthday || "").trim(), age_group: d.age_group || "", presence_entity: d.presence_entity || "" }; const { ok, err, res } = await this._callWS(payload); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; } // The PIN goes through its own command so it is hashed server-side and // never stored on (or read back with) the child record. const childId = (res && res.id) || d.id; let pinErr = ""; if (childId && (kioskPin || (d.kiosk_pin_clear && d.has_kiosk_pin))) { const pinRes = await this._callWS({ type: "taskmate/kiosk/set_pin", child_id: childId, pin: kioskPin }); if (!pinRes.ok) pinErr = pinRes.err; } this._closeDialog(true); await this._fetchState(); if (pinErr) { this._showToast("err", this._t("panel.toast_kiosk_pin_failed", {error: pinErr})); return; } this._showToast("ok", wasAdd ? this._t("panel.toast_child_added") : this._t("panel.toast_child_updated")); } // ---- Chore reorder --------------------------------------------------- _openReorderDialog(child_id) { const child = (this._state.children || []).find(c => c.id === child_id); if (!child) return; const childChores = (this._state.chores || []).filter(c => c.assignment_mode !== "unassigned" && ((c.assigned_to || []).length === 0 || (c.assigned_to || []).includes(child_id)) ); // Use the child's stored chore_order if present, else fall back to the chore list order. const existingOrder = (child.chore_order || []).filter(id => childChores.find(c => c.id === id)); const missing = childChores.map(c => c.id).filter(id => !existingOrder.includes(id)); const order = [...existingOrder, ...missing]; this._openDialog({ kind: "reorder", mode: "edit", data: { child_id, order, name: child.name } }); } async _doSaveChoreOrder() { const d = this._dialog.data; const { ok, err } = await this._callWS({ type: "taskmate/set_chore_order", child_id: d.child_id, chore_order: d.order || [], }); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; } this._closeDialog(true); await this._fetchState(); this._showToast("ok", this._t("panel.toast_order_saved")); } // ---- Global chore reorder --------------------------------------------- _openGlobalReorderDialog() { const allChores = this._state.chores || []; if (allChores.length < 2) return; const existingOrder = (this._state.chore_display_order || []).filter(id => allChores.find(c => c.id === id)); const missing = allChores.map(c => c.id).filter(id => !existingOrder.includes(id)); const order = [...existingOrder, ...missing]; this._openDialog({ kind: "reorder", mode: "edit", data: { global: true, order, name: this._t("panel.tab_chores") } }); } async _doSaveGlobalChoreOrder() { const d = this._dialog.data; const { ok, err } = await this._callWS({ type: "taskmate/set_global_chore_order", chore_order: d.order || [], }); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; } this._closeDialog(true); await this._fetchState(); this._showToast("ok", this._t("panel.toast_order_saved")); } // ---- Bulk add chores ------------------------------------------------- _openBulkAddDialog() { this._openDialog({ kind: "bulk-chore", mode: "add", data: { chore_names: "", points: 10, assigned_to: [], requires_approval: true, time_category: "anytime", schedule_mode: "specific_days", due_days: [], daily_limit: 1, completion_sound: "coin", } }); } async _doSaveBulkChores() { const d = this._dialog.data; const names = (d.chore_names || "").split(/[\n,]/).map(s => s.trim()).filter(Boolean); if (names.length === 0) { this._showToast("err", this._t("panel.toast_enter_chore_name")); return; } const { ok, err, res } = await this._callWS({ type: "taskmate/add_chores_bulk", chore_names: names, points: Number(d.points) || 10, assigned_to: d.assigned_to || [], depends_on: d.depends_on || [], requires_approval: !!d.requires_approval, time_category: d.time_category || "anytime", schedule_mode: d.schedule_mode || "specific_days", due_days: d.due_days || [], daily_limit: Number(d.daily_limit) || 1, completion_sound: d.completion_sound || "coin", icon: d.icon || "", }); if (!ok) { this._showToast("err", this._t("panel.toast_bulk_add_failed", {error: err})); return; } this._closeDialog(true); await this._fetchState(); this._showToast("ok", this._t("panel.toast_bulk_added", {count: (res && res.count) || names.length})); } // ---- Inline rename --------------------------------------------------- _startInlineRename(kind, id) { if (kind !== "chore") return; const c = (this._state.chores || []).find(x => x.id === id); if (!c) return; this._inlineRename = { kind: "chore", id, value: c.name }; this._render(); const inp = this.querySelector("[data-field='_inlineRename']"); if (inp) { inp.focus(); inp.select(); } } async _commitInlineRename() { if (!this._inlineRename) return; const { kind, id, value } = this._inlineRename; const newName = (value || "").trim(); if (!newName) { this._inlineRename = null; this._render(); return; } if (kind === "chore") { const c = (this._state.chores || []).find(x => x.id === id); if (c && c.name === newName) { this._inlineRename = null; this._render(); return; } const { ok, err } = await this._callWS({ type: "taskmate/update_chore", chore_id: id, name: newName }); this._inlineRename = null; if (!ok) { this._showToast("err", this._t("panel.toast_rename_failed", {error: err})); this._render(); return; } await this._fetchState(); this._showToast("ok", this._t("panel.toast_renamed")); } } // ---- Chores ---------------------------------------------------------- async _doBulk(action, assigned_to) { const ids = [...this._bulkSel]; if (!ids.length) return; if (action === "delete" && !confirm(this._t("panel.bulk_delete_confirm", { count: ids.length }))) return; const payload = { type: "taskmate/bulk_chore_action", action, chore_ids: ids }; if (action === "reassign") payload.assigned_to = assigned_to || []; const { ok, err, res } = await this._callWS(payload); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; } this._bulkSel.clear(); this._bulkMode = false; await this._fetchState(); this._showToast("ok", this._t("panel.bulk_done_toast", { count: (res && res.count) || ids.length })); } async _doCloneChore(id) { if (!id) return; const { ok, err } = await this._callWS({ type: "taskmate/clone_chore", chore_id: id }); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; } await this._fetchState(); this._showToast("ok", this._t("panel.toast_chore_cloned")); } _openChoreDialog(id) { const blank = { name: "", description: "", points: 10, task_type: "standard", timed_rate_points: 10, timed_rate_minutes: 5, timed_max_daily_minutes: 0, assigned_to: [], requires_approval: true, time_category: "anytime", completion_sound: "coin", daily_limit: 1, weekly_target: 0, difficulty: "medium", claim_allowance_minutes: 0, schedule_mode: "specific_days", due_days: [], recurrence: "weekly", recurrence_day: "", recurrence_start: "", first_occurrence_mode: "available_immediately", assignment_mode: "everyone", assignment_rotation_anchor: "", manual_start_child_id: "", require_availability: false, icon: "", visibility_entity: "", visibility_state: "on", visibility_operator: "none", weather_entity: "", weather_block_conditions: [], weather_temp_min: "", weather_temp_max: "", weather_wind_max: "", enabled: true, expires_on: "", due_time: "", early_bonus: 0, late_penalty: 0, mandatory: false, mandatory_penalty_points: 0, require_photo: false, open_ended: false, team_size: 0, team_points_mode: "each", team_bonus: 0, publish_calendar_entities: [], depends_on: [], bonus_subtasks: [], tag_ids: [], }; // NFC / QR tags (#923): the picker lists HA's tag registry. Load it once in // the background — the dialog opens straight away and repaints when it lands. if (!this._haTags) { this._ensureHaTags().then(() => { if (this._dialog && this._dialog.kind === "chore") this._render(); }); } 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 || [])], tag_ids: [...(c.tag_ids || [])], bonus_subtasks: (c.bonus_subtasks || []).map(b => ({...b})), visibility_operator: c.visibility_operator || "none", weather_block_conditions: [...(c.weather_block_conditions || [])], // null/undefined limits render as an empty input, not "null" weather_temp_min: c.weather_temp_min ?? "", weather_temp_max: c.weather_temp_max ?? "", weather_wind_max: c.weather_wind_max ?? "", manual_start_child_id: "", } }); } else { this._openDialog({ kind: "chore", mode: "add", data: blank }); } } // --- Custom completion sounds (#856) ------------------------------------ _pickCustomSound() { const input = document.createElement("input"); input.type = "file"; input.accept = "audio/mpeg,audio/ogg,audio/wav,audio/mp4,.mp3,.ogg,.wav,.m4a"; input.addEventListener("change", () => { const file = input.files && input.files[0]; if (file) this._uploadCustomSound(file); }); input.click(); } /** * Upload the file, then register it under a display name. * * Two steps because the bytes go over HTTP (multipart) while the registry * entry is a WebSocket command — same split as chore images. The default * name is the filename without its extension, which is nearly always what * the user wants. */ async _uploadCustomSound(file) { if (this._soundBusy) return; this._soundBusy = true; this._render(); try { const post = () => { const token = this._hass && this._hass.auth && this._hass.auth.data && this._hass.auth.data.access_token; const fd = new FormData(); fd.append("file", file, file.name || "sound"); return fetch("/api/taskmate/sound", { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd, }); }; // The cached access token can go stale mid-session, so refresh and retry // once on a 401 before giving up. Mirrors the chore-image upload. if (this._hass && this._hass.auth && this._hass.auth.expired && this._hass.auth.refreshAccessToken) { try { await this._hass.auth.refreshAccessToken(); } catch (e) { /* surfaced below */ } } let resp = await post(); if (resp.status === 401 && this._hass && this._hass.auth && this._hass.auth.refreshAccessToken) { await this._hass.auth.refreshAccessToken(); resp = await post(); } if (resp.status === 413) { this._showToast("err", this._t("panel.sound_too_large")); return; } if (resp.status === 400) { this._showToast("err", this._t("panel.sound_bad_type")); return; } if (resp.status === 507) { this._showToast("err", this._t("panel.sound_storage_full")); return; } if (!resp.ok) { this._showToast("err", this._t("panel.sound_upload_failed")); return; } const body = await resp.json(); const name = (file.name || "").replace(/\.[^.]+$/, "").slice(0, 40) || this._t("panel.sound_default_name"); const { ok, err } = await this._callWS({ type: "taskmate/add_custom_sound", file: body.file, name, }); if (!ok) { this._showToast("err", this._t("panel.sound_upload_failed", { error: err })); return; } this._showToast("ok", this._t("panel.sound_uploaded")); await this._fetchState(); } catch (err) { this._showToast("err", this._t("panel.sound_upload_failed")); } finally { this._soundBusy = false; this._render(); } } async _renameCustomSound(file, current) { const name = prompt(this._t("panel.sound_rename_prompt"), current || ""); if (name === null) return; if (!name.trim()) { this._showToast("err", this._t("panel.sound_name_required")); return; } const { ok, err } = await this._callWS({ type: "taskmate/rename_custom_sound", file, name: name.trim().slice(0, 40), }); if (!ok) { this._showToast("err", err); return; } await this._fetchState(); } async _deleteCustomSound(file, name) { if (!confirm(this._t("panel.sound_delete_confirm", { name: name || file }))) return; const { ok, err, res } = await this._callWS({ type: "taskmate/remove_custom_sound", file }); if (!ok) { this._showToast("err", err); return; } // Chores that used it were reset to the default sound server-side; say so // rather than letting the change be discovered later. const reset = (res && res.chores_reset) || 0; this._showToast("ok", reset ? this._t("panel.sound_deleted_reset", { count: reset }) : this._t("panel.sound_deleted")); await this._fetchState(); } _pickChoreImage() { const input = document.createElement("input"); input.type = "file"; input.accept = "image/jpeg,image/png,image/webp"; input.addEventListener("change", () => { const file = input.files && input.files[0]; if (file) this._uploadChoreImage(file); }); input.click(); } // Downscale to 512px before upload: these render in slots between 20 and // 128px, so anything larger is bytes every device re-downloads for nothing. _downscaleChoreImage(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const max = 512; let { width, height } = img; if (width > max || height > max) { const scale = Math.min(max / width, max / height); width = Math.round(width * scale); height = Math.round(height * scale); } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; canvas.getContext("2d").drawImage(img, 0, 0, width, height); URL.revokeObjectURL(url); canvas.toBlob(b => (b ? resolve(b) : reject(new Error("encode failed"))), "image/jpeg", 0.8); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("decode failed")); }; img.src = url; }); } async _uploadChoreImage(file) { if (this._choreImageBusy) return; this._choreImageBusy = true; this._render(); try { const blob = await this._downscaleChoreImage(file); const post = () => { const token = this._hass && this._hass.auth && this._hass.auth.data && this._hass.auth.data.access_token; const fd = new FormData(); fd.append("file", blob, "chore.jpg"); return fetch("/api/taskmate/image", { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd, }); }; // The cached access token can go stale mid-session, so refresh and retry // once on a 401 before giving up. Mirrors the child card's photo upload. if (this._hass && this._hass.auth && this._hass.auth.expired && this._hass.auth.refreshAccessToken) { try { await this._hass.auth.refreshAccessToken(); } catch (e) { /* surfaced below */ } } let resp = await post(); if (resp.status === 401 && this._hass && this._hass.auth && this._hass.auth.refreshAccessToken) { await this._hass.auth.refreshAccessToken(); resp = await post(); } if (resp.status === 413) { this._showToast("err", this._t("panel.chore_image_too_large")); return; } if (resp.status === 400) { this._showToast("err", this._t("panel.chore_image_bad_type")); return; } if (!resp.ok) { this._showToast("err", this._t("panel.chore_image_failed")); return; } const body = await resp.json(); if (this._dialog && body.image_url) this._dialog.data.image_url = body.image_url; } catch (err) { this._showToast("err", this._t("panel.chore_image_failed")); } finally { this._choreImageBusy = false; this._render(); } } async _doSaveChore() { 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"; // Teamwork (#928). A timed task can't be one, so switching to timed // quietly turns it off rather than refusing the save. The other clashes // are the backend's rules too, checked here so the parent gets a clear // message instead of a raw error. const teamSize = (d.task_type || "standard") === "timed" ? 0 : Math.max(0, Math.floor(Number(d.team_size) || 0)); if (teamSize === 1 || teamSize > 10) { this._showToast("err", this._t("panel.chore_team_size_invalid")); return; } if (teamSize >= 2 && (!["everyone", "unassigned"].includes(d.assignment_mode || "everyone") || d.open_ended)) { this._showToast("err", this._t("panel.chore_team_conflict")); return; } const base = { name: d.name.trim(), description: d.description || "", icon: d.icon || "", image_url: d.image_url || "", points: Number(d.points) || 0, assigned_to: d.assigned_to || [], // The dialog tracks this via toggle-depends, but it was never sent, so a // dependency could neither be saved nor cleared — _ws_update_chore only // applies fields present in the message (#793). depends_on: d.depends_on || [], 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, weekly_target: Math.max(0, Number(d.weekly_target) || 0), schedule_mode: d.schedule_mode || "specific_days", due_days: d.due_days || [], recurrence: d.recurrence || "weekly", recurrence_day: d.recurrence_day || "", recurrence_start: d.recurrence_start || "", first_occurrence_mode: d.first_occurrence_mode || "available_immediately", assignment_mode: d.assignment_mode || "everyone", assignment_rotation_anchor: d.assignment_rotation_anchor || "", require_availability: !!d.require_availability, visibility_entity: d.visibility_entity || "", visibility_state: d.visibility_state || "on", visibility_operator: d.visibility_operator || "none", weather_entity: d.weather_entity || "", weather_block_conditions: d.weather_block_conditions || [], weather_temp_min: this._optNum(d.weather_temp_min), weather_temp_max: this._optNum(d.weather_temp_max), weather_wind_max: this._optNum(d.weather_wind_max), enabled: d.enabled !== false, expires_on: (d.expires_on || "").trim(), due_time: (d.due_time || "").trim(), early_bonus: Math.max(0, Number(d.early_bonus) || 0), late_penalty: Math.max(0, Number(d.late_penalty) || 0), mandatory: !!d.mandatory, mandatory_penalty_points: Math.max(0, Number(d.mandatory_penalty_points) || 0), require_photo: !!d.require_photo, open_ended: !!d.open_ended, team_size: teamSize, team_points_mode: d.team_points_mode === "split" ? "split" : "each", team_bonus: Math.max(0, Number(d.team_bonus) || 0), publish_calendar_entities: d.publish_calendar_entities || [], tag_ids: d.tag_ids || [], bonus_subtasks: (d.bonus_subtasks || []).filter(b => b.name && b.name.trim()).map(b => ({ name: b.name.trim(), points: Number(b.points) || 5, description: b.description || "", ...(b.id ? {id: b.id} : {}), })), task_type: d.task_type || "standard", timed_rate_points: Number(d.timed_rate_points) || 10, timed_rate_minutes: Math.max(1, Number(d.timed_rate_minutes) || 5), timed_max_daily_minutes: Math.max(0, Number(d.timed_max_daily_minutes) || 0), manual_start_child_id: d.manual_start_child_id || null, }; const payload = wasAdd ? { type: "taskmate/add_chore", ...base } : { type: "taskmate/update_chore", chore_id: d.id, ...base }; const { ok, err } = await this._callWS(payload); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; } this._closeDialog(true); await this._fetchState(); this._showToast("ok", wasAdd ? this._t("panel.toast_chore_added") : this._t("panel.toast_chore_updated")); } _addBonusSubtask() { if (!this._dialog) return; const d = this._dialog.data; d.bonus_subtasks = d.bonus_subtasks || []; d.bonus_subtasks.push({ name: "", points: 5, description: "" }); this._render(); } _removeBonusSubtask(idx) { if (!this._dialog) return; const d = this._dialog.data; if (d.bonus_subtasks && d.bonus_subtasks[idx] !== undefined) { d.bonus_subtasks.splice(idx, 1); this._render(); } } // ---- Rewards --------------------------------------------------------- _openRewardDialog(id) { const blank = { name: "", description: "", cost: 50, icon: "mdi:gift", assigned_to: [], is_jackpot: false, pool_enabled: false, quantity_str: "", expires_at: "", restock_enabled: false, restock_amount: 0, restock_period: "weekly", unlock_entity: "", unlock_minutes: 30, time_lock_enabled: false, available_days: [], available_from: "", available_until: "", streak_freeze: false }; 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 || "", available_days: (r.available_days || []).map(Number), } }); } 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 || [], // A streak freeze is one child's own token, never a shared jackpot (#925). is_jackpot: !d.streak_freeze && !!d.is_jackpot, pool_enabled: !!d.pool_enabled, streak_freeze: !!d.streak_freeze, quantity: qty, expires_at: (d.expires_at || "").trim() || null, restock_enabled: !!d.restock_enabled, unlock_entity: d.unlock_entity || "", unlock_minutes: Math.max(0, Number(d.unlock_minutes) || 0), restock_amount: Math.max(0, Number(d.restock_amount) || 0), restock_period: d.restock_period || "weekly", time_lock_enabled: !!d.time_lock_enabled, available_days: (d.available_days || []).map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6), available_from: d.available_from || "", available_until: d.available_until || "", }; const payload = wasAdd ? { type: "taskmate/add_reward", ...base } : { type: "taskmate/update_reward", reward_id: d.id, ...base }; const { ok, err } = await this._callWS(payload); 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; } }); } // ---- Recaps (#929) ----------------------------------------------------- // Frequencies are multi-select chips held in a draft until "Save settings", // like the time-period and vacation editors. Chip clicks repaint only the // row they belong to, so unsaved edits elsewhere on the tab survive. _recapDraftState() { if (!this._recapDraft) { const s = this._state?.settings || {}; const family = Array.isArray(s.recap_frequencies) ? s.recap_frequencies : []; const kids = s.recap_child_frequencies && typeof s.recap_child_frequencies === "object" ? s.recap_child_frequencies : {}; this._recapDraft = { family: [...family], children: Object.fromEntries(Object.entries(kids).map(([id, v]) => [id, Array.isArray(v) ? [...v] : []])), }; } return this._recapDraft; } _recapChips(selected, who, readOnly) { return RECAP_FREQUENCIES.map(f => { const on = selected.includes(f); return ``; }).join(""); } _recapNextList(selected) { if (!selected.length) { return ` ${this._t("panel.recaps_off")}`; } const next = this._recapSchedule?.next || {}; const lang = this._hass?.locale?.language || this._hass?.language || undefined; return RECAP_FREQUENCIES.filter(f => selected.includes(f)).map(f => { let when = "…"; if (next[f]) { const [y, m, d] = next[f].split("-").map(Number); try { when = new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(lang, { timeZone: "UTC", weekday: "short", day: "numeric", month: "short", year: "numeric" }); } catch (e) { when = next[f]; } } return `${this._esc(this._t("recap.freq." + f))}: ${this._esc(when)}`; }).join(""); } _recapFamilyRow() { const d = this._recapDraftState(); return `
${this._t("panel.recaps_frequency_label")}${this._t("panel.recaps_frequency_hint")}
${this._recapChips(d.family, "__family__", false)}
${this._recapNextList(d.family)}
${d.family.includes("every_9_months") ? `
${this._t("panel.recaps_nine_month_note")}
` : ""}
`; } _recapChildRow(child) { const d = this._recapDraftState(); const custom = Object.prototype.hasOwnProperty.call(d.children, child.id); const selected = custom ? d.children[child.id] : d.family; const id = this._esc(child.id); return ` ${this._esc(child.name)}
${this._recapChips(selected, child.id, !custom)}
${this._recapNextList(selected)}
`; } _renderRecapsSection() { const s = this._state?.settings || {}; const children = this._state?.children || []; if (!this._recapSchedule && !this._recapScheduleLoading) this._loadRecapSchedule(); const retention = s.recap_retention || "forever"; return `

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

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

${this._recapFamilyRow()}
${children.length ? `
${this._t("panel.recaps_per_child_label")}${this._t("panel.recaps_per_child_hint")}
${children.map(c => `${this._recapChildRow(c)}`).join("")}
${this._t("panel.recaps_col_child")}${this._t("panel.recaps_col_frequency")}${this._t("panel.recaps_col_next")}
` : ""}
${this._t("panel.recaps_notify_label")}${this._t("panel.recaps_notify_hint")}
${this._t("panel.recaps_send_time_label")}${this._t("panel.recaps_send_time_hint")}
${this._t("panel.recaps_retention_label")}${this._t("panel.recaps_retention_hint")}
${this._t("panel.recaps_compare_label")}${this._t("panel.recaps_compare_hint")}
${children.length ? `
${this._t("panel.recaps_preview_label")}${this._t("panel.recaps_preview_hint")}
` : ""}
`; } _recapRepaintRows() { const family = this.querySelector('[data-recap-row="__family__"]'); if (family) family.innerHTML = this._recapFamilyRow(); for (const c of this._state?.children || []) { const row = this.querySelector(`tr[data-recap-row="${CSS.escape(c.id)}"]`); if (row) row.innerHTML = this._recapChildRow(c); } } _recapToggle(who, freq) { if (!RECAP_FREQUENCIES.includes(freq)) return; const d = this._recapDraftState(); const list = who === "__family__" ? d.family : d.children[who]; if (!list) return; const i = list.indexOf(freq); if (i >= 0) list.splice(i, 1); else list.push(freq); this._recapRepaintRows(); } _recapSetMode(who, mode) { const d = this._recapDraftState(); if (mode === "custom") { if (!Object.prototype.hasOwnProperty.call(d.children, who)) d.children[who] = [...d.family]; } else { delete d.children[who]; } this._recapRepaintRows(); } async _loadRecapSchedule() { this._recapScheduleLoading = true; const { ok, res } = await this._callWS({ type: "taskmate/recaps/schedule" }); this._recapScheduleLoading = false; if (!ok) return; this._recapSchedule = res; this._recapRepaintRows(); } async _doRecapPreview() { const childId = this.querySelector("[data-recap-preview-child]")?.value; if (!childId) return; const { ok, err } = await this._callWS({ type: "taskmate/recaps/preview", child_id: childId }); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; } this._showToast("ok", this._t("panel.recaps_preview_done")); } _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 || ""; }); // Child undo window (#918): whole seconds, 0 (off) to 3600. if ("chore_undo_seconds" in payload) { const secs = payload.chore_undo_seconds; if (!Number.isInteger(secs) || secs < 0 || secs > 3600) { this._showToast("err", this._t("panel.settings_chore_undo_invalid")); return; } } // Surprise inspections (#981): the children the random pick may choose. const inspKids = root.querySelectorAll("[data-insp-kid]"); if (inspKids.length) { payload.inspection_pick_children = Array.from(inspKids) .filter(el => el.classList.contains("tm-chip-on")) .map(el => el.dataset.inspKid); } // Non-admin parent role (#661): collect the switched-on HA users. const parentBoxes = root.querySelectorAll("[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; } if (this._recapDraft) { // Recaps (#929): only children that still exist keep a Custom override. const ids = new Set((this._state.children || []).map(c => c.id)); payload.recap_frequencies = [...this._recapDraft.family]; payload.recap_child_frequencies = Object.fromEntries( Object.entries(this._recapDraft.children).filter(([id]) => ids.has(id))); } const { ok, err, res } = await this._callWS(payload); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; } this._recapDraft = null; 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) { const { ok, err } = await this._callWS({ type: "taskmate/notifications/set_route", type_id: typeId, recipient_id: recipientId, enabled, time }); if (!ok) this._showToast("err", this._t("panel.toast_save_failed", { error: err })); 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 _notifSetNavUrl(typeId, navUrl) { const payload = { type: "taskmate/notifications/set_nav_url", nav_url: navUrl || "" }; if (typeId) payload.type_id = typeId; const { ok, err } = await this._callWS(payload); if (!ok) this._showToast("err", this._t("panel.toast_save_failed", { error: err })); await this._fetchState(); } async _notifSetGroup(typeId, group) { const payload = { type: "taskmate/notifications/set_group", group: group || "" }; if (typeId) payload.type_id = typeId; const { ok, err } = await this._callWS(payload); if (!ok) this._showToast("err", this._t("panel.toast_save_failed", { error: err })); 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 _notifSetPresenceArrival(value) { const n = Math.max(0, Math.min(1440, Math.round(Number(value)))); const { ok, err } = await this._callWS({ type: "taskmate/notifications/set_presence_arrival", min_away_minutes: Number.isFinite(n) ? n : 30 }); if (!ok) this._showToast("err", this._t("panel.toast_save_failed", { error: err })); 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); // Echoing an unparseable value straight back would put stored text into // markup, so escape it — the panel builds its HTML as strings. if (isNaN(then)) return this._esc(iso); const diffSec = Math.floor((Date.now() - then.getTime()) / 1000); if (diffSec < 60) return this._t("panel.time_ago_just_now"); if (diffSec < 3600) return this._t("panel.time_ago_minutes", {count: Math.floor(diffSec / 60)}); 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() + this._wizardStyles(); } const existingToast = this.querySelector(".tm-toast"); this.innerHTML = ` ${this._cachedStyles}
${this._sidebar()}
${this._topbar()}
${this._mobileTabs()}
${this._scopeBanner()}
${this._approvalBanner()}
${this._renderBody()}
${this._bottomNav()}
${this._dialog ? this._renderDialog() : ""}
${this._renderSaveTemplateDialog()}
${this._renderPalette()}
`; if (existingToast) this.appendChild(existingToast); this._shellReady = true; this._zoneCache = {}; this._applyDesign(); this._bindHaPickers(); if (this._palette) this._focusPalette(); return; } const zones = { sidebar: this._sidebar(), topbar: this._topbar(), mtabs: this._mobileTabs(), scope: this._scopeBanner(), approval: this._approvalBanner(), body: this._renderBody(), bnav: this._bottomNav(), dialog: this._dialog ? this._renderDialog() : "", tpl: this._renderSaveTemplateDialog(), palette: this._renderPalette(), }; const dialogZone = this.querySelector('[data-zone="dialog"]'); let savedScroll = 0; if (dialogZone) { const dialogBody = dialogZone.querySelector(".tm-dialog-body"); savedScroll = dialogBody ? dialogBody.scrollTop : 0; } if (this._dialog) { const openSet = this._dialog._openAdvanced instanceof Set ? this._dialog._openAdvanced : new Set(); this.querySelectorAll("details.tm-advanced[data-section]").forEach(el => { const key = el.dataset.section; if (!key) return; if (el.open) openSet.add(key); else openSet.delete(key); }); this._dialog._openAdvanced = openSet; } let anyChanged = false; let paletteChanged = 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; } const hadDialog = !!el.querySelector(".tm-scrim"); el.innerHTML = html; this._markDialogEntry(el, hadDialog); anyChanged = true; if (name === "palette") paletteChanged = true; } // A rebuilt command search has a fresh input: give it the focus back. if (paletteChanged && this._palette) this._focusPalette(); if (anyChanged) { this._bindHaPickers(); } if (savedScroll) { const newBody = dialogZone && dialogZone.querySelector(".tm-dialog-body"); if (newBody) newBody.scrollTop = savedScroll; } this._applyDesign(); } // A dialog plays its opening animation only when it first appears (#974). // Zones are rebuilt wholesale whenever their HTML changes, so an animation // on .tm-scrim / .tm-dialog replayed on every redraw — twice per picture // upload. The class goes on the DOM, not into the HTML, so the zone cache // is unaffected and later rebuilds come up without it. _markDialogEntry(zone, hadDialog) { if (hadDialog) return; const scrim = zone.querySelector(".tm-scrim"); if (scrim) scrim.classList.add("tm-enter"); } // 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"); // Icon-only sidebar (#966) lives on the shell, which is never rebuilt. shell.classList.toggle("tm-shell-rail", !!this._navRail); } } // Everything waiting on a parent: approvals, reward claims, swap requests // and new wishes (#966 — the Today page's "Needs you" list). _needsYouCount() { if (!this._state) return 0; const s = this._state; return (s.pending_completions || []).filter(c => this._childInScope(c.child_id)).length + (s.pending_reward_claims || []).filter(c => this._childInScope(c.child_id)).length + (s.swap_requests || []).filter(r => this._childInScope(r.requester_id)).length + (s.wishes || []).filter(w => w.status === "pending" && this._childInScope(w.child_id)).length + this._openInspections().length; } _sidebarGroups() { const counts = this._state ? { today: this._needsYouCount(), 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, // Wishlists (#932): what is waiting on a parent — approvals and hand-overs. wishlists: (this._state.wishes || []).filter(w => w.status === "pending" || w.status === "redeem_requested").length, penalties: (this._state.penalties || []).length, bonuses: (this._state.bonuses || []).length, groups: (this._state.task_groups || []).length, templates: (this._state.templates || []).length, bounties: (this._state.bounties || []).filter(b => ["open", "claimed", "pending"].includes(b.status)).length, auctions: (this._state.auctions || []).filter(a => a.status === "open").length, } : {}; // Grouped by what the parent is doing (#966). Group keys use `key`, not // `id`: only nav items carry an id. return [ { key: "home", head: "", items: [ { id: "today", label: this._t("panel.tab_today"), icon: "mdi:home-outline" }, ]}, { key: "family", head: this._t("panel.nav_family"), items: [ { id: "children", label: this._t("panel.tab_children"), icon: "mdi:account-multiple" }, { id: "groups", label: this._t("panel.tab_groups"), icon: "mdi:layers-outline" }, ]}, { key: "earn", head: this._t("panel.nav_earn"), items: [ { id: "chores", label: this._t("panel.tab_chores"), icon: "mdi:check-circle-outline" }, { id: "bounties", label: this._t("panel.tab_bounties"), icon: "mdi:flag-outline" }, { id: "auctions", label: this._t("panel.tab_auctions"), icon: "mdi:gavel" }, { 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: "templates", label: this._t("panel.tab_templates"), icon: "mdi:clipboard-list-outline" }, ]}, { key: "spend", head: this._t("panel.nav_spend"), items: [ { id: "rewards", label: this._t("panel.tab_rewards"), icon: "mdi:gift-outline" }, { id: "wishlists", label: this._t("panel.tab_wishlists"), icon: "mdi:heart-outline" }, ]}, { key: "adjust", head: this._t("panel.nav_adjust"), items: [ { id: "bonuses", label: this._t("panel.tab_bonuses"), icon: "mdi:flash-outline" }, { id: "penalties", label: this._t("panel.tab_penalties"), icon: "mdi:alert-circle-outline" }, { id: "badges", label: this._t("panel.tab_badges"), icon: "mdi:medal-outline" }, ]}, { key: "review", head: this._t("panel.nav_review"), items: [ { id: "activity", label: this._t("panel.tab_activity"), icon: "mdi:pulse" }, { id: "insights", label: this._t("panel.tab_insights"), icon: "mdi:chart-box-outline" }, // The audit view has always been implemented (and documented in the // wiki), but it was never listed here — and the nav is built solely // from this list, so there was no way to open it. { id: "audit", label: this._t("panel.tab_audit"), icon: "mdi:history" }, ]}, { key: "system", 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(); const rail = !!this._navRail; const railLabel = this._t(rail ? "panel.nav_expand" : "panel.nav_collapse"); return ` `; } _topbar() { const crumb = this._sidebarGroups() .flatMap(g => g.items) .find(i => i.id === this._activeTab); const crumbLabel = crumb ? crumb.label : (this._activeTab === "setup" ? this._t("wizard.title") : ""); const pendingCount = this._state ? (this._state.pending_completions || []).length + (this._state.pending_reward_claims || []).length + (this._state.swap_requests || []).length : 0; const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || ""); const searchLabel = this._t("panel.palette_placeholder"); return `
TaskMate / ${this._esc(crumbLabel)}
${this._state ? ` ${this._scopeChips()} ` : ""} ${pendingCount > 0 && this._activeTab !== "activity" && this._activeTab !== "today" ? ` ` : ""} ${this._state ? this._newMenu() : ""}
`; } // ---- Child filter (#966) -------------------------------------------- // One child picked here narrows Today, Children, Chores, Rewards, Quests, // Challenges, Bonuses, Penalties, Activity and Wishlists to that child. _readStoredList(key) { try { const v = JSON.parse(localStorage.getItem(key) || "[]"); return Array.isArray(v) ? v : []; } catch (_) { return []; } } // The picked child's id, or "" for everyone (also when that child is gone). _scopeId() { const id = this._scope; if (!id || !this._state) return ""; return (this._state.children || []).some(c => c.id === id) ? id : ""; } _childInScope(childId) { const id = this._scopeId(); return !id || childId === id; } // Items with an assigned_to list: an empty list means everyone. _inScope(item) { const id = this._scopeId(); if (!id) return true; const who = item && item.assigned_to; return !Array.isArray(who) || who.length === 0 || who.includes(id); } _childColor(childId) { const palette = ["#e91e63", "#2196f3", "#009688", "#ff9800", "#9c27b0", "#795548", "#3f51b5", "#8bc34a"]; const idx = (this._state && this._state.children || []).findIndex(c => c.id === childId); return palette[(idx < 0 ? 0 : idx) % palette.length]; } _childAvatar(child, cls = "") { return ``; } _scopeChips() { const children = (this._state && this._state.children) || []; if (children.length < 2) return ""; const current = this._scopeId(); return `
${children.map(c => ` `).join("")}
`; } _scopeBanner() { const id = this._scopeId(); if (!id) return ""; const child = (this._state.children || []).find(c => c.id === id); return `
${this._t("panel.scope_showing", { name: `${this._esc(child.name)}` })}
`; } // ---- "New" menu (#966) ---------------------------------------------- _newMenuItems() { return [ { act: "add-chore", icon: "mdi:check-circle-outline", label: this._t("panel.new_chore") }, { act: "add-bounty", icon: "mdi:flag-outline", label: this._t("panel.new_bounty") }, { act: "add-auction", icon: "mdi:gavel", label: this._t("panel.new_auction") }, { act: "add-reward", icon: "mdi:gift-outline", label: this._t("panel.new_reward") }, { act: "add-quest", icon: "mdi:map-marker-path", label: this._t("panel.new_quest") }, { act: "add-challenge", icon: "mdi:trophy-outline", label: this._t("panel.new_challenge") }, { act: "add-child", icon: "mdi:account-plus-outline", label: this._t("panel.new_child") }, ]; } _newMenu() { const open = !!this._newMenuOpen; return `
${open ? ` ` : ""}
`; } // ---- Phone bottom bar (#966) ---------------------------------------- _bottomNav() { if (!this._state) return ""; const today = this._needsYouCount(); const items = [ { id: "today", icon: "mdi:home-outline", label: this._t("panel.tab_today") }, { id: "chores", icon: "mdi:check-circle-outline", label: this._t("panel.tab_chores") }, { id: "rewards", icon: "mdi:gift-outline", label: this._t("panel.tab_rewards") }, { id: "children", icon: "mdi:account-multiple", label: this._t("panel.tab_children") }, ]; const inBar = items.some(it => it.id === this._activeTab); return ` `; } _mobileTabs() { const groups = this._sidebarGroups(); const open = !!this._mobileNavOpen; const current = groups.flatMap(g => g.items).find(it => it.id === this._activeTab) || { id: this._activeTab, label: this._activeTab === "setup" ? this._t("wizard.title") : "", icon: this._activeTab === "setup" ? "mdi:auto-fix" : "mdi:cog-outline" }; const curUrgent = (current.id === "activity" || current.id === "today") && current.count > 0; const curShowCount = current.count != null && current.count > 0; return `
${open ? `
` : ""}
`; } _approvalBanner() { if (!this._state) return ""; if (this._activeTab === "activity" || this._activeTab === "today") 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 "today": return this._renderTodayTab(); case "children": return this._renderChildrenTab(); case "activity": return this._renderActivityTab(); case "insights": return this._renderInsightsTab(); case "chores": return this._renderChoresTab(); case "rewards": return this._renderRewardsTab(); case "wishlists": return this._renderWishlistsTab(); 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 "bounties": return this._renderBountiesTab(); case "auctions": return this._renderAuctionsTab(); 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(); case "setup": return this._wizardBody(); default: return `
${this._t("panel.tab_unknown")}
`; } } /** * Insight reports (#679). Computed on demand rather than stored — a stale * cached report is worse than a slow one — so the tab fetches when opened * and when the window changes. */ _renderInsightsTab() { const view = this._insightView || "fairness"; const switcher = `
${["fairness", "friction", "projection", ...(this._ratingOn() || view === "quality" ? ["quality"] : []), "health"].map(v => ` `).join("")}
`; if (view === "friction") return switcher + this._renderFrictionReport(); if (view === "projection") return switcher + this._renderProjectionReport(); if (view === "health") return switcher + this._renderHealthReport(); if (view === "quality") return switcher + this._renderQualityReport(); return switcher + this._renderFairnessReport(); } /** * Friction (#680): which chores aren't working, and what to do about each. * Deliberately leads with a suggestion rather than just a diagnosis. */ _renderFrictionReport() { const report = this._friction; if (!report) { this._loadFriction(this._frictionDays || 30); return `
${this._t("panel.insights_loading")}
`; } const days = this._frictionDays || 30; const problems = report.chores.filter(c => c.verdict !== "fine"); return `

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

${[14, 30, 90].map(d => ` `).join("")}
${this._t("panel.insights_friction_intro", { start: report.start, end: report.end })} ${report.chores.length === 0 ? `
${this._t("panel.insights_friction_none")}
` : `
${report.problem_count ? this._t("panel.insights_friction_problems", { count: report.problem_count }) : this._t("panel.insights_friction_healthy")}
${(problems.length ? problems : report.chores).map(c => ` `).join("")}
${this._t("panel.insights_col_chore")} ${this._t("panel.insights_col_done")} ${this._t("panel.insights_col_rate")} ${this._t("panel.insights_col_last")} ${this._t("panel.insights_col_verdict")} ${this._t("panel.insights_col_suggestion")}
${this._esc(c.name)}${c.outstanding_misses ? ` ⚠ ${c.outstanding_misses}` : ""} ${c.completed}${c.expected ? ` / ${c.expected}` : ""} ${c.rate === null ? "—" : c.rate + "%"} ${c.days_since === null ? this._t("panel.insights_never") : this._t("panel.insights_days_ago", { days: c.days_since })} ${this._t("panel.insights_verdict_" + c.verdict)} ${this._t("panel.insights_suggest_" + c.suggestion)}
${this._t("panel.insights_friction_note")} `}
`; } /** * Projection (#681): what the week ahead looks like. A ceiling rather than * a forecast — shared-pool chores are credited to every eligible child, * because the schedule can't know who will actually get there first. */ _renderProjectionReport() { const report = this._projection; if (!report) { this._loadProjection(this._projectionDays || 7); return `
${this._t("panel.insights_loading")}
`; } const days = this._projectionDays || 7; const kids = report.children; const maxPoints = Math.max(1, ...kids.map(k => k.points)); const pointsName = this._state.settings?.points_name || this._t("common.points"); return `

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

${[7, 14, 28].map(d => ` `).join("")}
${this._t("panel.insights_projection_intro", { start: report.start, end: report.end })}
${kids.map(k => `
${this._esc(k.name)}
${this._t("panel.insights_projected_earn", { points: k.points, name: this._esc(pointsName) })} · ${k.chores} ${this._t(k.chores === 1 ? "panel.insights_chore" : "panel.insights_chores")} · ${this._t("panel.insights_projected_total", { total: k.projected_total })}
`).join("")}
${report.unassigned_points > 0 ? `
${this._t("panel.insights_projection_unassigned", { points: report.unassigned_points })}
` : ""} ${kids.map(k => ``).join("")} ${report.by_day.map(d => ` ${kids.map(k => { const cell = d.children.find(c => c.id === k.id) || { points: 0, chores: 0 }; return ``; }).join("")} `).join("")}
${this._t("panel.insights_col_day")}${this._esc(k.name)}
${this._t("panel.day_" + d.weekday.slice(0, 3))} ${d.date.slice(5)}${cell.chores ? `${this._num(cell.points)} (${this._num(cell.chores)})` : "—"}
${this._t("panel.insights_projection_note")}
`; } /** * Health & diagnostics (#682). Every issue carries a severity, a plain * sentence and where to fix it — a diagnostic that only says "3 problems" * is one the parent can't act on. */ _renderHealthReport() { const report = this._health; if (!report) { this._loadHealth(); return `
${this._t("panel.insights_loading")}
`; } const kb = (report.storage_bytes / 1024).toFixed(1); const counts = report.counts; return `

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

${report.healthy ? this._t("panel.health_all_good") : this._t("panel.health_issues", { count: report.issues.filter(i => i.severity !== "info").length })}
${report.issues.length ? `
${report.issues.map(i => `
${this._t("panel.health_sev_" + i.severity)} ${this._esc(i.message)}
`).join("")}
` : ""}
${Object.entries(counts).map(([key, value]) => `
${value}
${this._t("panel.health_count_" + key)}
`).join("")}
${kb} KB
${this._t("panel.health_count_storage")}
`; } async _loadHealth() { if (this._healthLoading) return; this._healthLoading = true; const { ok, res, err } = await this._callWS({ type: "taskmate/reports/health" }); this._healthLoading = false; if (!ok) { this._showToast("err", err); return; } this._health = res; this._render(); } async _loadProjection(days) { if (this._projectionLoading) return; this._projectionLoading = true; const { ok, res, err } = await this._callWS({ type: "taskmate/reports/projection", days }); this._projectionLoading = false; if (!ok) { this._showToast("err", err); return; } this._projection = res; this._render(); } async _loadFriction(days) { if (this._frictionLoading) return; this._frictionLoading = true; const { ok, res, err } = await this._callWS({ type: "taskmate/reports/friction", days }); this._frictionLoading = false; if (!ok) { this._showToast("err", err); return; } this._friction = res; this._render(); } _renderFairnessReport() { const days = this._insightDays || 7; const report = this._fairness; if (!report) { this._loadFairness(days); return `
${this._t("panel.insights_loading")}
`; } const windows = [7, 14, 30]; const maxShare = Math.max(1, ...report.children.map(c => c.share_completions)); return `

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

${windows.map(d => ` `).join("")}
${this._t("panel.insights_fairness_intro", { start: report.start, end: report.end, fair: report.fair_share, })} ${report.total_completions === 0 ? `
${this._t("panel.insights_no_data")}
` : `
${report.balanced ? this._t("panel.insights_balanced") : this._t("panel.insights_unbalanced")}
${report.children.map(c => `
${this._esc(c.name)}
${c.completions} ${this._t(c.completions === 1 ? "panel.insights_chore" : "panel.insights_chores")} · ${this._num(c.points)} ${this._esc(this._state.settings?.points_name || this._t("common.points"))} · ${c.share_completions}%
${this._t("panel.insights_status_" + c.status)}
`).join("")}
${this._t("panel.insights_fairness_note", { tolerance: report.tolerance })} `}
`; } /** * Quality ratings (#927): average star rating per child and per chore. * Unrated approvals are left out of the averages, not counted as ★★. */ _renderQualityReport() { const days = this._qualityDays || 30; const report = this._quality; if (!report) { this._loadQuality(days); return `
${this._t("panel.insights_loading")}
`; } const stars = (avg) => { if (avg === null || avg === undefined) return `—`; const full = Math.round(avg); return ` ${avg.toFixed(1)}`; }; const table = (title, rows) => `

${this._esc(title)}

${rows.map(r => ` `).join("")}
${this._t("panel.insights_quality_col_average")} ${this._t("panel.insights_quality_col_rated")} ★★★★★★
${this._esc(r.name)} ${stars(r.average)} ${this._ltrNums(`${r.rated} / ${r.total}`)} ${r.counts["1"]} ${r.counts["2"]} ${r.counts["3"]}
`; return `

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

${[7, 30, 90].map(d => ` `).join("")}
${this._t("panel.insights_quality_intro", { start: report.start, end: report.end })} ${report.enabled ? "" : `
${this._t("panel.insights_quality_disabled")}
`} ${!report.rated_completions ? `
${this._t("panel.insights_quality_none")}
` : `
${this._t("panel.insights_quality_overall", { average: Number(report.average).toFixed(1), rated: report.rated_completions, total: report.total_completions, })}
${table(this._t("panel.insights_quality_by_child"), report.children)} ${table(this._t("panel.insights_quality_by_chore"), report.chores.filter(r => r.rated > 0))} `}
`; } async _loadQuality(days) { if (this._qualityLoading) return; this._qualityLoading = true; const { ok, res, err } = await this._callWS({ type: "taskmate/reports/quality", days }); this._qualityLoading = false; if (!ok) { this._showToast("err", err); return; } this._quality = res; this._render(); } async _loadFairness(days) { if (this._fairnessLoading) return; this._fairnessLoading = true; const { ok, res, err } = await this._callWS({ type: "taskmate/reports/fairness", days }); this._fairnessLoading = false; if (!ok) { this._showToast("err", err); return; } this._fairness = res; this._render(); } _searchBox(placeholder) { return `
${this._filter ? `` : ""}
`; } _filterByName(items) { // The child filter (#966) narrows every list that has an assigned_to. const scoped = items.filter(it => this._inScope(it)); if (!this._filter) return scoped; const q = this._filter.toLowerCase(); return scoped.filter(it => (it.name || "").toLowerCase().includes(q)); } // -- Children tab ------------------------------------------------------ _renderChildrenTab() { const all = this._state.children || []; const children = this._filterByName(all).filter(c => this._childInScope(c.id)); 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")}
${this._renderAdjustStrip(child, pointsName)}
`; } // Manual point adjustment (#746). Minus amounts descend and plus amounts // ascend so the two smallest meet in the middle and the row reads outward // from zero. The two `-set` spans are load-bearing: with all seven buttons as // direct flex children, a narrow card wraps only the last one and orphans ⋯ // on a row of its own. Grouping makes each side wrap as a whole unit. _renderAdjustStrip(child, pointsName) { const amounts = this._quickPointAmounts(); const id = this._esc(child.id); const name = child.name || this._t("panel.child_unnamed"); const btn = (delta) => { const amount = Math.abs(delta); const label = this._t(delta < 0 ? "panel.adjust_remove_title" : "panel.adjust_add_title", { amount, points: pointsName, child: name }); return ``; }; const customLabel = this._t("panel.adjust_custom_title", { child: name }); return `
${amounts.slice().reverse().map(a => btn(-a)).join("")} ${amounts.map(a => btn(a)).join("")}
`; } // -- Activity tab ------------------------------------------------------ // Lookups the approval rows and activity feed share. Wishlist redemptions // (#932) are claims with no reward behind them, and a bounty's completion // (#931) names the bounty rather than a chore. _activityMaps() { 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])); for (const w of (this._state.wishes || [])) { if (!rewardById[w.id]) rewardById[w.id] = { id: w.id, name: w.name, cost: w.target }; } for (const b of this._state.bounties || []) { if (!choreById[b.id]) choreById[b.id] = { id: b.id, name: b.title, points: b.points, bounty: true }; } return { childById, choreById, rewardById }; } // Recent activity, newest first: approved completions and claims plus // points transactions. Filtered by the child filter (#966). _activityEvents() { const { childById, choreById, rewardById } = this._activityMaps(); const completions = this._state.completions || []; const claims = this._state.reward_claims || []; const transactions = this._state.points_transactions || []; const events = []; completions.filter(c => c.approved && this._childInScope(c.child_id)).forEach(c => events.push({ ts: c.approved_at || c.completed_at, kind: "completion", child_id: c.child_id, completion_id: c.id, 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 && this._childInScope(c.child_id)).forEach(c => events.push({ ts: c.approved_at || c.claimed_at, kind: "claim", child_id: c.child_id, 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: -(c.approved_cost ?? (rewardById[c.reward_id] || {}).cost ?? 0), })); // A pass bonus's transaction carries the parent's note along (#981). const passNotes = Object.fromEntries(this._inspections().filter(i => i.bonus_txn_id).map(i => [i.bonus_txn_id, i.note || ""])); transactions.filter(t => this._childInScope(t.child_id)).forEach(t => events.push({ ts: t.created_at, kind: String(t.reason || "").startsWith("Inspection passed:") ? "inspection" : "manual", child_id: t.child_id, 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, note: passNotes[t.id] || "", })); events.push(...this._inspectionEvents()); events.sort((a, b) => (b.ts || "").localeCompare(a.ts || "")); return events; } _timelineHtml(events) { return `
${events.map(ev => `
${this._esc(this._timeAgo(ev.ts))}
${this._esc(ev.child)} · ${this._esc(ev.label)}${ev.kind === "completion" && ev.completion_id ? ` ${this._inspSlot(ev.completion_id)}` : ""}
${ev.note ? `
“${this._esc(ev.note)}”
` : ""}
${ev.points == null ? "" : `${ev.points >= 0 ? '+' : ''}${this._num(ev.points)}`}
`).join("")}
`; } // Everything waiting on a parent, as approval rows. The Activity tab and // the Today page (#966) share this; Today also lists new wishes. _approvalQueue({ wishes = false } = {}) { const pendingCompletions = (this._state.pending_completions || []).filter(c => this._childInScope(c.child_id)); const pendingClaims = (this._state.pending_reward_claims || []).filter(c => this._childInScope(c.child_id)); const pendingSwaps = (this._state.swap_requests || []).filter(r => this._childInScope(r.requester_id)); const pendingWishes = wishes ? (this._state.wishes || []).filter(w => w.status === "pending" && this._childInScope(w.child_id)) : []; const { childById, choreById, rewardById } = this._activityMaps(); const pointsName = (this._state.settings && this._state.settings.points_name) || this._t("common.points"); // A redo an inspection sent back (#981) pays nothing extra: say so. const redoIds = new Set(this._inspections().map(i => i.redo_completion_id).filter(Boolean)); const inspections = this._openInspections(); const html = ` ${this._inspectionQueueHtml()} ${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); } } // What the child was promised when they submitted beats anything // recalculated here: a chore edited while the work sat in the // queue, or a speed/roulette bonus that no longer applies. chorePoints = c.submitted_points ?? chorePoints; 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")}${redoIds.has(c.id) ? ` · ${this._esc(this._t("panel.insp_redo_queue"))}` : ""}
${this._ratingOn() ? this._ratingPicker(c.id) : ""}
`; }).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 ? ` · ${this._num(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("")} ${pendingWishes.map(w => { const child = childById[w.child_id]; return `
${this._t("panel.today_wish_text", {child: this._esc((child && child.name) || "?"), wish: this._esc(w.name)})}
${this._t("panel.wish_suggested", { child: this._esc((child && child.name) || "?"), points: this._fmtNum(w.suggested_target || w.target), points_name: this._esc(pointsName) })} · ${this._esc(this._timeAgo(w.created_at))}
`; }).join("")} `; return { count: pendingCompletions.length + pendingClaims.length + pendingSwaps.length + pendingWishes.length + inspections.length, chores: pendingCompletions.length, html, }; } _approveAllButton(chores) { if (!chores) return ""; return ``; } _renderActivityTab() { const transactions = (this._state.points_transactions || []).filter(t => this._childInScope(t.child_id)); const { childById } = this._activityMaps(); const queue = this._approvalQueue(); const recent = this._activityEvents().slice(0, 30); return `

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

${this._t("panel.activity_pending_approvals")} ${queue.count > 0 ? `${queue.count}` : `${this._t("panel.activity_pending_all_clear")}`} ${this._approveAllButton(queue.chores)}

${queue.count === 0 ? `

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

` : `
${queue.html}
`}

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

${recent.length === 0 ? `

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

` : this._timelineHtml(recent)}

${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 = this._txnReversible(t.reason); 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 ? '+' : ''}${this._num(t.points)} ${this._esc(this._translateReason(t.reason) || "—")} ${undoable ? `` : ""}
`}
`; } // -- Today tab (#966) -------------------------------------------------- // The home page: each child's day, everything waiting on a parent, the // chore board and recent activity. The board comes from get_state's // `today` block (coordinator.daily_progress_state). _todayEntry(childId) { const board = this._state.today && this._state.today.board; return board ? (board.children || []).find(e => e.child_id === childId) : null; } _renderTodayTab() { const kids = (this._state.children || []).filter(c => this._childInScope(c.id)); const pointsName = (this._state.settings && this._state.settings.points_name) || this._t("common.points"); const queue = this._approvalQueue({ wishes: true }); const hour = new Date().getHours(); const greet = this._t(hour < 12 ? "panel.today_greeting_morning" : hour < 18 ? "panel.today_greeting_afternoon" : "panel.today_greeting_evening"); const dateText = new Date().toLocaleDateString([], { weekday: "long", day: "numeric", month: "long" }); const needs = queue.count > 0 ? `${this._t(queue.count === 1 ? "panel.today_needs_one" : "panel.today_needs_many", { count: queue.count })}` : this._t("panel.today_all_clear"); if ((this._state.children || []).length === 0) { return `

${this._esc(greet)}

${this._wizardTodayEmpty()} `; } const recent = this._activityEvents().slice(0, 6); return `

${this._esc(greet)}

${this._esc(dateText)} · ${needs}
${this._wizardNudge()}
${kids.map(c => this._renderTodayKid(c, pointsName)).join("")}

${this._t("panel.today_needs_title")} ${queue.count > 0 ? `${queue.count}` : ""} ${this._approveAllButton(queue.chores)}

${queue.count === 0 ? `

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

` : `
${queue.html}
`}

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

${this._boardView === "week" ? this._renderTodayWeek(kids) : this._renderTodayBoard(kids)}

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

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

${recent.length === 0 ? `

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

` : this._timelineHtml(recent)}
`; } _renderTodayKid(child, pointsName) { const entry = this._todayEntry(child.id); const due = entry ? entry.due : 0; const done = entry ? entry.done : 0; const pct = due ? Math.round((done / due) * 100) : 0; const streak = this._num(child.current_streak); let dayPill = ""; if (entry) { if (due === 0) dayPill = `${this._t("panel.today_nothing_due")}`; else if (done >= due) dayPill = `${this._t("panel.today_all_done")}`; else dayPill = `${this._t("panel.today_left", { count: due - done })}`; } const scopeLabel = this._t("panel.scope_only", { name: child.name }); return `
${streak > 0 ? `${this._ltrNums(this._t("panel.today_streak", { count: streak }))}` : ""} ${dayPill}
`; } _renderTodayBoard(kids) { const board = this._state.today && this._state.today.board; if (!board) return `

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

`; if (kids.length === 0) return ""; const choreById = Object.fromEntries((this._state.chores || []).map(c => [c.id, c])); const periods = [ ...this._effectiveTimePeriods().map(p => ({ id: p.id, label: this._timePeriodLabel(p), icon: p.icon || "mdi:clock-outline" })), { id: "anytime", label: this._t("panel.time_anytime"), icon: "mdi:clock-outline" }, ]; const known = new Set(periods.map(p => p.id)); const slotOf = chore => (chore && known.has(chore.time_category) ? chore.time_category : "anytime"); const cells = {}; // `${period}|${child}` -> items for (const kid of kids) { const entry = this._todayEntry(kid.id); for (const item of (entry && entry.chores) || []) { const chore = choreById[item.chore_id]; if (!chore) continue; const key = `${slotOf(chore)}|${kid.id}`; (cells[key] = cells[key] || []).push({ ...item, chore }); } } const rows = periods.filter(p => kids.some(k => (cells[`${p.id}|${k.id}`] || []).length)); if (rows.length === 0) return `

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

`; const statusLabel = st => this._t(`panel.today_status_${st}`); return `
${kids.map(k => `
${this._childAvatar(k, "tm-av-sm")}${this._esc(k.name)}
`).join("")} ${rows.map(p => `
${this._esc(p.label)}
${kids.map(k => { const items = cells[`${p.id}|${k.id}`] || []; return `
${items.length ? items.map(it => ` `).join("") : `–`}
`; }).join("")} `).join("")}
${["done", "pending", "todo", "missed"].map(st => `${this._esc(statusLabel(st))}`).join("")}
`; } _renderTodayWeek(kids) { const history = this._state.today && this._state.today.history; if (!history) return `

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

`; const first = kids.map(k => history[k.id]).find(Boolean) || []; const dayName = iso => new Date(`${iso}T12:00:00`).toLocaleDateString([], { weekday: "short" }); const gaps = kids.some(k => (history[k.id] || []).some(d => d.due == null)); return `
${first.map(d => `
${this._esc(dayName(d.date))}
`).join("")} ${kids.map(k => `
${this._childAvatar(k, "tm-av-sm")}${this._esc(k.name)}
${(history[k.id] || []).map(d => { if (d.due == null) return `
–
`; const pct = d.due ? Math.round((d.done / d.due) * 100) : 100; return `
${d.due ? `${d.done}/${d.due}` : "–"}
`; }).join("")} `).join("")}
${gaps ? `

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

` : ""} `; } // -- Command search (#966) --------------------------------------------- // Ctrl/Cmd+K or "/" opens it. Every result is a button carrying an ordinary // data-act, so picking one runs exactly what the matching button does. _isTypingTarget(e) { const el = (e.composedPath && e.composedPath()[0]) || e.target; if (!el || !el.tagName) return false; const tag = el.tagName.toLowerCase(); return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable || tag.startsWith("ha-textfield"); } _onDocKeyDown(e) { if (!this.isConnected || !this._state) return; const key = (e.key || "").toLowerCase(); if ((e.ctrlKey || e.metaKey) && key === "k") { e.preventDefault(); if (this._palette) this._closePalette(); else if (!this._dialog) this._openPalette(); return; } if (!this._palette) { // Esc closes an open editor even when focus isn't inside the panel — // after tapping a board chip it sits on the page, which the panel's own // keydown listener never hears (#968). if (e.key === "Escape" && this._dialog && !(e.composedPath && e.composedPath().includes(this))) { this._closeDialog(); return; } if (e.key === "/" && !this._dialog && !e.ctrlKey && !e.metaKey && !e.altKey && !this._isTypingTarget(e)) { e.preventDefault(); this._openPalette(); } return; } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); this._closePalette(); return; } if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const n = this._paletteResults().length; if (!n) return; this._palette.hi = (this._palette.hi + (e.key === "ArrowDown" ? 1 : n - 1)) % n; this._paintPaletteList(); return; } if (e.key === "Enter") { e.preventDefault(); const btn = this.querySelector(`.tm-pal-item[data-idx="${this._palette.hi}"]`); if (btn) btn.click(); } } _openPalette() { this._newMenuOpen = false; this._mobileNavOpen = false; this._palette = { q: "", hi: 0 }; this._render(); } _closePalette() { this._palette = null; this._render(); } _focusPalette() { const input = this.querySelector(".tm-pal-input"); if (!input) return; input.focus(); const end = input.value.length; try { input.setSelectionRange(end, end); } catch (_) {} } _paletteCommands() { const s = this._state; const cmds = []; const goTo = this._t("panel.palette_group_go"); for (const it of this._sidebarGroups().flatMap(g => g.items)) { cmds.push({ group: goTo, icon: it.icon, label: it.label, data: { act: "tab", tab: it.id } }); } const actions = this._t("panel.palette_group_actions"); for (const it of this._newMenuItems()) cmds.push({ group: actions, icon: it.icon, label: it.label, data: { act: it.act } }); cmds.push({ group: actions, icon: "mdi:auto-fix", label: this._t("wizard.run_btn"), data: { act: "wz-open" } }); if ((s.pending_completions || []).length) { cmds.push({ group: actions, icon: "mdi:check-all", label: this._t("panel.activity_approve_all"), data: { act: "approve-all-chores" } }); } for (const c of s.children || []) { cmds.push({ group: actions, icon: "mdi:plus-minus-variant", label: this._t("panel.palette_adjust_points", { name: c.name }), data: { act: "adjust-points-custom", id: c.id } }); } if ((s.children || []).length >= 2) { for (const c of s.children) cmds.push({ group: actions, icon: "mdi:filter-variant", label: this._t("panel.scope_only", { name: c.name }), data: { act: "scope", child: c.id } }); if (this._scopeId()) cmds.push({ group: actions, icon: "mdi:filter-variant-remove", label: this._t("panel.scope_clear"), data: { act: "scope", child: "" } }); } const lists = [ [this._t("panel.tab_children"), s.children, "edit-child", c => c.avatar || "mdi:account-circle"], [this._t("panel.tab_chores"), s.chores, "edit-chore", c => c.icon || "mdi:check-circle-outline"], [this._t("panel.tab_rewards"), s.rewards, "edit-reward", r => r.icon || "mdi:gift-outline"], [this._t("panel.tab_quests"), s.quests, "edit-quest", q => q.icon || "mdi:map-marker-path"], [this._t("panel.tab_challenges"), s.challenges, "edit-challenge", c => c.icon || "mdi:trophy-outline"], ]; for (const [group, items, act, icon] of lists) { for (const it of items || []) cmds.push({ group, icon: icon(it), label: it.name || "", data: { act, id: it.id }, searchOnly: true }); } return cmds; } _paletteResults() { const q = ((this._palette && this._palette.q) || "").trim().toLowerCase(); const cmds = this._paletteCommands(); if (!q) return cmds.filter(c => !c.searchOnly); const words = q.split(/\s+/); return cmds.filter(c => { const hay = `${c.label} ${c.group}`.toLowerCase(); return words.every(w => hay.includes(w)); }).slice(0, 60); } _paletteListHtml() { const results = this._paletteResults(); if (!results.length) return `
${this._t("panel.palette_no_results")}
`; const hi = this._palette ? this._palette.hi : 0; let last = null; return results.map((c, i) => { const head = c.group !== last ? `
${this._esc(c.group)}
` : ""; last = c.group; const attrs = Object.entries(c.data) .map(([k, v]) => `data-${k}="${this._esc(v)}"`) .join(" "); return `${head}`; }).join(""); } _paintPaletteList() { const list = this.querySelector(".tm-pal-list"); if (!list) return; list.innerHTML = this._paletteListHtml(); const hi = list.querySelector(".tm-pal-hi"); if (hi && hi.scrollIntoView) hi.scrollIntoView({ block: "nearest" }); } _renderPalette() { if (!this._palette || !this._state) return ""; const ph = this._t("panel.palette_placeholder"); return `
`; } // -- Setup wizard (#980) ------------------------------------------------ // A guided first run: children (with a birthday, an age or an age group), // chores suggested for their ages, starter rewards, then one backend call // that creates the lot (taskmate/setup_wizard/apply) and, optionally, a // "Family chores" dashboard. It only ever adds. The draft lives in this // browser's storage until it is created, so leaving part-way loses nothing. _wizardHasData() { const s = this._state || {}; return (s.children || []).length + (s.chores || []).length + (s.rewards || []).length > 0; } // Opens by itself once, when the panel first loads a family with no // children — unless it was skipped before in this browser. _wizardMaybeAutoOpen() { if (this._wzAutoChecked || !this._state) return; this._wzAutoChecked = true; if ((this._state.children || []).length) return; if (localStorage.getItem(WZ_SKIP_KEY) === "true") return; if (this._activeTab !== "today") return; this._wizardOpen(); } // Settings and the command search ask first when a family already exists. _wizardRequestOpen() { if (!this._wizardHasData()) { this._wizardOpen(); return; } const s = this._state; this._openDialog({ kind: "wizard-confirm", data: { children: (s.children || []).length, chores: (s.chores || []).length, rewards: (s.rewards || []).length, } }); } async _wizardOpen(opts = {}) { if (!this._wzCatalogue) { const { ok, res, err } = await this._callWS({ type: "taskmate/setup_wizard/catalogue" }); if (!ok) { this._showToast("err", this._t("wizard.toast_load_failed", { error: err })); return; } this._wzCatalogue = res; } if (!this._wz || this._wz.step === 5) { const draft = this._wizardLoadDraft(); this._wz = draft || this._wizardFresh(); } this._wizardSyncExisting(); if (opts.step != null) this._wz.step = opts.step; localStorage.removeItem(WZ_SKIP_KEY); this._activeTab = "setup"; this._mobileNavOpen = false; this._render(); } _wizardClose(toTab = "today") { // A finished run leaves nothing to come back to; anything else stays as a draft. if (this._wz && this._wz.step === 5) this._wz = null; else this._wizardSaveDraft(); localStorage.setItem(WZ_SKIP_KEY, "true"); this._activeTab = toTab; this._render(); } _wizardLoadDraft() { try { const d = JSON.parse(localStorage.getItem(WZ_DRAFT_KEY) || "null"); if (!d || d.v !== 1 || !Array.isArray(d.kids)) return null; d.busy = false; d.result = null; d.step = Math.min(Math.max(0, Number(d.step) || 0), 4); return d; } catch (_) { return null; } } _wizardSaveDraft() { const wz = this._wz; if (!wz || wz.step === 5) return; try { const { busy: _busy, result: _result, ...keep } = wz; localStorage.setItem(WZ_DRAFT_KEY, JSON.stringify(keep)); } catch (_) { /* storage full or blocked: the wizard still works */ } } _wizardFresh() { const settings = (this._state && this._state.settings) || {}; const pname = settings.points_name || this._t("wizard.pname_stars"); const picon = settings.points_icon || "mdi:star"; const wz = { v: 1, step: 0, seq: 1, pname, picon, pnameOrig: pname, piconOrig: picon, pnameCustom: false, kids: [], form: null, chores: {}, packs: [], rewards: {}, custom: [], rwName: "", rwCost: 20, approval: "big", dash: true, notify: true, showAll: false, }; wz.pnameCustom = !WZ_POINT_NAMES.some(([k]) => this._t(`wizard.pname_${k}`) === pname); return wz; } // Existing children join the wizard as "Already set up", so their ages can // steer the suggestions. Keeps any age group the parent picked in the draft. _wizardSyncExisting() { const wz = this._wz; const children = ((this._state && this._state.children) || []).filter(c => !c.is_guest); const picked = Object.fromEntries(wz.kids.filter(k => k.existing).map(k => [k.key, k.band])); const existing = children.map(c => { const full = /^\d{4}-\d{2}-\d{2}$/.test(c.birthday || ""); return { key: c.id, name: c.name, icon: c.avatar || "mdi:account-circle", color: this._childColor(c.id), existing: true, mode: full ? "bday" : "none", bday: full ? c.birthday : "", age: 0, band: c.age_group || picked[c.id] || "", origBand: c.age_group || "", }; }); const ids = new Set(children.map(c => c.id)); // A draft's new child who has since been added by hand is dropped here too. const names = new Set(children.map(c => (c.name || "").trim().toLowerCase())); const fresh = wz.kids.filter(k => !k.existing && !ids.has(k.key) && !names.has(k.name.trim().toLowerCase())); wz.kids = [...existing, ...fresh]; wz.mode = this._wizardHasData() ? "existing" : "fresh"; if (!wz.form) wz.form = this._wizardNewForm(); this._wizardInitChores(); this._wizardInitRewards(); } _wizardNewForm() { const used = new Set((this._wz ? this._wz.kids : []).map(k => k.color)); const d = new Date(); return { name: "", mode: "bday", bday: `${d.getFullYear() - 6}-${String(d.getMonth() + 1).padStart(2, "0")}-01`, age: 6, band: "6_8", icon: WZ_KID_ICONS[(this._wz ? this._wz.kids.length : 0) % WZ_KID_ICONS.length], color: WZ_KID_COLORS.find(c => !used.has(c)) || WZ_KID_COLORS[0], }; } _wizardAgeOf(kid) { if (kid.mode === "bday" && /^\d{4}-\d{2}-\d{2}$/.test(kid.bday || "")) { const [y, m, d] = kid.bday.split("-").map(Number); const now = new Date(); let age = now.getFullYear() - y; if (now.getMonth() + 1 < m || (now.getMonth() + 1 === m && now.getDate() < d)) age--; return Math.max(0, age); } if (kid.mode === "age") return Number(kid.age) || 0; return null; } // An age maps onto its group; under-3s get the youngest group. _wizardGroupForAge(age) { if (age == null) return ""; const hit = WZ_AGE_GROUPS.find(([, lo, hi]) => age >= lo && age <= hi); return hit ? hit[0] : WZ_AGE_GROUPS[0][0]; } _wizardBand(kid) { const age = this._wizardAgeOf(kid); return age == null ? (kid.band || "") : this._wizardGroupForAge(age); } _wizardBandIdx(id) { return WZ_AGE_GROUPS.findIndex(g => g[0] === id); } // A chore can go to children in its age group or older, never younger. _wizardEligible(groupId) { const gi = this._wizardBandIdx(groupId); return this._wz.kids.filter(k => { const b = this._wizardBand(k); return b && this._wizardBandIdx(b) >= gi; }); } _wizardChoreName(s) { return this._t(`wizard.chore.${s.id}`); } _wizardRewardName(r) { return this._t(`wizard.reward.${r.id}`); } // Built-in pack names and their chores are translated here; a key the // locale lacks (a pack added later) falls back to the stored English. _wizardPackName(tpl) { const key = `wizard.pack.${tpl.id}.name`; const t = this._t(key); return t === key ? tpl.name : t; } _wizardPackChoreName(tpl, idx) { const key = `wizard.pack.${tpl.id}.chore_${idx}`; const t = this._t(key); return t === key ? tpl.chores[idx].name : t; } _wizardPacks() { return ((this._state && this._state.templates) || []).filter(t => t.builtin); } _wizardHave(list, name) { const n = (name || "").trim().toLowerCase(); return ((this._state && this._state[list]) || []).some(x => (x.name || "").trim().toLowerCase() === n); } _wizardInitChores() { const wz = this._wz; const groups = {}; for (const s of this._wzCatalogue.chores) (groups[s.age_group] = groups[s.age_group] || []).push(s); for (const [gid, list] of Object.entries(groups)) { const inGroup = wz.kids.filter(k => this._wizardBand(k) === gid).map(k => k.key); list.forEach((s, i) => { const prev = wz.chores[s.id]; const keys = new Set(wz.kids.map(k => k.key)); if (prev && prev.touched) { prev.who = prev.who.filter(k => keys.has(k)); if (!prev.who.length) prev.on = false; return; } const have = this._wizardHave("chores", this._wizardChoreName(s)); wz.chores[s.id] = { on: inGroup.length > 0 && i < 5 && !have, pts: s.points, who: inGroup, touched: false }; }); } } _wizardInitRewards() { const wz = this._wz; for (const r of this._wzCatalogue.rewards) { if (wz.rewards[r.id]) continue; wz.rewards[r.id] = { on: !!r.picked && !this._wizardHave("rewards", this._wizardRewardName(r)), cost: r.cost }; } } _wizardPerWeek(s) { return Number(s.per_week) || 0; } // A pack chore named like a ticked suggestion (or a chore the family has) // is only added once. _wizardPackDupe(name) { const n = name.trim().toLowerCase(); if (this._wizardHave("chores", name)) return true; return this._wzCatalogue.chores.some(s => this._wz.chores[s.id].on && this._wizardChoreName(s).trim().toLowerCase() === n); } _wizardWeekly(kid) { let pts = 0, n = 0; for (const s of this._wzCatalogue.chores) { const c = this._wz.chores[s.id]; if (c.on && c.who.includes(kid.key) && !this._wizardHave("chores", this._wizardChoreName(s))) { n++; pts += c.pts * this._wizardPerWeek(s); } } for (const tpl of this._wizardPacks()) { if (!this._wz.packs.includes(tpl.id)) continue; (tpl.chores || []).forEach((c, i) => { if (this._wizardPackDupe(this._wizardPackChoreName(tpl, i))) return; n++; pts += (c.points || 0) * ((c.due_days || []).length || 7); }); } return { n, pts: Math.round(pts) }; } _wizardApproval(points) { const a = this._wz.approval; return a === "all" || (a === "big" && points >= 4); } // Everything the Create button will send, built from the current picks. _wizardPlan() { const wz = this._wz; const children = wz.kids.filter(k => !k.existing).map(k => ({ ref: k.key, name: k.name.trim(), avatar: k.icon, birthday: k.mode === "bday" ? k.bday : "", age_group: this._wizardBand(k), })); // Existing children: only fill in a missing age group, never change one. const child_updates = wz.kids .filter(k => k.existing && !k.origBand && this._wizardBand(k)) .map(k => ({ child_id: k.key, age_group: this._wizardBand(k) })); const chores = []; for (const s of this._wzCatalogue.chores) { const c = wz.chores[s.id]; const name = this._wizardChoreName(s); if (!c.on || !c.who.length || this._wizardHave("chores", name)) continue; chores.push({ name, points: c.pts, icon: s.icon, assigned_to: [...c.who], requires_approval: this._wizardApproval(c.pts), time_category: s.time_category, daily_limit: 1, ...s.schedule, }); } for (const tpl of this._wizardPacks()) { if (!wz.packs.includes(tpl.id)) continue; (tpl.chores || []).forEach((c, i) => { const name = this._wizardPackChoreName(tpl, i); if (this._wizardPackDupe(name)) return; chores.push({ name, points: c.points || 0, assigned_to: [], requires_approval: this._wizardApproval(c.points || 0), time_category: c.time_category || "anytime", daily_limit: c.daily_limit || 1, schedule_mode: c.schedule_mode || "specific_days", due_days: [...(c.due_days || [])], }); }); } const rewards = []; for (const r of this._wzCatalogue.rewards) { const pick = wz.rewards[r.id]; const name = this._wizardRewardName(r); if (pick.on && !this._wizardHave("rewards", name)) rewards.push({ name, cost: pick.cost, icon: r.icon }); } for (const r of wz.custom) if (r.on) rewards.push({ name: r.name, cost: r.cost, icon: "mdi:gift-outline" }); return { children, child_updates, chores, rewards }; } // "YYYY-MM-DD" naming a day that exists (no 13th month, no 30 February). _wizardRealDate(value) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || ""); if (!m) return false; const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]; } // True when the plan would add or fill in anything at all. _wizardPlanHasWork(plan) { return !!(plan.children.length || plan.child_updates.length || plan.chores.length || plan.rewards.length); } _wizardAddKid() { const wz = this._wz; const f = wz.form; const name = (f.name || "").trim(); if (!name) return false; const today = new Date().toISOString().slice(0, 10); if (f.mode === "bday" && !this._wizardRealDate(f.bday)) { this._showToast("err", this._t("wizard.toast_invalid_birthday")); return false; } if (f.mode === "bday" && f.bday > today) { this._showToast("err", this._t("wizard.toast_bad_birthday")); return false; } if (wz.kids.some(k => k.name.trim().toLowerCase() === name.toLowerCase())) { this._showToast("err", this._t("wizard.toast_child_exists", { name })); return false; } wz.kids.push({ key: `new${wz.seq++}`, name, icon: f.icon, color: f.color, mode: f.mode, bday: f.bday, age: f.age, band: f.band, existing: false }); wz.form = this._wizardNewForm(); this._wizardInitChores(); return true; } _wizardGo(step) { const wz = this._wz; if (wz.busy || step === 5) return; // Leaving Children with a name typed adds that child, as the footer says. if (wz.step === 1 && step > 1 && (wz.form.name || "").trim() && !this._wizardAddKid()) return; if (step >= 2 && !wz.kids.length) { this._showToast("err", this._t("wizard.toast_need_child")); wz.step = 1; this._render(); return; } wz.step = Math.max(0, Math.min(4, step)); this._wizardSaveDraft(); this._render(); const body = this.querySelector(".tm-body"); if (body) body.scrollTop = 0; } _wizardAct(t, e) { const wz = this._wz; const act = t.dataset.act; const v = t.dataset.v; if (act === "wz-open") { this._wizardRequestOpen(); return; } if (act === "wz-open-confirmed") { this._closeDialog(true); this._wizardOpen(); return; } if (act === "wz-nudge") { this._wizardNudgeAct(t.dataset.id, t.dataset.v, true); return; } if (act === "wz-nudge-dismiss") { this._wizardNudgeAct(t.dataset.id, t.dataset.v, false); return; } if (!wz) return; if (wz.busy && act !== "wz-noop") return; const f = wz.form; switch (act) { case "wz-close": this._wizardClose(); return; case "wz-today": this._wz = null; this._activeTab = "today"; this._render(); return; case "wz-restore": this._wizardClose("settings"); return; case "wz-dashboard": history.pushState(null, "", `/${WZ_DASH_PATH}`); window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } })); return; case "wz-go": this._wizardGo(Number(v)); return; case "wz-next": this._wizardGo(wz.step + 1); return; case "wz-back": this._wizardGo(wz.step - 1); return; case "wz-create": this._wizardCreate(); return; case "wz-pname": wz.pnameCustom = v === "custom"; if (!wz.pnameCustom) { const hit = WZ_POINT_NAMES.find(([k]) => k === v); wz.pname = this._t(`wizard.pname_${v}`); wz.picon = hit ? hit[1] : wz.picon; } break; case "wz-fmode": f.mode = v; break; case "wz-fage": f.age = Math.max(2, Math.min(18, (Number(f.age) || 0) + Number(t.dataset.d))); break; case "wz-fband": f.band = v; break; case "wz-ficon": f.icon = v; break; case "wz-fcolor": f.color = v; break; case "wz-add-kid": this._wizardAddKid(); break; case "wz-edit-kid": { const k = wz.kids.find(x => x.key === t.dataset.id); if (!k || k.existing) return; wz.kids = wz.kids.filter(x => x !== k); wz.form = { name: k.name, mode: k.mode, bday: k.bday, age: k.age, band: k.band || "6_8", icon: k.icon, color: k.color }; this._wizardInitChores(); break; } case "wz-del-kid": wz.kids = wz.kids.filter(k => k.existing || k.key !== t.dataset.id); this._wizardInitChores(); break; case "wz-kband": { const k = wz.kids.find(x => x.key === t.dataset.id); if (k && k.existing && !k.origBand) { k.band = v; this._wizardInitChores(); } break; } case "wz-sg": { const c = wz.chores[t.dataset.id]; const s = this._wzCatalogue.chores.find(x => x.id === t.dataset.id); if (!c || !s) return; if (!c.on && !c.who.length) c.who = this._wizardEligible(s.age_group).map(k => k.key); if (!c.who.length) return; c.on = !c.on; c.touched = true; break; } case "wz-sg-who": { e.stopPropagation(); const c = wz.chores[t.dataset.id]; if (!c) return; const i = c.who.indexOf(t.dataset.k); if (i >= 0) c.who.splice(i, 1); else c.who.push(t.dataset.k); c.touched = true; // Nobody left means nobody would get it (and an empty list is "everyone"). c.on = c.who.length > 0; break; } case "wz-sg-pts": { e.stopPropagation(); const c = wz.chores[t.dataset.id]; if (!c) return; c.pts = Math.max(1, Math.min(100, c.pts + Number(t.dataset.d))); c.touched = true; break; } case "wz-band-all": { const list = this._wzCatalogue.chores.filter(s => s.age_group === v && !this._wizardHave("chores", this._wizardChoreName(s))); const el = this._wizardEligible(v).map(k => k.key); const all = list.every(s => wz.chores[s.id].on); for (const s of list) { const c = wz.chores[s.id]; if (!all && !c.who.length) c.who = [...el]; c.on = !all && c.who.length > 0; c.touched = true; } break; } case "wz-show-all": wz.showAll = true; break; case "wz-pack": wz.packs = wz.packs.includes(v) ? wz.packs.filter(p => p !== v) : [...wz.packs, v]; break; case "wz-rw": { const r = wz.rewards[t.dataset.id]; if (r) r.on = !r.on; break; } case "wz-rw-cost": { e.stopPropagation(); const r = wz.rewards[t.dataset.id] || wz.custom.find(x => x.key === t.dataset.id); if (r) r.cost = Math.max(5, Math.min(100000, r.cost + Number(t.dataset.d))); break; } case "wz-rw-custom-toggle": { const r = wz.custom.find(x => x.key === t.dataset.id); if (r) r.on = !r.on; break; } case "wz-rw-add": { const name = (wz.rwName || "").trim(); if (!name) return; wz.custom.push({ key: `rw${wz.seq++}`, name, cost: Math.max(0, Math.round(Number(wz.rwCost) || 0)), on: true }); wz.rwName = ""; wz.rwCost = 20; break; } case "wz-appr": wz.approval = v; break; case "wz-dash": wz.dash = !wz.dash; break; case "wz-notify": wz.notify = !wz.notify; break; default: return; } this._wizardSaveDraft(); this._render(); } // Typing must not rebuild the page (it would steal the focus), so inputs // update the draft and patch only what shows the value. _wizardInput(t) { const wz = this._wz; if (!wz) return; const key = t.dataset.wzIn; if (key === "pname") { wz.pname = t.value; } else if (key === "rw-name") { wz.rwName = t.value; } else if (key === "rw-cost") { wz.rwCost = t.value; } else if (key === "name") { wz.form.name = t.value; const label = this._t("wizard.add_named", { name: t.value.trim() || this._t("wizard.child_word") }); this.querySelectorAll('[data-act="wz-add-kid"]').forEach(b => { b.disabled = !t.value.trim(); b.lastElementChild.textContent = label; }); this.querySelectorAll(".tm-wz-mini-name").forEach(el => { el.textContent = t.value.trim() || this._t("wizard.new_child"); }); const hint = this.querySelector(".tm-wz-foot-hint"); if (hint) hint.textContent = t.value.trim() ? this._t("wizard.foot_will_add", { name: t.value.trim() }) : ""; } else if (key === "bday") { wz.form.bday = t.value; const age = this._wizardAgeOf({ mode: "bday", bday: t.value }); const text = age != null ? this._t("wizard.age_n", { age }) : ""; this.querySelectorAll(".tm-wz-age-now").forEach(el => { el.textContent = text; }); this.querySelectorAll(".tm-wz-add .tm-wz-mini-sub").forEach(el => { el.textContent = text || this._t("wizard.ready_for_chores"); }); } this._wizardSaveDraft(); } async _wizardCreate() { const wz = this._wz; if (wz.busy) return; const plan = this._wizardPlan(); if (!this._wizardPlanHasWork(plan)) return; wz.busy = true; this._render(); const { ok, res, err, code } = await this._callWS({ type: "taskmate/setup_wizard/apply", ...plan }); if (!ok) { wz.busy = false; this._render(); // Birthday problems come back as codes, so the parent reads them in // their own language rather than Python's wording. const known = { invalid_birthday: "wizard.toast_invalid_birthday", future_birthday: "wizard.toast_bad_birthday" }[code]; this._showToast("err", known ? this._t(known) : this._t("wizard.toast_create_failed", { error: err })); return; } // The points name is only touched when it was changed on the Welcome step. const pname = (wz.pname || "").trim(); if (pname && (pname !== wz.pnameOrig || wz.picon !== wz.piconOrig)) { await this._callWS({ type: "taskmate/update_settings", points_name: pname.slice(0, 120), points_icon: wz.picon }); } if (wz.notify) await this._wizardEnableApprovalAlerts(); let dashboard = "off"; if (wz.dash) dashboard = await this._wizardCreateDashboard(res.children || {}); wz.result = { ...res, dashboard, updated: plan.child_updates.length }; wz.busy = false; wz.step = 5; localStorage.removeItem(WZ_DRAFT_KEY); await this._fetchState(); } // "Tell me when something needs approving": turn the two approval alerts on // if they were off. Never turns anything off. async _wizardEnableApprovalAlerts() { const cfg = (this._notifState && this._notifState.config) || {}; for (const typeId of ["pending_chore_approval", "pending_reward_claim"]) { if (cfg[typeId] && cfg[typeId].master_enabled) continue; await this._callWS({ type: "taskmate/notifications/set_master_enabled", type_id: typeId, enabled: true }); } } _wizardOverviewEntity() { const states = (this._hass && this._hass.states) || {}; if (states["sensor.taskmate_overview"]) return "sensor.taskmate_overview"; const hit = Object.keys(states).find(id => { const a = id.startsWith("sensor.") && states[id].attributes; return a && a.points_name !== undefined && Array.isArray(a.children); }); return hit || "sensor.taskmate_overview"; } // A "Family chores" dashboard: one child card per child plus the rewards // card. Created only when the url is free — an existing dashboard (or any // panel on that path) is never touched. async _wizardCreateDashboard(refToId) { if (this._hass && this._hass.panels && this._hass.panels[WZ_DASH_PATH]) return "exists"; const list = await this._callWS({ type: "lovelace/dashboards/list" }); if (!list.ok) return "failed"; if ((list.res || []).some(d => d.url_path === WZ_DASH_PATH)) return "exists"; const title = this._t("wizard.dash_title"); const created = await this._callWS({ type: "lovelace/dashboards/create", url_path: WZ_DASH_PATH, title, icon: "mdi:checkbox-marked-circle-plus-outline", mode: "storage", show_in_sidebar: true, require_admin: false, }); if (!created.ok) return "failed"; const entity = this._wizardOverviewEntity(); const cards = this._wz.kids.map(k => ({ type: "custom:taskmate-child-card", entity, child_id: refToId[k.key] || k.key, time_category: "all", header_color: k.color, })); cards.push({ type: "custom:taskmate-rewards-card", entity }); const saved = await this._callWS({ type: "lovelace/config/save", url_path: WZ_DASH_PATH, config: { title, views: [{ title, path: "chores", icon: "mdi:checkbox-marked-circle-outline", cards }] }, }); return saved.ok ? "created" : "failed"; } // "Vaiha is 9 now — see new chore ideas" (#980): suggestions never move up // by themselves. Shown once per step up: either button records the new group. _wizardNudgeKid() { for (const c of (this._state && this._state.children) || []) { if (!c.age_group || !/^\d{4}-\d{2}-\d{2}$/.test(c.birthday || "")) continue; const kid = { mode: "bday", bday: c.birthday }; const now = this._wizardBand(kid); if (this._wizardBandIdx(now) > this._wizardBandIdx(c.age_group)) return { child: c, age: this._wizardAgeOf(kid), group: now }; } return null; } _wizardNudge() { const hit = this._wizardNudgeKid(); if (!hit) return ""; const id = this._esc(hit.child.id); return `
${this._childAvatar(hit.child)} ${this._t("wizard.nudge_text", { name: `${this._esc(hit.child.name)}`, age: hit.age })}
`; } async _wizardNudgeAct(childId, group, open) { const { ok, err } = await this._callWS({ type: "taskmate/update_child", child_id: childId, age_group: group }); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; } await this._fetchState(); if (open) await this._wizardOpen({ step: 2 }); } // ---- wizard rendering ---- _wizardKav(kid, size = 44, off = false) { return ``; } _wizardUnit(n) { return `${this._fmtNum(n)}`; } _wizardGroupLabel(id) { return this._t(`wizard.age_group.${id}`); } _wizardFreqLabel(s) { const time = this._t(`panel.time_${s.time_category}`); let freq; if (s.frequency === "days") freq = s.days.map(d => this._labelOf(DAYS, d)).join(", "); else freq = this._t(`wizard.freq_${s.frequency}`); return `${freq} · ${time}`; } _wizardBody() { const wz = this._wz; if (!wz || !this._wzCatalogue) { return this._emptyState("🪄", this._t("wizard.title"), this._t("wizard.intro_existing"), "wz-open", this._t("wizard.run_btn")); } const step = wz.step; const last = step === 5; const steps = WZ_STEPS.map((id, i) => ` ${i ? `` : ""} `).join(""); const nextKey = ["wizard.next_welcome", "wizard.next_children", "wizard.next_chores", "wizard.next_rewards"][step]; const body = [ () => this._wizardStepWelcome(), () => this._wizardStepChildren(), () => this._wizardStepChores(), () => this._wizardStepRewards(), () => this._wizardStepReview(), () => this._wizardStepDone(), ][step](); const typed = step === 1 && (wz.form.name || "").trim(); return `
${this._t("wizard.title")} ${last ? "" : ``}
${body}
${last ? "" : `
${step ? `` : ``} ${step === 0 && wz.mode === "fresh" ? `` : ""} ${step === 1 ? `${typed ? this._esc(this._t("wizard.foot_will_add", { name: typed })) : ""}` : ""} ${step === 4 ? `` : ``}
`}
`; } _wizardStepWelcome() { const wz = this._wz; const ex = wz.mode === "existing"; const s = this._state; const tiles = [ ["mdi:account-multiple", "#3498db", "children"], ["mdi:check-circle-outline", "#2ecc71", "chores"], ["mdi:gift-outline", "#9b59b6", "rewards"], ]; return ` ${ex ? `
${this._t("wizard.existing_banner", { children: this._wizardCount("children", (s.children || []).length), chores: this._wizardCount("chores", (s.chores || []).length), rewards: this._wizardCount("rewards", (s.rewards || []).length) })}
` : ""}

${this._t(ex ? "wizard.welcome_title_existing" : "wizard.welcome_title")}

${this._t(ex ? "wizard.welcome_copy_existing" : "wizard.welcome_copy")}

${tiles.map(([icon, color, id], i) => `
${i + 1} · ${this._t(`wizard.step_${id}`)}${this._t(`wizard.tile_${id}`)}
`).join("")}
${this._t("wizard.pname_question")}
${WZ_POINT_NAMES.map(([k, icon]) => { const on = !wz.pnameCustom && wz.pname === this._t(`wizard.pname_${k}`); return ``; }).join("")}
${wz.pnameCustom ? `` : ""}

${this._t("wizard.pname_hint")}

`; } _wizardKidMeta(k) { const bits = []; const age = this._wizardAgeOf(k); if (k.mode === "bday" && age != null) { const [y, m, d] = k.bday.split("-").map(Number); const when = new Date(y, m - 1, d).toLocaleDateString([], { day: "numeric", month: "long", year: "numeric" }); bits.push(`${this._esc(when)} · ${this._t("wizard.age_n", { age })}`); } else if (k.mode === "age" && age != null) { bits.push(`${this._t("wizard.age_n", { age })}`); } const band = this._wizardBand(k); if (band) { const g = this._wzCatalogue.age_groups.find(x => x.id === band) || {}; bits.push(`${this._t("wizard.group_chores", { group: this._wizardGroupLabel(band) })}`); } return bits.join(`·`); } _wizardStepChildren() { const wz = this._wz; const f = wz.form; const preview = { mode: f.mode, bday: f.bday, age: f.age }; const pa = this._wizardAgeOf(preview); const groups = this._wzCatalogue.age_groups; const today = new Date().toISOString().slice(0, 10); const rows = wz.kids.map(k => { const needsBand = k.existing && !this._wizardBand(k); const bandPick = k.existing && !k.origBand && k.mode !== "bday"; return `
${this._wizardKav(k)}
${this._esc(k.name)}${k.existing ? `${this._t("wizard.already_set_up")}` : ""}
${needsBand ? `${this._t("wizard.no_age_yet")}` : this._wizardKidMeta(k)}
${bandPick ? `
${groups.map(g => ``).join("")}
` : ""}
${k.existing ? "" : ` `}
`; }).join(""); const modes = [["bday", "wizard.age_mode_bday"], ["age", "wizard.age_mode_age"], ["none", "wizard.age_mode_none"]]; let ageCtl; if (f.mode === "bday") { ageCtl = `
${pa != null ? this._t("wizard.age_n", { age: pa }) : ""}

${this._t("wizard.bday_hint")}

`; } else if (f.mode === "age") { ageCtl = `
${this._num(f.age)}
`; } else { ageCtl = `
${groups.map(g => ``).join("")}

${this._t("wizard.group_hint")}

`; } const typed = (f.name || "").trim(); return `

${this._t("wizard.children_title")}

${this._t("wizard.children_copy")}

${rows ? `
${rows}
` : ""}

${this._t("wizard.add_child")}

${this._t("wizard.age")}
${modes.map(([m, key]) => ``).join("")}
${ageCtl}
${this._t("wizard.avatar")}
${WZ_KID_ICONS.map(ic => ``).join("")}
${this._t("wizard.colour")}
${WZ_KID_COLORS.map(c => ``).join("")}
${this._t("wizard.preview")}
${this._esc(typed || this._t("wizard.new_child"))}
${pa != null ? this._t("wizard.age_n", { age: pa }) : this._t("wizard.ready_for_chores")}
0
${this._t("wizard.preview_chores")}
${this._t("wizard.preview_rewards")}

${this._t("wizard.preview_hint")}

`; } _wizardSuggestionRow(g, s) { const c = this._wz.chores[s.id]; const name = this._wizardChoreName(s); if (this._wizardHave("chores", name)) { return `
${this._esc(name)}${this._t("wizard.have_chore")}
${this._t("wizard.already_have")}
`; } const el = this._wizardEligible(g.id); return ` `; } _wizardStepChores() { const wz = this._wz; const withKids = new Set(wz.kids.map(k => this._wizardBand(k)).filter(Boolean)); const bands = this._wzCatalogue.age_groups.map(g => { const list = this._wzCatalogue.chores.filter(s => s.age_group === g.id); const kids = wz.kids.filter(k => this._wizardBand(k) === g.id); const open = withKids.has(g.id) || wz.showAll; const pickable = list.filter(s => !this._wizardHave("chores", this._wizardChoreName(s))); const onN = list.filter(s => wz.chores[s.id].on).length; const allOn = pickable.length > 0 && pickable.every(s => wz.chores[s.id].on); return `
${this._wizardGroupLabel(g.id)}${kids.length ? `${kids.map(k => this._wizardKav(k, 22)).join("")}` : ""}
${open ? this._t(`wizard.age_group_sub.${g.id}`) : this._t("wizard.nobody_in_group")}
${open ? `${this._t("wizard.picked_n", { count: onN })} ${this._wizardEligible(g.id).length ? `` : ""}` : ``}
${open ? list.map(s => this._wizardSuggestionRow(g, s)).join("") : ""}
`; }).join(""); const packs = this._wizardPacks(); const packBands = packs.filter(p => wz.packs.includes(p.id)).map(p => `
${this._esc(this._wizardPackName(p))}${this._t("wizard.template_pack")}
${this._t("wizard.pack_sub")}
${(p.chores || []).map((c, i) => { const name = this._wizardPackChoreName(p, i); const dupe = this._wizardPackDupe(name); const days = (c.due_days || []).length ? c.due_days.map(d => this._labelOf(DAYS, d)).join(", ") : this._t("wizard.freq_daily"); return `
${this._esc(name)}${dupe ? this._t("wizard.pack_dupe") : this._esc(`${days} · ${this._t(`panel.time_${c.time_category || "anytime"}`)}`)}
${wz.kids.map(k => this._wizardKav(k, 26)).join("")} ${this._wizardUnit(c.points || 0)}
`; }).join("")}
`).join(""); return `

${this._t("wizard.chores_title")}

${this._t("wizard.chores_copy")}

${bands}${packBands}
`; } _wizardTopEarner() { return this._wz.kids.map(k => [k, this._wizardWeekly(k).pts]).filter(([, p]) => p > 0).sort((a, b) => b[1] - a[1])[0] || null; } _wizardEvery(cost, top) { if (!top) return ""; const days = Math.max(1, Math.round(cost / (top[1] / 7))); return this._t(days === 1 ? "wizard.every_day" : "wizard.every_n_days", { count: days }); } _wizardStepRewards() { const wz = this._wz; const top = this._wizardTopEarner(); const card = (id, name, icon, pick, have, custom = false) => { if (have) return `
${this._esc(name)}
${this._t("wizard.already_have")}
`; return ``; }; const tiers = this._wzCatalogue.reward_tiers.map(tier => `
${this._t(`wizard.tier.${tier}`)}
${this._wzCatalogue.rewards.filter(r => r.tier === tier).map(r => { const name = this._wizardRewardName(r); return card(r.id, name, r.icon, wz.rewards[r.id], this._wizardHave("rewards", name)); }).join("")}
`).join(""); const custom = wz.custom.length ? `
${this._t("wizard.tier.custom")}
${wz.custom.map(r => card(r.key, r.name, "mdi:gift-outline", r, false, true)).join("")}
` : ""; const earn = top ? this._t("wizard.rewards_earns", { name: this._esc(top[0].name), amount: `${top[1]} ${this._esc(wz.pname)}` }) : ""; return `

${this._t("wizard.rewards_title")}

${this._t("wizard.rewards_copy")} ${earn}

${tiers}${custom}
`; } _wizardCount(noun, n) { return this._t(`wizard.count_${noun}_${n === 1 ? "one" : "many"}`, { count: n }); } _wizardCounts(plan) { return { children: this._wizardCount("children", plan.children.length), chores: this._wizardCount("chores", plan.chores.length), rewards: this._wizardCount("rewards", plan.rewards.length), }; } _wizardStepReview() { const wz = this._wz; const s = this._state; const plan = this._wizardPlan(); const c = this._wizardCounts(plan); const keep = { ...c, have_chores: this._wizardCount("chores", (s.chores || []).length), have_rewards: this._wizardCount("rewards", (s.rewards || []).length) }; const intro = !this._wizardPlanHasWork(plan) ? this._t("wizard.review_nothing") : wz.mode !== "existing" ? this._t("wizard.review_fresh", c) : this._t(plan.children.length ? "wizard.review_existing" : "wizard.review_existing_no_children", keep); const packNames = plan.chores.filter(ch => !ch.assigned_to.length).map(ch => ch.name); const kidCards = wz.kids.map(k => { const names = plan.chores.filter(ch => ch.assigned_to.includes(k.key)).map(ch => ch.name).concat(packNames); const w = this._wizardWeekly(k); const age = this._wizardAgeOf(k); return `
${this._esc(k.name)}
${age != null ? `${this._t("wizard.age_n", { age })} · ` : this._wizardBand(k) ? `${this._wizardGroupLabel(this._wizardBand(k))} · ` : ""}${this._t(k.existing ? "wizard.kid_existing" : "wizard.kid_new")}
${this._t("wizard.per_week_short", { amount: w.pts })}
    ${names.slice(0, 5).map(n => `
  • ${this._esc(n)}
  • `).join("") || `
  • ${this._t("wizard.no_new_chores")}
  • `}
${names.length > 5 ? `
${this._t("wizard.n_more", { count: names.length - 5 })}
` : ""}
`; }).join(""); const appr = [["none", "wizard.appr_none"], ["big", "wizard.appr_big"], ["all", "wizard.appr_all"]]; const sw = (act, on, title, hint) => `
${title}${hint}
`; return `

${this._t("wizard.review_title")}

${intro}

${kidCards}

${this._t("wizard.defaults")}

${this._t("wizard.appr_title")}${this._t("wizard.appr_hint")}
${appr.map(([v, key]) => ``).join("")}
${sw("wz-dash", wz.dash, this._t("wizard.dash_toggle"), this._t("wizard.dash_hint"))} ${sw("wz-notify", wz.notify, this._t("wizard.notify_toggle"), this._t("wizard.notify_hint"))}

${this._t("wizard.rewards_n", { count: plan.rewards.length })}

${plan.rewards.map(r => `
${this._esc(r.name)}${this._wizardUnit(r.cost)}
`).join("") || `

${this._t("wizard.no_rewards")}

`}
`; } _wizardStepDone() { const wz = this._wz; const r = wz.result || {}; const conf = Array.from({ length: 36 }, (_, i) => ``).join(""); // Only what was actually added: never a row of zeros. const phrase = (noun, n) => (n ? `${this._wizardCount(noun, n)}` : ""); const added = (r.children_added || 0) + (r.chores_added || 0) + (r.rewards_added || 0) + (r.updated || 0); const copy = !added && r.dashboard !== "created" ? "wizard.done_copy_nothing" : wz.mode === "existing" ? "wizard.done_copy_existing" : "wizard.done_copy"; const dash = r.dashboard === "created" || r.dashboard === "exists"; const dashNote = r.dashboard === "exists" ? this._t("wizard.dash_existed") : r.dashboard === "failed" ? this._t("wizard.dash_failed") : ""; return `

${this._t("wizard.done_title")}

${this._t(copy)}

${phrase("children", r.children_added || 0)} ${phrase("chores", r.chores_added || 0)} ${phrase("rewards", r.rewards_added || 0)} ${r.dashboard === "created" ? `${this._t("wizard.one_dashboard")}` : ""}
${dashNote ? `

${dashNote}

` : ""}
${this._t("wizard.next_tablet_title")}${this._t("wizard.next_tablet")}
${this._t("wizard.next_today_title")}${this._t("wizard.next_today")}
${this._t("wizard.next_change_title")}${this._t("wizard.next_change")}
${dash ? `` : ""}
`; } _renderWizardConfirmDialog() { const d = this._dialog.data; return this._dialogShell(this._t("wizard.confirm_title"), `

${this._t("wizard.confirm_have", { children: this._wizardCount("children", d.children), chores: this._wizardCount("chores", d.chores), rewards: this._wizardCount("rewards", d.rewards) })}

`, ` `); } // Settings → Setup wizard (#980), at the top of Settings. _wizardSettingsSection() { return `
${this._t("wizard.title")}${this._t("wizard.settings_hint")}
`; } // The Today page with no children yet: start the wizard or go it alone. _wizardTodayEmpty() { return `

${this._t("wizard.empty_title")}

${this._t("wizard.empty_copy")}

`; } _wizardStyles() { return ``; } // -- 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"; // A weekly quota doesn't replace the day list — the chore is still only // offered on those days, it just stops once the week's count is filled. const weeklyPill = Number(c.weekly_target) > 0 ? ` ${this._t("panel.chore_weekly_target_pill", { count: Number(c.weekly_target) })}` : ""; const teamPill = Number(c.team_size) >= 2 ? ` ${this._t("panel.chore_team_pill", { count: Number(c.team_size) })}` : ""; 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" ? `${this._num(c.timed_rate_points)}/${this._num(c.timed_rate_minutes, 1)} min` : this._num(c.points)} ${this._esc(this._timeCategoryLabel(c.time_category))} ${assignedNames} ${modeBadge}${teamPill} ${currentName} ${this._esc(schedLabel)}${weeklyPill} ${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("")}
`} `; } /** Reward-list pill summarising a time lock (#857), or "" when unrestricted. */ _rewardTimeLockPill(r) { if (!r.time_lock_enabled) return ""; const summary = window.__taskmate_time_lock_label ? window.__taskmate_time_lock_label( { days: r.available_days, from: r.available_from, until: r.available_until }, (k, pr) => this._t(k, pr)) : ""; const label = summary || this._t("panel.reward_timelock_badge"); return `${this._mdi("mdi:clock-outline")} ${this._esc(label)}`; } _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")}` : ""} ${r.streak_freeze ? `❄️ ${this._t("panel.reward_badge_streak_freeze")}` : ""} ${this._rewardTimeLockPill(r)}

${this._idBadge(r.id)}
${this._esc(r.description || "")}
${this._fmtNum(r.cost)}
${this._t("panel.reward_stat_cost", {points_name: pointsName})}
${r.quantity != null ? `
${this._num(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}%
` : ""}
`; } // -- Wishlists tab (#932) ---------------------------------------------- // Children add wishes from the wishlist card; parents approve them here // (confirming or changing the target), pledge on behalf of relatives, and // hand a funded wish over by approving its reward claim. _safeWishImage(u) { return typeof u === "string" && u.startsWith("/api/taskmate/image/") ? u : ""; } // A wish link is typed by a child: only an absolute http(s) URL is ever // emitted into an href. _esc() stops markup injection, not a javascript: URL. _safeWishLink(u) { if (typeof u !== "string" || !u) return null; try { const url = new URL(u); if (url.protocol !== "http:" && url.protocol !== "https:") return null; return { href: url.href, host: url.hostname.replace(/^www\./, "") }; } catch (_e) { return null; } } _wishThumb(w, size = 44) { const src = this._safeWishImage(w.image_url); return src ? `` : `${this._mdi("mdi:gift-outline")}`; } _wishLinkChip(w) { const link = this._safeWishLink(w.link); return link ? `${this._mdi("mdi:link-variant")} ${this._esc(link.host)}` : ""; } _wishBar(w) { const target = this._num(w.target); const saved = this._num(w.saved); const pledged = this._num(w.pledged); const pct = (n) => (target > 0 ? Math.max(0, Math.min(100, (n / target) * 100)) : 0); return `
${this._ltrNums(`${this._fmtNum(Math.min(target, saved + pledged))} / ${this._fmtNum(target)}`)}
`; } _renderWishlistsTab() { const children = this._state.children || []; const childById = Object.fromEntries(children.map(c => [c.id, c])); // The wishlist's own child picker wins; otherwise follow the child filter (#966). const picked = this._wishChild || this._scopeId(); const filter = picked && childById[picked] ? picked : ""; const all = (this._state.wishes || []).filter(w => childById[w.child_id] && (!filter || w.child_id === filter)); const pending = all.filter(w => w.status === "pending"); const active = all.filter(w => w.status === "active" || w.status === "redeem_requested"); const history = all .filter(w => w.status === "redeemed" || w.status === "declined") .sort((a, b) => String(b.redeemed_at || b.declined_at || "").localeCompare(String(a.redeemed_at || a.declined_at || ""))); const pledges = all .flatMap(w => (w.pledges || []).map(p => ({ ...p, wish: w }))) .sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || ""))); const pointsName = this._state.settings.points_name || this._t("common.points"); const who = (w) => this._esc((childById[w.child_id] || {}).name || "?"); const chips = `
${children.map(c => ``).join("")}
`; const pendingHtml = pending.length === 0 ? `

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

` : pending.map(w => { const typed = (this._wishTargets || {})[w.id]; const value = typed != null ? typed : this._num(w.target); return `
${this._wishThumb(w, 52)}
${this._esc(w.name)} ${this._wishLinkChip(w)}
${this._t("panel.wish_suggested", { child: who(w), points: this._fmtNum(w.suggested_target || w.target), points_name: this._esc(pointsName) })} · ${this._esc(this._timeAgo(w.created_at))}
`; }).join(""); const activeHtml = active.length === 0 ? `

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

` : `
${active.map(w => { const status = w.status === "redeem_requested" ? `${this._t("panel.wish_status_redeem")}` : w.funded ? `${this._t("panel.wish_status_funded")}` : `${this._t("panel.wish_status_saving")}`; const main = w.status === "redeem_requested" && w.claim_id ? `` : (!w.funded && w.status === "active") ? `` : ""; return ` `; }).join("")}
${this._t("panel.wish_col_wish")}${this._t("panel.wish_col_progress")} ${this._t("panel.wish_col_saved")}${this._t("panel.wish_col_pledged")} ${this._t("panel.wish_col_status")}
${this._wishThumb(w, 32)}
${this._esc(w.name)}
${who(w)} ${this._wishLinkChip(w)}
${main}
${this._wishBar(w)} ${this._fmtNum(w.saved)} ${this._fmtNum(w.pledged)} ${status}
`; const pledgesHtml = pledges.length === 0 ? `

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

` : `
${pledges.map(p => ` `).join("")}
${this._t("panel.wish_col_from")}${this._t("panel.wish_col_to")}${this._t("panel.wish_col_amount")} ${this._t("panel.wish_col_message")}${this._t("panel.wish_col_date")}
${this._esc(p.name)}
${p.wish.status === "active" || p.wish.status === "redeem_requested" ? `
` : ""}
${who(p.wish)} · ${this._esc(p.wish.name)} +${this._fmtNum(p.points)} ${this._esc(p.message || "—")} ${this._esc(this._timeAgo(p.created_at))}
`; const historyHtml = history.length === 0 ? `

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

` : `
${history.map(w => ` `).join("")}
${this._wishThumb(w, 32)}
${this._esc(w.name)}
${who(w)}
${w.status === "redeemed" ? `${this._t("panel.wish_status_redeemed")}` : `${this._t("panel.wish_status_declined")}${w.decline_reason ? ` ${this._esc(w.decline_reason)}` : ""}`} ${this._fmtNum(w.target)} ${this._esc(this._timeAgo(w.redeemed_at || w.declined_at || w.created_at))}
`; return `

${this._t("panel.tab_wishlists")} ${active.length + pending.length}

${chips}

${this._t("panel.wish_section_pending")} ${pending.length ? `${pending.length}` : ""}

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

${pendingHtml}

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

${activeHtml}

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

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

${pledgesHtml}

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

${historyHtml}
`; } _openWishPledge(wishId) { const w = (this._state.wishes || []).find(x => x.id === wishId); if (!w) return; const remaining = this._num(w.remaining); this._openDialog({ kind: "wish-pledge", data: { wish_id: w.id, name: "", points: Math.min(20, remaining), message: "" } }); } _renderWishPledgeDialog() { const d = this._dialog.data; const w = (this._state.wishes || []).find(x => x.id === d.wish_id); if (!w) return ""; const child = (this._state.children || []).find(c => c.id === w.child_id) || {}; const remaining = this._num(w.remaining); // Quick picks: names already used for pledges, most recent first. const names = [...new Set((this._state.wishes || []) .flatMap(x => x.pledges || []) .sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || ""))) .map(p => p.name).filter(Boolean))].slice(0, 6); const amount = Math.max(0, Math.min(this._num(d.points), remaining)); const target = this._num(w.target); const pct = (n) => (target > 0 ? Math.max(0, Math.min(100, (n / target) * 100)) : 0); const body = ` ${this._field(this._t("panel.wish_pledge_who"), "name", d.name)} ${names.length ? `
${names.map(n => ``).join("")}
` : ""} ${this._field(this._t("panel.wish_pledge_amount"), "points", d.points, "number", this._esc(this._t("panel.wish_pledge_amount_hint", { points: this._fmtNum(remaining) })))}
${[10, 20, 50].filter(n => n < remaining).map(n => ``).join("")}
${this._field(this._t("panel.wish_pledge_message", { name: child.name || "" }), "message", d.message)}

${this._ltrNums(this._t("panel.wish_pledge_preview", { total: this._fmtNum(Math.min(target, this._num(w.saved) + this._num(w.pledged) + amount)), target: this._fmtNum(target) }))}

`; return this._dialogShell( this._t("panel.wish_pledge_title", { child: child.name || "", wish: w.name }), body, ` ` ); } _renderWishDeclineDialog() { const d = this._dialog.data; const w = (this._state.wishes || []).find(x => x.id === d.wish_id); if (!w) return ""; return this._dialogShell( this._t("panel.wish_decline_title", { wish: w.name }), this._field(this._t("panel.wish_decline_reason"), "reason", d.reason, "text", this._esc(this._t("panel.wish_decline_reason_hint"))), ` ` ); } async _doWishService(service, data, okKey, confirmKey) { if (confirmKey && !confirm(this._t(confirmKey))) return false; const { ok, err } = await this._callService(service, data); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return false; } await this._fetchState(); this._showToast("ok", this._t(okKey)); return true; } async _doApproveWish(wishId) { const w = (this._state.wishes || []).find(x => x.id === wishId); if (!w) return; const typed = (this._wishTargets || {})[wishId]; const target = typed != null && typed !== "" ? Number(typed) : this._num(w.target); if (!Number.isInteger(target) || target < 1 || target > 100000) { this._showToast("err", this._t("panel.wish_err_target")); return; } if (await this._doWishService("approve_wish", { wish_id: wishId, target }, "panel.wish_toast_approved")) { if (this._wishTargets) delete this._wishTargets[wishId]; } } async _doDeclineWish() { const d = this._dialog.data; const reason = String(d.reason || "").trim(); const data = reason ? { wish_id: d.wish_id, reason } : { wish_id: d.wish_id }; if (await this._doWishService("decline_wish", data, "panel.wish_toast_declined")) this._closeDialog(true); } async _doPledgeWish() { const d = this._dialog.data; const name = String(d.name || "").trim(); const points = Number(d.points); if (!name) { this._showToast("err", this._t("panel.wish_err_who")); return; } if (!Number.isInteger(points) || points < 1) { this._showToast("err", this._t("panel.wish_err_amount")); return; } const data = { wish_id: d.wish_id, name, points }; const message = String(d.message || "").trim(); if (message) data.message = message; if (await this._doWishService("pledge_to_wish", data, "panel.wish_toast_pledged")) this._closeDialog(true); } // -- 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) => { 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 || "")}
    ${this._num(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(", "))}
    `; } // -- Bounties tab (#931) ---------------------------------------------- // One-off jobs any eligible child can claim. Active = open + claimed; // Waiting approval = submitted, and the same completion also sits in the // Activity queue and the approvals card; History = completed + expired. _bountyLists() { const all = this._state.bounties || []; const closedDesc = (a, b) => String(b.closed_at || "").localeCompare(String(a.closed_at || "")); return { active: all.filter(b => b.status === "open" || b.status === "claimed"), pending: all.filter(b => b.status === "pending"), history: all.filter(b => b.status === "completed" || b.status === "expired").sort(closedDesc), }; } _bountyDur(ms) { const sec = Math.max(0, Math.round(ms / 1000)); const d = Math.floor(sec / 86400); const h = Math.floor((sec % 86400) / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (d) return this._t("bounty.dur_days", { d, h }); if (h) return this._t("bounty.dur_hours", { h, m: String(m).padStart(2, "0") }); return this._t("bounty.dur_minutes", { m, s: String(s).padStart(2, "0") }); } _renderBountiesTab() { const lists = this._bountyLists(); const all = this._state.bounties || []; const sub = this._bountySubTab || "active"; const rows = lists[sub] || []; const childById = Object.fromEntries((this._state.children || []).map(c => [c.id, c])); const tabs = [ { id: "active", label: this._t("panel.bounty_tab_active", { count: lists.active.length }) }, { id: "pending", label: this._t("panel.bounty_tab_pending", { count: lists.pending.length }) }, { id: "history", label: this._t("panel.bounty_tab_history", { count: lists.history.length }) }, ]; return `

    ${this._t("panel.tab_bounties")} ${lists.active.length + lists.pending.length}

    ${all.length === 0 ? this._emptyState("🏁", this._t("panel.bounty_empty_title"), this._t("panel.bounty_empty_copy"), "add-bounty", this._t("panel.bounty_post_btn")) : `
    ${tabs.map(t => ``).join("")}
    ${rows.length === 0 ? `` : rows.map(b => this._renderBountyRow(b, childById)).join("")}
    ${this._t("panel.bounty_col_bounty")}${this._t("panel.bounty_col_points")}${this._t("panel.bounty_col_eligible")}${this._t("panel.bounty_col_status")}${this._t("panel.bounty_col_expires")}
    ${this._t("panel.bounty_nothing")}
    `} `; } _renderBountyRow(b, childById) { const now = Date.now(); const name = (id) => (childById[id] && childById[id].name) || "?"; const eligible = (b.eligible_child_ids || []).length ? this._esc(b.eligible_child_ids.map(name).join(", ")) : `${this._t("panel.bounty_all_children")}`; const meta = [this._t("panel.bounty_lock_meta", { hours: b.claim_hours })]; if (b.require_photo) meta.push(this._t("panel.bounty_photo_meta")); const left = (iso) => this._bountyDur(Date.parse(iso || "") - now); let status = ""; if (b.status === "open") { status = `${this._t("panel.bounty_status_open")}`; } else if (b.status === "claimed") { status = `${this._t("panel.bounty_status_claimed")}
    ${this._t("panel.bounty_claimed_meta", { name: this._esc(name(b.claimed_by)), time: left(b.claim_until) })}
    `; } else if (b.status === "pending") { const comp = (this._state.completions || []).find(c => c.id === b.completion_id); status = `${this._t("panel.bounty_status_pending")}
    ${this._esc(name(b.claimed_by))}${comp ? ` · ${this._timeAgo(comp.completed_at)}` : ""}
    `; } else if (b.status === "completed") { status = `${this._t("panel.bounty_status_completed")}
    ${this._t("panel.bounty_completed_meta", { name: this._esc(name(b.claimed_by)), points: this._num(b.points_awarded || b.points) })}
    `; } else if (b.status === "expired") { status = `${this._t("panel.bounty_status_expired")}
    ${this._t(b.lapse_count > 0 ? "panel.bounty_expired_lapsed_meta" : b.claim_count > 0 ? "panel.bounty_expired_unfinished_meta" : "panel.bounty_expired_meta", { date: b.closed_at ? this._esc(new Date(b.closed_at).toLocaleDateString([], { day: "numeric", month: "short" })) : "" })}
    `; } if (b.lapse_count > 0) status += `
    ${this._t("panel.bounty_lapsed_meta", { count: b.lapse_count })}
    `; const expires = (b.status === "open" || b.status === "claimed") ? (b.expires_at ? this._esc(left(b.expires_at)) : this._t("panel.bounty_no_expiry")) : "—"; const id = this._esc(b.id); let actions; if (b.status === "pending" && b.completion_id) { const cid = this._esc(b.completion_id); actions = `${this._ratingOn() ? this._ratingPicker(b.completion_id) : ""} `; } else if (b.status === "claimed") { actions = ` `; } else if (b.status === "open") { actions = ` `; } else { actions = ``; } return `
    ${this._mdi(b.icon || "mdi:flag-outline")}
    ${this._esc(b.title)}
    ${this._esc(meta.join(" · "))}
    ${actions}
    ${this._num(b.points)} ${eligible} ${status} ${expires} `; } _openBountyDialog(id) { const pad = (n) => String(n).padStart(2, "0"); const localInput = (iso) => { const d = new Date(iso); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; }; if (id) { const b = (this._state.bounties || []).find(x => x.id === id); if (!b) return; this._openDialog({ kind: "bounty", mode: "edit", data: { id: b.id, status: b.status, title: b.title, description: b.description || "", points: b.points, icon: b.icon || "mdi:flag-outline", claim_hours: b.claim_hours, eligible_child_ids: [...(b.eligible_child_ids || [])], require_photo: !!b.require_photo, notify_children: b.notify_children !== false, exp_mode: b.expires_at ? "pick" : "none", exp_pick: b.expires_at ? localInput(b.expires_at) : "", exp_pick_orig: b.expires_at ? localInput(b.expires_at) : "", exp_dirty: false, } }); } else { this._openDialog({ kind: "bounty", mode: "add", data: { title: "", description: "", points: 50, icon: "mdi:car-wash", claim_hours: 2, eligible_child_ids: [], require_photo: false, notify_children: true, exp_mode: "24h", exp_pick: "", exp_dirty: true, } }); } } /** The dialog's expiry choice as an ISO instant, or null for none. */ _bountyExpiryIso(d) { const now = new Date(); const at = (base, hours) => { const x = new Date(base); x.setHours(hours, 0, 0, 0); return x; }; let when = null; if (d.exp_mode === "tonight") { when = at(now, 20); if (when <= now) when.setDate(when.getDate() + 1); } else if (d.exp_mode === "24h") { when = new Date(now.getTime() + 24 * 3600e3); } else if (d.exp_mode === "weekend") { when = at(now, 20); when.setDate(when.getDate() + ((7 - when.getDay()) % 7)); if (when <= now) when.setDate(when.getDate() + 7); } else if (d.exp_mode === "pick") { when = d.exp_pick ? new Date(d.exp_pick) : null; if (when && Number.isNaN(when.getTime())) when = null; } return when ? when.toISOString() : null; } async _doSaveBounty() { this._syncIconPickers(); const d = this._dialog.data; const wasAdd = this._dialog.mode === "add"; const locked = !wasAdd && d.status !== "open"; if (!d.title || !String(d.title).trim()) { this._showToast("err", this._t("panel.bounty_title_required")); return; } const expires = this._bountyExpiryIso(d); // Typing a new date into the picker counts as touching the expiry too. if (d.exp_mode === "pick" && d.exp_pick !== d.exp_pick_orig) d.exp_dirty = true; if (d.exp_dirty && d.exp_mode === "pick" && !expires) { this._showToast("err", this._t("panel.bounty_expiry_past")); return; } if (d.exp_dirty && expires && Date.parse(expires) <= Date.now()) { this._showToast("err", this._t("panel.bounty_expiry_past")); return; } const points = Math.max(1, Math.round(Number(d.points) || 0)); let payload; if (locked) { payload = { type: "taskmate/bounty_update", bounty_id: d.id, points }; } else { payload = { type: wasAdd ? "taskmate/bounty_post" : "taskmate/bounty_update", ...(wasAdd ? {} : { bounty_id: d.id }), title: String(d.title).trim(), description: d.description || "", points, icon: d.icon || "mdi:flag-outline", claim_hours: Math.min(48, Math.max(1, Math.round(Number(d.claim_hours) || 2))), eligible_child_ids: d.eligible_child_ids || [], require_photo: !!d.require_photo, notify_children: !!d.notify_children, }; } if (d.exp_dirty) payload.expires_at = expires; 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_bounty_posted") : this._t("panel.toast_bounty_updated")); } async _doReleaseBounty(id) { const b = (this._state.bounties || []).find(x => x.id === id); if (!b || !confirm(this._t("panel.bounty_release_confirm", { title: b.title }))) return; const { ok, err } = await this._callWS({ type: "taskmate/bounty_release", bounty_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_bounty_released")); } async _doRemoveBounty(id) { const b = (this._state.bounties || []).find(x => x.id === id); if (!b || !confirm(this._t("panel.bounty_remove_confirm", { title: b.title }))) return; const { ok, err } = await this._callWS({ type: "taskmate/bounty_remove", bounty_id: 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_bounty_removed")); } _bountyDialogSet(field, value) { if (!this._dialog || !this._dialog.data) return; this._syncIconPickers(); this._dialog.data[field] = value; if (field === "exp_mode") this._dialog.data.exp_dirty = true; 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; } _renderBountyDialog() { const d = this._dialog.data; const children = this._state.children || []; const locked = this._dialog.mode === "edit" && d.status !== "open"; const dis = locked ? " disabled" : ""; const chip = (act, id, on, label, extra = "") => ``; const expChips = [ ["none", this._t("panel.bounty_exp_none")], ["tonight", this._t("panel.bounty_exp_tonight")], ["24h", this._t("panel.bounty_exp_24h")], ["weekend", this._t("panel.bounty_exp_weekend")], ["pick", this._t("panel.bounty_exp_pick")], ]; const eligible = d.eligible_child_ids || []; return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.bounty_dialog_post") : this._t("panel.bounty_dialog_edit"), [ locked ? `

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

    ` : "", `
    ${this._t("panel.bounty_title_label")}
    `, `
    ${this._t("panel.bounty_desc_label")}
    `, `
    ${this._t("panel.bounty_points_label")}
    ${[10, 25, 50, 100].map(n => chip("bounty-points", n, Number(d.points) === n, String(n))).join("")}
    ${this._t("panel.bounty_lock_label")} ${this._t("panel.bounty_lock_hint")}
    `, locked ? "" : `
    ${this._t("panel.bounty_icon_label")}
    ${BOUNTY_ICONS.map(ic => chip("bounty-icon", ic, d.icon === ic, ``, ` title="${this._esc(ic)}"`)).join("")}
    ${this._iconPickerField("", "icon", d.icon)}`, `
    ${this._t("panel.bounty_expires_label")}
    ${expChips.map(([k, l]) => chip("bounty-exp", k, d.exp_mode === k, this._esc(l))).join("")}
    ${d.exp_mode === "pick" ? `` : ""}
    `, locked ? "" : `
    ${this._t("panel.bounty_who_label")}
    ${chip("bounty-el-all", "", eligible.length === 0, this._t("panel.bounty_all_children"))} ${children.map(c => chip("toggle-bounty-el", c.id, eligible.includes(c.id), this._esc(c.name))).join("")}
    `, locked ? "" : this._switch(this._t("panel.bounty_photo_label"), "require_photo", !!d.require_photo, this._t("panel.bounty_photo_hint")), locked || this._dialog.mode !== "add" ? "" : this._switch(this._t("panel.bounty_notify_label"), "notify_children", d.notify_children !== false, this._t("panel.bounty_notify_hint")), ].join(""), ` ` ); } // -- Auctions tab (#982) ---------------------------------------------- // Reverse auctions on one occurrence of a chore. Live = bidding open, with // the sealed bids (parents may see the amounts); Closed = settled or // cancelled, with the winner and how the won chore itself is going. _auctionable(c) { return !!c && c.enabled !== false && (c.assignment_mode || "everyone") !== "unassigned" && c.task_type !== "timed" && !c.open_ended && !(Number(c.team_size) >= 2); } _auctionDate(iso, opts = { weekday: "short", day: "numeric", month: "short" }) { const d = new Date(`${iso}T00:00:00`); return Number.isNaN(d.getTime()) ? String(iso || "") : d.toLocaleDateString(this._hass?.locale?.language, opts); } _auctionWhen(iso) { const d = new Date(iso || ""); return Number.isNaN(d.getTime()) ? "" : d.toLocaleString(this._hass?.locale?.language, { weekday: "short", hour: "2-digit", minute: "2-digit" }); } _renderAuctionsTab() { const all = this._state.auctions || []; const live = all.filter(a => a.status === "open"); const closed = all.filter(a => a.status !== "open") .sort((a, b) => String(b.closed_at || "").localeCompare(String(a.closed_at || ""))); const sub = this._auctionSubTab || "live"; const reveal = this._auctionReveal !== false; const monthAgo = Date.now() - 30 * 86400e3; const recent = closed.filter(a => a.status === "closed" && Date.parse(a.closed_at || "") >= monthAgo); const childById = Object.fromEntries((this._state.children || []).map(c => [c.id, c])); const rows = sub === "live" ? live : closed; const heads = sub === "live" ? ["col_chore", "col_max", "col_who", "col_bids", "col_closes"] : ["col_chore", "col_max", "col_bids", "col_result", "col_status"]; return `

    ${this._t("panel.tab_auctions")} ${live.length}

    ${this._t("panel.auction_page_sub")}
    ${all.length === 0 ? this._emptyState("🔨", this._t("panel.auction_empty_title"), this._t("panel.auction_empty_copy"), "add-auction", this._t("panel.auction_start_btn")) : `
    ${this._fmtNum(live.length)}
    ${this._t("panel.auction_stat_live")}
    ${this._fmtNum(live.reduce((n, a) => n + (a.bids || []).length, 0))}
    ${this._t("panel.auction_stat_bids")}
    ${this._ltrNums(`${this._fmtNum(recent.filter(a => a.winner_id).length)} / ${this._fmtNum(recent.length)}`)}
    ${this._t("panel.auction_stat_won")}
    ${sub === "live" ? ` ` : ""}
    ${heads.map((h, i) => ``).join("")} ${rows.length === 0 ? `` : rows.map(a => this._renderAuctionRow(a, childById, reveal)).join("")}
    ${this._t(`panel.auction_${h}`)}
    ${this._t("panel.auction_nothing")}
    `} `; } _auctionBidChips(a, childById, reveal) { const bids = a.bids || []; if (!bids.length) return `${this._t(a.status === "open" ? "panel.auction_no_bids_yet" : "panel.auction_no_bids")}`; const show = reveal || a.status !== "open"; return bids.map((b, i) => { const child = childById[b.child_id]; return ` ${child ? this._childAvatar(child) : ""} ${show ? `${this._num(b.points)} ★` : `${this._t("panel.auction_sealed")}`} ${i === 0 && a.status === "open" && show ? `· ${this._t("panel.auction_leading")}` : ""} `; }).join(""); } _renderAuctionRow(a, childById, reveal) { const id = this._esc(a.id); const chore = `
    ${this._mdi(a.icon || "mdi:gavel")}
    ${this._esc(a.chore_name)}
    ${this._esc(this._t("panel.auction_normally", { date: this._auctionDate(a.occurrence), points: this._num(a.normal_points) }))}
    `; const max = `${this._num(a.max_points)} ★${a.min_points > 1 ? `
    ${this._t("panel.auction_min_meta", { points: this._num(a.min_points) })}
    ` : ""}`; if (a.status === "open") { const left = Date.parse(a.closes_at || "") - Date.now(); const who = (a.eligible_child_ids || []).map(cid => childById[cid]).filter(Boolean) .map(c => this._childAvatar(c)).join(""); return `
    ${chore}
    ${max} ${who} ${this._auctionBidChips(a, childById, reveal)} ${this._esc(this._bountyDur(left))}
    ${this._esc(this._auctionWhen(a.closes_at))}
    `; } const winner = a.winner_id && childById[a.winner_id]; let result; let status; let actions = ""; const today = new Date(); const todayIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; if (a.status === "cancelled") { result = `${this._t("panel.auction_status_cancelled")}`; status = `${this._t("panel.auction_status_normal")}`; } else if (a.winner_id) { result = `
    ${winner ? this._childAvatar(winner) : ""}
    ${this._esc(winner ? winner.name : "?")} ${this._t("panel.auction_won_at", { points: `${this._num(a.price)} ★` })}
    ${this._esc(this._t("panel.auction_assigned_for", { date: this._auctionDate(a.occurrence) }))}
    `; const st = a.chore_status || "todo"; status = st === "done" ? `${this._t("panel.auction_status_done", { points: this._num(a.points_awarded) })}` : st === "pending" ? `${this._t("panel.auction_status_pending")}` : st === "missed" ? `${this._t("panel.auction_status_missed")}` : `${this._t("panel.auction_status_todo")}`; if (a.occurrence >= todayIso && st !== "done" && st !== "pending") { actions = ``; } } else { result = `${this._t("panel.auction_no_bids")}
    ${this._t("panel.auction_fallback")}
    `; status = `${this._t("panel.auction_status_normal")}`; if (a.occurrence > todayIso) { actions = ``; } } // Finished (#999): a parent can clear it off the list. A live one keeps // its Cancel above, which tells the bidders. if (a.deletable) { actions += ``; } return `
    ${chore}
    ${actions}
    ${max} ${this._auctionBidChips(a, childById, true)} ${result} ${status} `; } /** Open the start sheet, optionally for a chore or re-opening a no-bid auction. */ _openAuctionDialog({ choreId = "", fromId = "" } = {}) { const chores = (this._state.chores || []).filter(c => this._auctionable(c)); const from = fromId ? (this._state.auctions || []).find(a => a.id === fromId) : null; const chore_id = (from && from.chore_id) || choreId || (chores[0] && chores[0].id) || ""; this._openDialog({ kind: "auction", mode: "add", data: { chore_id, occurrences: [], occurrence: from ? from.occurrence : "", pool: [], eligible_child_ids: [], normal_points: 0, max_points: from ? Math.ceil(from.max_points * 1.5) : 0, reopen: !!from, min_on: false, min_points: 1, close_mode: "", close_pick: "", notify_children: true, loading: !!chore_id, refusal: "", } }); if (chore_id) this._auctionPickChore(chore_id); } async _auctionPickChore(choreId) { if (!this._dialog || this._dialog.kind !== "auction") return; const d = this._dialog.data; d.chore_id = choreId; d.loading = true; this._render(); const { ok, res, err } = await this._callWS({ type: "taskmate/auctions/occurrences", chore_id: choreId }); if (!this._dialog || this._dialog.kind !== "auction" || this._dialog.data.chore_id !== choreId) return; d.loading = false; if (!ok) { d.occurrences = []; d.refusal = err; this._render(); return; } d.occurrences = res.occurrences || []; d.refusal = res.refusal || ""; if (!d.occurrences.includes(d.occurrence)) d.occurrence = d.occurrences[0] || ""; d.pool = res.pool || []; d.eligible_child_ids = [...d.pool]; d.normal_points = Number(res.normal_points) || 0; if (!d.reopen || !d.max_points) d.max_points = Math.max(1, d.normal_points * 2); d.reopen = false; d.close_mode = this._auctionDefaultClose(d); this._dialogInitialHash = this._hashDialog(); this._render(); } /** A preset's closing instant (Date), or null when it can't be used. */ _auctionCloseAt(d, mode) { if (!d.occurrence) return null; const now = new Date(); const dayStart = new Date(`${d.occurrence}T00:00:00`); let when = null; if (mode === "tonight") { when = new Date(now); when.setHours(19, 0, 0, 0); } else if (mode === "tomorrow") { when = new Date(now); when.setDate(when.getDate() + 1); when.setHours(8, 0, 0, 0); } else if (mode === "daybefore") { when = new Date(dayStart); when.setDate(when.getDate() - 1); when.setHours(18, 0, 0, 0); } else if (mode === "pick") { when = d.close_pick ? new Date(d.close_pick) : null; } if (!when || Number.isNaN(when.getTime())) return null; if (when <= now || when > dayStart) return null; return when; } _auctionDefaultClose(d) { return ["tonight", "tomorrow", "daybefore"].find(m => this._auctionCloseAt(d, m)) || "pick"; } _auctionDialogSet(field, value) { if (!this._dialog || this._dialog.kind !== "auction") return; const d = this._dialog.data; if (field === "max_step") d.max_points = Math.max(1, (Number(d.max_points) || 0) + value); else d[field] = value; 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; } _renderAuctionDialog() { const d = this._dialog.data; const children = this._state.children || []; const chores = (this._state.chores || []).filter(c => this._auctionable(c) || c.id === d.chore_id) .sort((a, b) => String(a.name).localeCompare(String(b.name))); const chip = (act, id, on, label, extra = "") => ``; const section = (key, parts) => `

    ${this._t(`panel.auction_sec_${key}`)}

    ${parts.join("")}
    `; const normal = this._num(d.normal_points); const multiples = [1, 1.5, 2, 3].map(m => [m, Math.max(1, Math.round(normal * m))]); const closeChips = [ ["tonight", this._t("panel.auction_close_tonight")], ["tomorrow", this._t("panel.auction_close_tomorrow")], ["daybefore", this._t("panel.auction_close_daybefore")], ["pick", this._t("panel.auction_close_pick")], ]; const occurrences = d.loading ? `
    ${this._t("panel.auction_loading")}
    ` : d.refusal ? `
    ${this._esc(d.refusal)}
    ` : d.occurrences.length ? `
    ${d.occurrences.map(o => chip("auction-occ", o, d.occurrence === o, `${this._esc(this._auctionDate(o))}`)).join("")}
    ` : `
    ${this._t("panel.auction_no_occurrences")}
    `; const body = chores.length === 0 ? `

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

    ` : [ section("chore", [ `
    ${this._t("panel.auction_chore_label")}
    `, `
    ${this._t("panel.auction_which_label")} ${occurrences} ${this._t("panel.auction_which_hint")}
    `, ]), section("price", [ `
    ${this._t("panel.auction_max_label")}
    ${this._num(d.max_points)} ★
    ${this._t("panel.auction_normally_worth", { points: normal })}
    ${multiples.map(([m, n]) => chip("auction-max-set", n, Number(d.max_points) === n, `${m}× · ${n}`)).join("")}
    ${this._t("panel.auction_max_hint")}
    `, this._switch(this._t("panel.auction_min_label"), "min_on", !!d.min_on, this._t("panel.auction_min_hint"), true), d.min_on ? `
    ` : "", ]), section("bidding", [ `
    ${this._t("panel.auction_closes_label")}
    ${closeChips.map(([k, l]) => chip("auction-close-at", k, d.close_mode === k, this._esc(l), k !== "pick" && !this._auctionCloseAt(d, k) ? " disabled" : "")).join("")}
    ${d.close_mode === "pick" ? `` : ""} ${this._t("panel.auction_close_hint")}
    `, `
    ${this._t("panel.auction_who_label")}
    ${children.filter(c => (d.pool || []).includes(c.id)).map(c => chip("toggle-auction-el", c.id, (d.eligible_child_ids || []).includes(c.id), `${this._childAvatar(c)}${this._esc(c.name)}`)).join("")}
    `, `
    ${this._t("panel.auction_nobid_label")} ${this._t("panel.auction_nobid_copy")}
    `, this._switch(this._t("panel.auction_notify_label"), "notify_children", d.notify_children !== false, this._t("panel.auction_notify_hint")), ]), ].join(""); return this._dialogShell(this._t("panel.auction_dialog_title"), body, ` `, { drawer: true }); } async _doSaveAuction() { const d = this._dialog && this._dialog.data; if (!d) return; if (!d.occurrence) { this._showToast("err", this._t("panel.auction_err_occurrence")); return; } const closes = this._auctionCloseAt(d, d.close_mode); if (!closes) { this._showToast("err", this._t("panel.auction_err_close")); return; } const eligible = d.eligible_child_ids || []; if (!eligible.length) { this._showToast("err", this._t("panel.auction_err_who")); return; } const max = Math.max(1, Math.round(Number(d.max_points) || 0)); const min = d.min_on ? Math.max(1, Math.round(Number(d.min_points) || 1)) : 1; if (min > max) { this._showToast("err", this._t("panel.auction_err_min")); return; } const { ok, err } = await this._callWS({ type: "taskmate/auctions/start", chore_id: d.chore_id, occurrence: d.occurrence, max_points: max, min_points: min, closes_at: closes.toISOString(), eligible_child_ids: eligible, notify_children: d.notify_children !== false, }); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; } this._closeDialog(true); this._auctionSubTab = "live"; await this._fetchState(); this._showToast("ok", this._t("panel.toast_auction_started")); } async _doCloseAuction(id) { const a = (this._state.auctions || []).find(x => x.id === id); if (!a || !confirm(this._t("panel.auction_close_confirm", { chore: a.chore_name }))) return; const { ok, err } = await this._callWS({ type: "taskmate/auctions/close", auction_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_auction_closed")); } async _doCancelAuction(id) { const a = (this._state.auctions || []).find(x => x.id === id); if (!a || !confirm(this._t("panel.auction_cancel_confirm", { chore: a.chore_name }))) return; const { ok, err } = await this._callWS({ type: "taskmate/auctions/cancel", auction_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_auction_cancelled")); } async _doDeleteAuction(id) { const a = (this._state.auctions || []).find(x => x.id === id); if (!a || !confirm(this._t("panel.auction_delete_confirm", { chore: a.chore_name }))) return; const { ok, err } = await this._callWS({ type: "taskmate/auctions/delete", auction_id: 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_auction_deleted")); } // -- 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-${this._esc((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"; // Plain text: both callers escape the whole label, so escaping the // operator here too rendered ">=" as a literal ">=" (#972). return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${c.operator} ${this._num(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._ltrNums(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._ltrNums(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" ? "−" : "+"}${this._num(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._esc(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 ------------------------------------------------------ /** * Custom completion sounds (#856). * * Uploaded clips are listed with preview / rename / delete. Deleting one * resets any chore using it back to the default sound server-side, so the * confirm copy warns about that rather than leaving it to be discovered. */ _renderSoundsSection() { const list = this._customSounds(); const busy = this._soundBusy; return `

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

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

    ${list.length ? `
    ${list.map(s => `
    ${this._esc(s.name)}
    `).join("")}
    ` : `

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

    `}
    ${this._t("panel.settings_sounds_formats")}
    `; } _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._wizardSettingsSection()}

    ${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_quick_points_label")}${this._t("panel.settings_quick_points_hint")}
    ${this._renderSoundsSection()}

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

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

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

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

    `}
    ${this._t("panel.settings_require_linked_child_label")}${this._t("panel.settings_require_linked_child_hint")}
    ${this._t("panel.settings_chore_undo_label")}${this._t("panel.settings_chore_undo_hint")}

    ${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_streak_freeze_max_label")}${this._t("panel.settings_streak_freeze_max_hint")}
    ${this._t("panel.settings_streak_freeze_earn_label")}${this._t("panel.settings_streak_freeze_earn_hint")}
    ${this._t("panel.settings_weekend_multiplier_label")}${this._t("panel.settings_weekend_multiplier_hint")}
    ${this._t("panel.settings_birthday_multiplier_label")}${this._t("panel.settings_birthday_multiplier_hint")}
    ${this._t("panel.settings_birthday_chores_off_label")}${this._t("panel.settings_birthday_chores_off_hint")}
    ${this._t("panel.settings_difficulty_multipliers_label")}${this._t("panel.settings_difficulty_multipliers_hint")}
    ${this._t("panel.settings_quality_rating_label")}${this._t("panel.settings_quality_rating_hint")}
    ${this._t("panel.settings_quality_rating_multipliers_label")}${this._t("panel.settings_quality_rating_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_unlock_allowlist")}${this._t("panel.settings_unlock_allowlist_hint")}
    ${(s.unlock_allowlist || []).map(e => ` ${this._esc(e)} `).join("") || `${this._t("panel.settings_unlock_allowlist_empty")}`}
    ${this._entityPickerField("", "_allowlist_pick", "", ["switch", "light", "media_player", "input_boolean", "automation", "script", "fan", "climate"], this._t("panel.settings_unlock_allowlist_add"))}
    ${this._t("panel.settings_roulette_enabled")}${this._t("panel.settings_roulette_enabled_hint")}
    ${this._t("panel.settings_roulette_section")}
    ${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._renderInspectionsSection()}

    ${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._renderRecapsSection()}

    ${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 || "")} ${this._num(p.points)} ${this.getAttribute?.("dir") === "rtl" ? "←" : "→"} ${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")}

    ${this._t("panel.notif_nav_url_global_hint")}
    ${this._t("panel.notif_group_global_hint")}
    ${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 === "presence_arrival" ? `
    ${this._t("panel.notif_presence_min_away_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")); } _openAdjustDialog(childId) { const child = (this._state.children || []).find(c => c.id === childId); if (!child) return; this._openDialog({ kind: "adjust", data: { child_id: childId, points: 10, reason: "" } }); } _renderAdjustDialog() { const d = this._dialog.data; const child = (this._state.children || []).find(c => c.id === d.child_id) || {}; return this._dialogShell( this._t("panel.adjust_dialog_title", { name: child.name || this._t("panel.child_unnamed") }), [ this._field(this._t("panel.adjust_amount"), "points", d.points, "number"), this._field(this._t("panel.adjust_reason"), "reason", d.reason, "text"), ].join(""), ` ` ); } // `sign` is +1 or -1, taken from which footer button was pressed, so the // dialog needs no direction control of its own. async _saveAdjustDialog(sign) { const d = this._dialog.data; const amount = Number(d.points); if (!Number.isInteger(amount) || amount < 1 || amount > 10000) { this._showToast("err", this._t("panel.adjust_err_amount")); return; } const reason = String(d.reason || "").trim() || "Admin panel adjustment"; const ok = await this._doAdjustPoints(d.child_id, sign * amount, reason); if (ok) this._closeDialog(true); } // Manual point adjustment (#746). The busy key is the CHILD, not the button, // so a laggy tablet can neither double-award one amount nor race +5 against // −10 on the same balance. async _doAdjustPoints(childId, delta, reason = "Admin panel adjustment") { const amount = Math.abs(Number(delta) || 0); if (!childId || !Number.isInteger(amount) || amount < 1 || amount > 10000) { this._showToast("err", this._t("panel.adjust_err_amount")); return false; } if (!this._adjustBusy) this._adjustBusy = new Set(); if (this._adjustBusy.has(childId)) return false; this._adjustBusy.add(childId); try { const service = delta > 0 ? "add_points" : "remove_points"; const data = { child_id: childId, points: amount }; if (reason) data.reason = reason; const { ok, err } = await this._callService(service, data); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return false; } await this._fetchState(); const child = (this._state.children || []).find(c => c.id === childId); this._showToast("ok", this._t("panel.adjust_done", { sign: delta > 0 ? "+" : "−", amount, points: this._state.settings.points_name || this._t("common.points"), child: (child && child.name) || "", })); return true; } finally { this._adjustBusy.delete(childId); } } _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 === "adjust") return this._renderAdjustDialog(); 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 === "bounty") return this._renderBountyDialog(); if (this._dialog.kind === "auction") return this._renderAuctionDialog(); 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(); if (this._dialog.kind === "wish-pledge") return this._renderWishPledgeDialog(); if (this._dialog.kind === "wish-decline") return this._renderWishDeclineDialog(); if (this._dialog.kind === "reject") return this._renderRejectDialog(); if (this._dialog.kind === "wizard-confirm") return this._renderWizardConfirmDialog(); if (this._dialog.kind === "insp-start") return this._renderInspectionStartDialog(); if (this._dialog.kind === "insp-pass") return this._renderInspectionPassDialog(); if (this._dialog.kind === "insp-fail") return this._renderInspectionFailDialog(); 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._field(this._t("panel.child_birthday_label"), "birthday", d.birthday, "text", this._esc(this._t("panel.child_birthday_hint"))), // Setup wizard age group (#980): only steers chore suggestions. this._select(this._t("panel.child_age_group_label"), "age_group", d.age_group || "", [{ v: "", l: this._t("panel.child_age_group_none") }].concat(WZ_AGE_GROUPS.map(([id]) => ({ v: id, l: this._t(`wizard.age_group.${id}`) }))), this._esc(this._t("panel.child_age_group_hint"))), 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._entityPickerField(this._t("panel.child_presence_label"), "presence_entity", d.presence_entity, ["person", "device_tracker"], this._t("panel.child_presence_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")), this._kioskPinField(d), ].join(""), ` ` ); } /** Kiosk PIN (#930): a masked 4-digit input, plus "remove" once one is set. */ _kioskPinField(d) { const clearing = !!(d.has_kiosk_pin && d.kiosk_pin_clear); return `
    ${this._esc(this._t("panel.child_kiosk_pin_label"))} ${this._esc(this._t("panel.child_kiosk_pin_hint"))}${d.has_kiosk_pin ? " " + this._esc(this._t("panel.child_kiosk_pin_set")) : ""}
    ${d.has_kiosk_pin ? this._switch(this._t("panel.child_kiosk_pin_remove"), "kiosk_pin_clear", d.kiosk_pin_clear, "", true) : ""}`; } _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"; // Fields grouped under headings, opened as a side panel (#968). const section = (key, parts) => `

    ${this._t(`panel.chore_sec_${key}`)}

    ${parts.join("")}
    `; return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_chore") : this._t("panel.dialog_edit_chore"), [ section("basics", [ this._field(this._t("panel.chore_name_label"), "name", d.name, "text"), `
    ${this._field(this._t("panel.chore_description_label"), "description", d.description, "text")} ${this._iconPickerField(this._t("panel.chore_icon_label"), "icon", d.icon)} ${this._choreImageField(d.image_url || "")}
    `, 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_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._soundField(d.completion_sound), ]), section("who", [ 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")}
    `) : "", 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") ) : "", ]), section("when", [ 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("")}
    ${this._field(this._t("panel.chore_weekly_target_label"), "weekly_target", d.weekly_target || 0, "number", this._t("panel.chore_weekly_target_hint"))} ` : "", 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._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._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")}
    ` : "", ]), section("checking", [ 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_open_ended_label"), "open_ended", d.open_ended, this._t("panel.chore_open_ended_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")) : "", ]), section("more", [ isTimedTask ? "" : this._renderChoreTeamwork(d), 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._renderChoreTags(d), `
    ${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)}
    `, this._dialog.mode === "edit" ? this._renderScheduledChanges(d) : "", `
    ${this._t("panel.chore_advanced_weather")}
    ${this._t("panel.chore_weather_intro")} ${this._entityPickerField(this._t("panel.chore_weather_entity_label"), "weather_entity", d.weather_entity, ["weather"], this._t("panel.chore_weather_entity_hint"))} ${d.weather_entity ? `
    ${this._t("panel.chore_weather_conditions_label")}
    ${WEATHER_CONDITIONS.map(w => ` `).join("")}
    ${this._t("panel.chore_weather_conditions_hint")}
    ${this._field(this._t("panel.chore_weather_temp_min_label"), "weather_temp_min", d.weather_temp_min, "number", this._t("panel.chore_weather_temp_min_hint"))} ${this._field(this._t("panel.chore_weather_temp_max_label"), "weather_temp_max", d.weather_temp_max, "number", this._t("panel.chore_weather_temp_max_hint"))}
    ${this._field(this._t("panel.chore_weather_wind_max_label"), "weather_wind_max", d.weather_wind_max, "number", this._t("panel.chore_weather_wind_max_hint"))} ${this._t("panel.chore_weather_failopen_hint")} ` : ""}
    `, ]), ].join(""), `${this._dialog.mode === "edit" && d.id ? `` : ""} `, { drawer: true } ); } /** * NFC / QR tags (#923): pick from HA's tag registry or type an id. Scanning a * linked tag in the companion app completes the chore for the scanning child. */ _renderChoreTags(d) { const selected = d.tag_ids || []; const registry = this._haTags || []; const known = new Set(registry.map(tag => tag.id)); // Typed ids that aren't in the registry still need a chip, or they could // never be removed. const typed = selected.filter(id => !known.has(id)); const chip = (id, label) => ` `; const cantTag = d.require_photo || d.open_ended || d.task_type === "timed"; const open = this._dialog._openAdvanced?.has("tags"); return `
    ${this._t("panel.chore_advanced_tags")}${selected.length ? ` ${selected.length}` : ""}
    ${this._t("panel.chore_tags_intro")} ${registry.length || typed.length ? `
    ${registry.map(tag => chip(tag.id, tag.name)).join("")} ${typed.map(id => chip(id, id)).join("")}
    ` : `${this._t("panel.chore_tags_none")}`}
    ${this._t("panel.chore_tags_typed_label")}
    ${this._t("panel.chore_tags_hint")} ${cantTag ? `${this._t("panel.chore_tags_blocked_hint")}` : ""}
    `; } /** * Scheduled config changes (#675) — queue an edit for a future date. * Edit-only: a chore has to exist before a change can be queued against it. */ _renderScheduledChanges(d) { const pending = (this._state.scheduled_changes || []) .filter(c => c.chore_id === d.id && !c.applied) .sort((a, b) => a.apply_on.localeCompare(b.apply_on)); const applied = (this._state.scheduled_changes || []) .filter(c => c.chore_id === d.id && c.applied) .sort((a, b) => b.apply_on.localeCompare(a.apply_on)) .slice(0, 5); const draft = this._dialog._schedDraft || {}; const open = this._dialog._openAdvanced?.has("scheduled"); const children = this._state.children || []; const describe = (changes) => Object.entries(changes || {}) .map(([f, v]) => { const label = this._t("panel.sched_field_" + f); const value = f === "assigned_to" ? (v || []).map(id => this._esc((children.find(c => c.id === id) || {}).name || id)).join(", ") || this._t("panel.sched_value_nobody") : Array.isArray(v) ? this._esc(v.join(", ")) : typeof v === "boolean" ? this._t(v ? "panel.sched_value_yes" : "panel.sched_value_no") : this._esc(String(v)); return `${label}: ${value}`; }).join(" · "); return `
    ${this._t("panel.chore_advanced_scheduled")}${pending.length ? ` ${pending.length}` : ""}
    ${this._t("panel.chore_scheduled_intro")} ${pending.length ? `
    ${pending.map(c => `
    ${this._esc(c.apply_on)}
    ${describe(c.changes)}${c.note ? `
    ${this._esc(c.note)}
    ` : ""}
    `).join("")}
    ` : `${this._t("panel.chore_scheduled_none")}`}
    ${this._dateField(this._t("panel.sched_date_label"), "_sched_apply_on", draft.apply_on || "")} ${this._select(this._t("panel.sched_field_label"), "_sched_field", draft.field || "points", SCHEDULED_FIELDS.map(f => ({ v: f.v, lk: "panel.sched_field_" + f.v })))}
    ${this._renderScheduledValueInput(draft, children)} ${this._field(this._t("panel.sched_note_label"), "_sched_note", draft.note || "", "text", this._t("panel.sched_note_hint"))}
    ${applied.length ? `
    ${this._t("panel.chore_scheduled_history")} ${applied.map(c => `
    ${this._esc(c.apply_on)}
    ${describe(c.changes)}
    ✓
    `).join("")}
    ` : ""}
    `; } /** The value control depends on which field is being scheduled. */ _renderScheduledValueInput(draft, children) { const field = draft.field || "points"; const spec = SCHEDULED_FIELDS.find(f => f.v === field) || SCHEDULED_FIELDS[0]; if (spec.kind === "children") { const picked = draft.value_list || []; return `
    ${this._t("panel.sched_value_label")}
    ${children.map(c => ` `).join("")}
    `; } if (spec.kind === "days") { const picked = draft.value_list || []; return `
    ${this._t("panel.sched_value_label")}
    ${DAYS.map(day => ` `).join("")}
    `; } if (spec.kind === "bool") { return this._select(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? "true", [{ v: "true", lk: "panel.sched_value_yes" }, { v: "false", lk: "panel.sched_value_no" }]); } if (spec.kind === "select") { return this._select(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? spec.options[0].v, spec.options); } return this._field(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? "", spec.kind === "number" ? "number" : "text"); } _schedDraft() { if (!this._dialog._schedDraft) this._dialog._schedDraft = { field: "points", value_list: [] }; return this._dialog._schedDraft; } _toggleScheduledListValue(value) { const draft = this._schedDraft(); const list = [...(draft.value_list || [])]; const i = list.indexOf(value); if (i >= 0) list.splice(i, 1); else list.push(value); draft.value_list = list; this._render(); } async _saveAllowlist(list) { const { ok, err } = await this._callWS({ type: "taskmate/update_settings", unlock_allowlist: list }); if (!ok) { this._showToast("err", err); return; } await this._fetchState(); this._render(); } _removeFromAllowlist(entityId) { const current = (this._state.settings || {}).unlock_allowlist || []; this._saveAllowlist(current.filter(e => e !== entityId)); } _addToAllowlist(entityId) { const current = (this._state.settings || {}).unlock_allowlist || []; if (!entityId || current.includes(entityId)) return; this._saveAllowlist([...current, entityId]); } async _addScheduledChange() { const d = this._dialog.data; const draft = this._schedDraft(); const spec = SCHEDULED_FIELDS.find(f => f.v === (draft.field || "points")) || SCHEDULED_FIELDS[0]; if (!draft.apply_on) { this._showToast("err", this._t("panel.toast_sched_date_required")); return; } let value; if (spec.kind === "children" || spec.kind === "days") { value = draft.value_list || []; } else if (spec.kind === "bool") { value = (draft.value ?? "true") === "true"; } else if (spec.kind === "number") { if (draft.value === "" || draft.value == null) { this._showToast("err", this._t("panel.toast_sched_value_required")); return; } value = Number(draft.value); } else { value = draft.value ?? ""; } const { ok, err } = await this._callWS({ type: "taskmate/scheduled/add", chore_id: d.id, apply_on: draft.apply_on, changes: { [spec.v]: value }, note: draft.note || "", }); if (!ok) { this._showToast("err", err); return; } this._dialog._schedDraft = { field: spec.v, value_list: [] }; this._showToast("ok", this._t("panel.toast_sched_added")); await this._fetchState(); this._render(); } async _removeScheduledChange(changeId) { const { ok, err } = await this._callWS({ type: "taskmate/scheduled/remove", change_id: changeId }); if (!ok) { this._showToast("err", err); return; } this._showToast("ok", this._t("panel.toast_sched_removed")); await this._fetchState(); this._render(); } _renderRewardDialog() { const d = this._dialog.data; const children = this._state.children || []; const pointsName = this._state.settings.points_name || this._t("common.points"); return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_reward") : this._t("panel.dialog_edit_reward"), [ this._field(this._t("panel.chore_name_label"), "name", d.name, "text"), this._field(this._t("panel.chore_description_label"), "description", d.description, "text"), `
    ${this._field(this._t("panel.reward_cost_label", {points_name: pointsName}), "cost", d.cost, "number")} ${this._iconPickerField(this._t("panel.reward_icon_label"), "icon", d.icon)}
    `, children.length > 0 ? `
    ${this._t("panel.reward_assigned_label")}
    ${children.map(c => ` `).join("")}
    ${this._t("panel.reward_assigned_hint")}
    ` : "", // Streak freeze (#925): approving the claim hands the child a token. // It is theirs alone, so the jackpot switch goes away while it's on. this._switch(this._t("panel.reward_streak_freeze_label"), "streak_freeze", d.streak_freeze, this._t("panel.reward_streak_freeze_hint"), true), // 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. d.streak_freeze ? "" : 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 && !d.streak_freeze) ? "" : this._switch(this._t("panel.reward_pool_label"), "pool_enabled", d.pool_enabled, this._t("panel.reward_pool_hint")), `
    ${this._field(this._t("panel.reward_quantity_label"), "quantity_str", d.quantity_str, "text", this._t("panel.reward_quantity_hint"))} ${this._dateField(this._t("panel.reward_expires_label"), "expires_at", d.expires_at, this._t("panel.reward_expires_hint"))}
    `, this._switch(this._t("panel.reward_restock_label"), "restock_enabled", d.restock_enabled, this._t("panel.reward_restock_hint")), `
    ${this._field(this._t("panel.reward_restock_amount"), "restock_amount", d.restock_amount, "number")} ${this._select(this._t("panel.reward_restock_period"), "restock_period", d.restock_period || "weekly", [ { v: "daily", l: this._t("panel.reward_restock_daily") }, { v: "weekly", l: this._t("panel.reward_restock_weekly") }, { v: "monthly", l: this._t("panel.reward_restock_monthly") }, ])}
    `, this._renderRewardTimeLock(d), this._renderRewardUnlock(d), ].join(""), ` ` ); } /** * Teamwork (#928): a chore that only counts once N children have joined it. * Tucked into its own section like the other optional behaviours, and open * whenever the chore already is one so an edit shows what's set. */ _renderChoreTeamwork(d) { const size = Number(d.team_size) || 0; const open = this._dialog._openAdvanced?.has("teamwork") || size >= 2; return `
    ${this._t("panel.chore_team_section")}
    ${this._t("panel.chore_team_hint")}
    ${this._field(this._t("panel.chore_team_size_label"), "team_size", size, "number")} ${this._select(this._t("panel.chore_team_points_label"), "team_points_mode", d.team_points_mode || "each", [ { v: "each", l: this._t("panel.chore_team_points_each") }, { v: "split", l: this._t("panel.chore_team_points_split") }, ])} ${this._field(this._t("panel.chore_team_bonus_label"), "team_bonus", d.team_bonus || 0, "number")}
    `; } /** * Time lock (#857). Restricts *claiming* to certain weekdays and/or a * time-of-day window — the reward stays visible the rest of the time, greyed * out with a "when it's available" badge, and saving points into a pool * reward is never blocked. */ _renderRewardTimeLock(d) { const open = this._dialog._openAdvanced?.has("timelock") || !!d.time_lock_enabled; const days = (d.available_days || []).map(Number); return `
    ${this._t("panel.reward_timelock_section")}
    ${this._switch(this._t("panel.reward_timelock_label"), "time_lock_enabled", d.time_lock_enabled, this._t("panel.reward_timelock_hint"), true)} ${d.time_lock_enabled ? `
    ${this._t("panel.reward_timelock_days_label")}
    ${DAYS.map((day, i) => ` `).join("")}
    ${this._t("panel.reward_timelock_days_hint")}
    ${this._timeField(this._t("panel.reward_timelock_from_label"), "available_from", d.available_from)} ${this._timeField(this._t("panel.reward_timelock_until_label"), "available_until", d.available_until)}
    ${this._t("panel.reward_timelock_times_hint")} ` : ""}
    `; } /** * Timed unlock (#678). Only offers entities the parent has allowlisted in * Settings — a reward must not be able to reach arbitrary devices. */ _renderRewardUnlock(d) { const allow = (this._state.settings || {}).unlock_allowlist || []; const open = this._dialog._openAdvanced?.has("unlock"); if (!allow.length) { return `
    ${this._t("panel.reward_unlock_section")}
    ${this._t("panel.reward_unlock_no_allowlist")}
    `; } const options = [{ v: "", lk: "panel.reward_unlock_none" }] .concat(allow.map(e => ({ v: e, l: e }))); return `
    ${this._t("panel.reward_unlock_section")}
    ${this._t("panel.reward_unlock_intro")} ${this._select(this._t("panel.reward_unlock_entity"), "unlock_entity", d.unlock_entity || "", options)} ${d.unlock_entity ? this._field(this._t("panel.reward_unlock_minutes"), "unlock_minutes", d.unlock_minutes ?? 30, "number", this._t("panel.reward_unlock_minutes_hint")) : ""}
    `; } _renderAvatarCatalogDialog() { const rows = (this._dialog.data.catalog || []); const typeOpts = [ { v: "free", l: this._t("panel.avatar_unlock_free") }, { v: "level", l: this._t("panel.avatar_unlock_level") }, { v: "points", l: this._t("panel.avatar_unlock_points") }, { v: "streak", l: this._t("panel.avatar_unlock_streak") }, ]; const rowHtml = rows.map((a, i) => `
    `).join(""); return this._dialogShell(this._t("panel.avatar_dialog_title"), [ `

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

    `, rows.length ? `
    ${rowHtml}
    ` : `
    ${this._t("panel.avatar_empty")}
    `, ``, ].join(""), ` `, // Four fields per row plus the icon picker need more than 560px (#972). { wide: true } ); } _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._soundField(d.completion_sound), this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [ { v: "easy", l: this._t("panel.difficulty_easy") }, { v: "medium", l: this._t("panel.difficulty_medium") }, { 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)}
    ${this._num(c.points)}
    `; }).join("")}
    `, ` ` ); } // ---- form helpers ---------------------------------------------------- // `drawer: true` opens it as a full-height panel on the right (#968). _dialogShell(title, body, footer, { drawer = false, wide = false } = {}) { return `
    `; } _field(label, name, value, type = "text", hintHtml = "") { return `
    ${this._esc(label)} ${hintHtml ? `${hintHtml}` : ""}
    `; } _dateField(label, name, value, hintHtml = "") { return `
    ${this._esc(label)} ${hintHtml ? `${hintHtml}` : ""}
    `; } _timeField(label, name, value, hintHtml = "") { return `
    ${this._esc(label)} ${hintHtml ? `${hintHtml}` : ""}
    `; } _select(label, name, value, options, hintHtml = "", rerender = false) { return `
    ${this._esc(label)} ${hintHtml ? `${hintHtml}` : ""}
    `; } _switch(label, name, checked, hintHtml = "", rerender = false) { return `
    ${this._esc(label)}
    ${hintHtml ? `${hintHtml}` : ""}
    `; } /** The chore picture well (#750): thumbnail or empty state, Upload, Remove. */ _choreImageField(value) { const busy = this._choreImageBusy; return `
    ${this._t("panel.chore_image_label")}
    ${value ? `` : `
    ${this._t("panel.chore_image_empty")}
    `}
    ${value ? `` : ""}
    ${this._t("panel.chore_image_hint")}
    `; } /** Uploaded custom sounds from the last get_state (#856). */ _customSounds() { return (this._state && this._state.custom_sounds) || []; } /** Built-in + uploaded sounds, as ${this._soundOptions().map(o => ``).join("")} `; } /** Play a sound through the shared engine (taskmate-sounds.js). */ _previewSound(value) { const engine = window.__taskmate_sounds; if (engine) engine.play(value, this._customSounds()); } /** * Icon field. `ha-icon-picker` is HA's own component — we keep it for the * searchable MDI list, but its Material shell (grey fill, underline, 56px * box) reads as a foreign control beside the panel's own inputs. The * `.tm-icon-well` wrapper supplies the tm-input frame and the picker inside * it is made transparent; see the CSS for why the wrapper, not the picker, * carries the border. */ _iconPickerField(label, name, value) { return `
    ${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, hintHtml = "") { 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)}
    ` : `
    ` }
    ${hintHtml ? `${hintHtml}` : ""}
    `; } _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") }); if (this._auctionable(c)) items.push({ act: "auction-chore-next", icon: "🔨", label: this._t("panel.auction_row_menu") }); 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, end-aligned (right, or left in RTL), // flipping up / clamping to viewport. const rect = btn.getBoundingClientRect(); const mw = menu.offsetWidth, mh = menu.offsetHeight, gap = 4, pad = 8; let left = this.getAttribute("dir") === "rtl" ? rect.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, "'"); } // Optional numeric field: "" clears the limit (sent as null). 0 is a real // threshold — a 0 °C minimum is meaningful — so it can't stand in for "unset". _optNum(value) { if (value === "" || value === null || value === undefined) return null; const n = Number(value); return Number.isFinite(n) ? n : null; } // Security defense-in-depth: only ever emit our own auth-gated photo endpoint // into an href/src. _esc() escapes HTML metacharacters but does NOT neutralize // a javascript:/external scheme, so guard the URL itself. Signed URLs keep the // /api/taskmate/photo/ prefix (the ?authSig= is appended), so they still pass. _safePhotoUrl(u) { return typeof u === "string" && u.startsWith("/api/taskmate/photo/") ? u : ""; } _fmtNum(n) { if (n == null) return "0"; return new Intl.NumberFormat().format(n); } // Keeps number runs like "3 / 1" reading left to right when the panel is // right-to-left (#995). Plain string in and out; the isolates it adds are // not HTML-special, so it can sit either side of _esc(). _ltrNums(s) { const design = window.__taskmate_design; return design && design.ltrNums ? design.ltrNums(s) : s; } // A number bound for a markup or attribute slot. The panel assembles its // HTML as strings, so anything stored in a field we *assume* is numeric has // to be proven numeric before it is echoed back. Non-numbers, inf and NaN // fall back rather than reaching the DOM. _num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; } // Quick point-adjust amounts (#746). Stored as a comma-separated string like // "5, 10, 20" — same shape as streak_milestones — so the generic settings // collector can round-trip it as a plain text input. Junk degrades to the // default rather than rendering a broken button row. _quickPointAmounts() { const raw = (this._state.settings || {}).quick_point_amounts; const parsed = String(raw == null ? "" : raw) .split(",") .map(p => Number(p.trim())) .filter(n => Number.isInteger(n) && n >= 1 && n <= 10000); const unique = [...new Set(parsed)].slice(0, 3); return unique.length ? unique : [5, 10, 20]; } _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); }