13035 lines
686 KiB
JavaScript
13035 lines
686 KiB
JavaScript
/**
|
||
* 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: <name>" 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 `
|
||
<div class="tm-stars" role="group" aria-label="${this._esc(this._t("panel.rating_label"))}">
|
||
${[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 `<button type="button" class="tm-star ${n <= sel ? "tm-star-on" : ""}" data-act="rate-chore"
|
||
data-id="${this._esc(completionId)}" data-rating="${n}" aria-pressed="${n === sel}"
|
||
title="${this._esc(label)}" aria-label="${this._esc(label)}">${n <= sel ? "★" : "☆"}</button>`;
|
||
}).join("")}
|
||
</div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("reject.reason_label")}</span>
|
||
<div class="tm-chip-row" style="margin-bottom:8px">
|
||
${chips.map(c => `<button type="button" class="tm-chip-btn ${d.reason === c ? "tm-chip-on" : ""}" aria-pressed="${d.reason === c}"
|
||
data-act="reject-chip" data-reason="${this._esc(c)}">${this._esc(c)}</button>`).join("")}
|
||
</div>
|
||
<input class="tm-input" type="text" data-field="reason" maxlength="200"
|
||
value="${this._esc(d.reason || "")}" placeholder="${this._esc(this._t("reject.reason_placeholder"))}">
|
||
<span class="tm-field-hint">${this._esc(this._t("reject.reason_hint"))}</span>
|
||
</div>`;
|
||
return this._dialogShell(
|
||
this._t("reject.title", { name: d.name }),
|
||
body,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-danger" data-act="save-reject">${this._t("reject.confirm")}</button>`
|
||
);
|
||
}
|
||
|
||
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 `<span class="tm-pill tm-insp-tag ${cls}"><ha-icon icon="mdi:magnify-scan"></ha-icon>${this._esc(this._t(key, params))}</span>`;
|
||
}
|
||
if ((this._state.inspectable_completions || []).includes(completionId)) {
|
||
return `<button type="button" class="tm-icon-btn tm-insp-flag" data-act="insp-flag" data-id="${this._esc(completionId)}"
|
||
title="${this._esc(this._t("panel.insp_flag_tooltip"))}" aria-label="${this._esc(this._t("panel.insp_flag_tooltip"))}"><ha-icon icon="mdi:magnify-scan"></ha-icon></button>`;
|
||
}
|
||
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 `
|
||
<div class="tm-approval-item tm-insp-item">
|
||
<div class="tm-approval-icon tm-insp-icon"><ha-icon icon="mdi:magnify-scan"></ha-icon></div>
|
||
<div class="tm-approval-body">
|
||
<div class="tm-approval-line">${this._t("panel.insp_needs_line", { child: this._esc(name), chore: this._esc(i.chore_name || this._t("panel.activity_deleted_chore")) })}</div>
|
||
<div class="tm-meta">${this._esc(meta)}</div>
|
||
</div>
|
||
<div class="tm-approval-actions">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="insp-fail" data-id="${this._esc(i.id)}">${this._t("panel.insp_fail")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="insp-pass" data-id="${this._esc(i.id)}">${this._t("panel.insp_pass")}</button>
|
||
<button type="button" class="tm-icon-btn" data-act="insp-cancel" data-id="${this._esc(i.id)}"
|
||
title="${this._esc(this._t("panel.insp_cancel"))}" aria-label="${this._esc(this._t("panel.insp_cancel"))}"><ha-icon icon="mdi:close"></ha-icon></button>
|
||
</div>
|
||
</div>`;
|
||
}).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 `
|
||
<div class="tm-insp-ctx">
|
||
<span class="tm-insp-ctx-ic"><ha-icon icon="mdi:magnify-scan"></ha-icon></span>
|
||
<div>
|
||
<div><strong>${this._esc(d.chore_name)}</strong> · <span style="color:${child ? this._childColor(child.id) : "inherit"};font-weight:600">${this._esc(d.child_name)}</span></div>
|
||
<div class="tm-meta">${this._esc(this._t("panel.insp_ctx", { time: when, points: this._num(d.points), points_name: pointsName }))}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_inspStepper(value) {
|
||
return `
|
||
<div class="tm-insp-stepper">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="insp-bonus" data-d="-5" aria-label="−5">−</button>
|
||
<input type="number" class="tm-input" min="0" max="10000" step="1" data-field="bonus" value="${this._esc(value)}" aria-label="${this._esc(this._t("panel.insp_bonus"))}">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="insp-bonus" data-d="5" aria-label="+5">+</button>
|
||
</div>`;
|
||
}
|
||
|
||
_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
|
||
? `<p class="tm-field-hint tm-insp-hint"><ha-icon icon="mdi:information-outline"></ha-icon>${this._esc(this._t("panel.insp_window_bed_soon", { bedtime: this._inspBedtime(), minutes: bed.minutes, time: bed.time }))}</p>`
|
||
: "";
|
||
const body = `
|
||
${this._inspDialogCtx(d)}
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.insp_window_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${["1h", "2h", "4h", "bed"].map(w => `<button type="button" class="tm-chip-btn ${d.window === w ? "tm-chip-on" : ""}" aria-pressed="${d.window === w}"
|
||
data-act="insp-window" data-window="${w}">${this._esc(this._inspWindowLabel(w, bed))}</button>`).join("")}
|
||
</div>
|
||
${bedSoon}
|
||
</div>
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.insp_bonus_label")}</span>
|
||
${this._inspStepper(d.bonus)}
|
||
</div>
|
||
${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,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-insp-go" data-act="insp-start-save"><ha-icon icon="mdi:magnify-scan"></ha-icon>${this._t("panel.insp_start_btn")}</button>`
|
||
);
|
||
}
|
||
|
||
_renderInspectionPassDialog() {
|
||
const d = this._dialog.data;
|
||
const body = `
|
||
${this._inspDialogCtx(d)}
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.insp_bonus")}</span>
|
||
${this._inspStepper(d.bonus)}
|
||
</div>
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(this._t("panel.insp_note_pass_label", { name: d.child_name }))}</span>
|
||
<textarea class="tm-input" data-field="note" maxlength="200" rows="2"
|
||
placeholder="${this._esc(this._t("panel.insp_note_pass_placeholder"))}">${this._esc(d.note || "")}</textarea>
|
||
</div>`;
|
||
return this._dialogShell(
|
||
this._t("panel.insp_pass_title"),
|
||
body,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="insp-pass-save">${this._inspPassLabel(d.bonus)}</button>`
|
||
);
|
||
}
|
||
|
||
// 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) => `
|
||
<button type="button" class="tm-insp-opt ${d.mode === mode ? "tm-insp-opt-on" : ""}" role="radio" aria-checked="${d.mode === mode}"
|
||
data-act="insp-fail-mode" data-mode="${mode}" ${disabled ? "disabled" : ""}>
|
||
<span class="tm-insp-radio"></span>
|
||
<span><strong>${this._t(titleKey)}</strong>
|
||
<small>${this._esc(disabled ? this._t("panel.insp_fail_redo_unavailable") : this._t(hintKey, { name: d.child_name, points: this._num(d.points), points_name: pointsName }))}</small></span>
|
||
</button>`;
|
||
const body = `
|
||
${this._inspDialogCtx(d)}
|
||
<div class="tm-insp-opts" role="radiogroup">
|
||
${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)}
|
||
</div>
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(this._t("panel.insp_fail_fix_label", { name: d.child_name }))}</span>
|
||
<textarea class="tm-input" data-field="note" maxlength="200" rows="2">${this._esc(d.note || "")}</textarea>
|
||
</div>
|
||
<p class="tm-field-hint tm-insp-hint"><ha-icon icon="mdi:information-outline"></ha-icon>${this._t("panel.insp_fail_no_penalty")}</p>`;
|
||
return this._dialogShell(
|
||
this._t("panel.insp_fail_title"),
|
||
body,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="insp-fail-save">${this._t(d.mode === "redo" ? "panel.insp_fail_send_back" : "panel.insp_fail_save")}</button>`
|
||
);
|
||
}
|
||
|
||
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 `
|
||
<div class="tm-section tm-insp-settings">
|
||
<div class="tm-section-head"><div><h3><ha-icon icon="mdi:magnify-scan"></ha-icon> ${this._t("panel.settings_insp_title")}</h3><p class="tm-meta">${this._t("panel.settings_insp_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_insp_enabled_label")}<small>${this._t("panel.settings_insp_enabled_hint")}</small></div>
|
||
<ha-switch data-setting="inspections_enabled" ${s.inspections_enabled !== false ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_insp_bonus_label")}<small>${this._t("panel.settings_insp_bonus_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="0" max="10000" step="1" data-setting="inspection_bonus" value="${this._num(s.inspection_bonus, 10)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_insp_window_label")}<small>${this._t("panel.settings_insp_window_hint")}</small></div>
|
||
<select class="tm-select" data-setting="inspection_window">
|
||
${["1h", "2h", "4h", "bed"].map(w => `<option value="${w}" ${w === win ? "selected" : ""}>${this._esc(this._inspWindowLabel(w))}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_insp_bedtime_label")}<small>${this._t("panel.settings_insp_bedtime_hint")}</small></div>
|
||
<input type="time" class="tm-input" data-setting="inspection_bedtime" value="${this._esc(this._inspBedtime())}" style="max-width:140px">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_insp_fail_label")}<small>${this._t("panel.settings_insp_fail_hint")}</small></div>
|
||
<select class="tm-select" data-setting="inspection_fail_mode">
|
||
${["note", "redo", "ask"].map(v => `<option value="${v}" ${v === fail ? "selected" : ""}>${this._esc(this._t("panel.settings_insp_fail_" + v))}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_insp_tell_label")}<small>${this._t("panel.settings_insp_tell_hint")}</small></div>
|
||
<ha-switch data-setting="inspection_tell_child" ${s.inspection_tell_child !== false ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label"><span><ha-icon icon="mdi:dice-5-outline"></ha-icon> ${this._t("panel.settings_insp_pick_label")}</span><small>${this._t("panel.settings_insp_pick_hint")}</small></div>
|
||
<ha-switch data-setting="inspection_pick_enabled" ${s.inspection_pick_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row tm-insp-sub">
|
||
<div class="tm-setting-label">${this._t("panel.settings_insp_pick_when_label")}<small>${this._t("panel.settings_insp_pick_when_hint")}</small></div>
|
||
<div class="tm-difficulty-mults">
|
||
<label>${this._t("panel.settings_insp_pick_time")}<input type="time" class="tm-input" data-setting="inspection_pick_time" value="${this._esc(s.inspection_pick_time || "17:00")}"></label>
|
||
<label>${this._t("panel.settings_insp_pick_chance")}<input type="number" class="tm-input" min="0" max="100" step="1" data-setting="inspection_pick_chance" value="${this._num(s.inspection_pick_chance, 30)}"></label>
|
||
</div>
|
||
</div>
|
||
${children.length ? `
|
||
<div class="tm-setting-row tm-insp-sub">
|
||
<div class="tm-setting-label">${this._t("panel.settings_insp_pick_children_label")}<small>${this._t("panel.settings_insp_pick_children_hint")}</small></div>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => {
|
||
const on = picked.includes(c.id);
|
||
return `<button type="button" class="tm-chip-btn ${on ? "tm-chip-on" : ""}" aria-pressed="${on}" data-act="insp-kid" data-insp-kid="${this._esc(c.id)}">${this._childAvatar(c)} ${this._esc(c.name)}</button>`;
|
||
}).join("")}
|
||
</div>
|
||
</div>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ---- 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"),
|
||
`<div class="tm-field-row">
|
||
${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}) },
|
||
])}
|
||
</div>`,
|
||
`<div class="tm-field-row">
|
||
${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")}
|
||
</div>`,
|
||
this._iconPickerField(this._t("panel.reward_icon_label"), "icon", d.icon),
|
||
children.length > 0 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.quest_assigned_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.assigned_to || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-challenge-assigned" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.quest_assigned_hint")}</span>
|
||
</div>
|
||
` : "",
|
||
this._switch(this._t("panel.quest_active_label"), "active", d.active !== false,
|
||
this._t("panel.quest_active_hint")),
|
||
].join(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-challenge">${this._t("panel.btn_save")}</button>`
|
||
);
|
||
}
|
||
|
||
// ---- 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 `
|
||
<div class="tm-setting-row tm-period-row">
|
||
<ha-icon-picker data-tp-field="icon" data-tp-idx="${i}" data-current="${this._esc(p.icon || "mdi:clock-outline")}"></ha-icon-picker>
|
||
<input type="text" class="tm-input" data-tp-field="label" data-tp-idx="${i}" value="${this._esc(p.label || "")}" placeholder="${this._esc(placeholder)}">
|
||
<div class="tm-time-pair">
|
||
<input type="time" class="tm-input" data-tp-field="start" data-tp-idx="${i}" value="${this._esc(p.start || "")}">
|
||
<span>–</span>
|
||
<input type="time" class="tm-input" data-tp-field="end" data-tp-idx="${i}" value="${this._esc(p.end || "")}">
|
||
</div>
|
||
${used
|
||
? `<button type="button" class="tm-icon-btn tm-period-del" disabled title="${this._esc(this._t("panel.tp_in_use_title", { count: used }))}"><ha-icon icon="mdi:lock-outline"></ha-icon></button>`
|
||
: `<button type="button" class="tm-icon-btn tm-period-del" data-act="tp-remove" data-idx="${i}" title="${this._esc(this._t("panel.tp_delete_title"))}"><ha-icon icon="mdi:trash-can-outline"></ha-icon></button>`}
|
||
${used ? `<div class="tm-period-inuse">${this._esc(this._t("panel.tp_in_use_hint", { count: used }))}</div>` : ""}
|
||
</div>`;
|
||
}).join("");
|
||
return `
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_time_boundaries_title")}</h3><p class="tm-meta">${this._t("panel.settings_time_boundaries_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
${rows}
|
||
<div class="tm-setting-row tm-period-row tm-period-anytime">
|
||
<ha-icon icon="mdi:clock-outline"></ha-icon>
|
||
<div class="tm-setting-label">${this._t("panel.time_anytime")}<small>${this._t("panel.tp_anytime_hint")}</small></div>
|
||
</div>
|
||
<div class="tm-period-actions">
|
||
<button type="button" class="tm-btn" data-act="tp-add"><ha-icon icon="mdi:plus"></ha-icon> ${this._t("panel.tp_add_period")}</button>
|
||
<button type="button" class="tm-btn" data-act="tp-reset">${this._t("panel.tp_reset_defaults")}</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// 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 `<button type="button" class="tm-chip-btn ${on ? "tm-chip-on" : ""}" aria-pressed="${on}"
|
||
${readOnly ? "disabled" : `data-act="recap-tog" data-who="${this._esc(who)}" data-freq="${f}"`}>${on ? "✓ " : ""}${this._esc(this._t("recap.freq." + f))}</button>`;
|
||
}).join("");
|
||
}
|
||
|
||
_recapNextList(selected) {
|
||
if (!selected.length) {
|
||
return `<span class="tm-recap-off"><ha-icon icon="mdi:information-outline"></ha-icon> ${this._t("panel.recaps_off")}</span>`;
|
||
}
|
||
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 `<span><b>${this._esc(this._t("recap.freq." + f))}:</b> ${this._esc(when)}</span>`;
|
||
}).join("");
|
||
}
|
||
|
||
_recapFamilyRow() {
|
||
const d = this._recapDraftState();
|
||
return `
|
||
<div class="tm-setting-label">${this._t("panel.recaps_frequency_label")}<small>${this._t("panel.recaps_frequency_hint")}</small></div>
|
||
<div class="tm-recap-cell">
|
||
<div class="tm-chip-row">${this._recapChips(d.family, "__family__", false)}</div>
|
||
<div class="tm-recap-next">${this._recapNextList(d.family)}</div>
|
||
${d.family.includes("every_9_months") ? `<div class="tm-meta">${this._t("panel.recaps_nine_month_note")}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_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 `
|
||
<td><strong>${this._esc(child.name)}</strong></td>
|
||
<td>
|
||
<div class="tm-recap-cell">
|
||
<div class="tm-recap-mode" role="group">
|
||
<button type="button" class="${custom ? "" : "on"}" aria-pressed="${!custom}" data-act="recap-mode" data-who="${id}" data-mode="default">${this._t("panel.recaps_mode_default")}</button>
|
||
<button type="button" class="${custom ? "on" : ""}" aria-pressed="${custom}" data-act="recap-mode" data-who="${id}" data-mode="custom">${this._t("panel.recaps_mode_custom")}</button>
|
||
</div>
|
||
<div class="tm-chip-row">${this._recapChips(selected, child.id, !custom)}</div>
|
||
</div>
|
||
</td>
|
||
<td><div class="tm-recap-next">${this._recapNextList(selected)}</div></td>`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-section tm-recaps">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.recaps_title")}</h3><p class="tm-meta">${this._t("panel.recaps_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row" data-recap-row="__family__">${this._recapFamilyRow()}</div>
|
||
${children.length ? `
|
||
<div class="tm-recap-kids">
|
||
<div class="tm-setting-label">${this._t("panel.recaps_per_child_label")}<small>${this._t("panel.recaps_per_child_hint")}</small></div>
|
||
<div class="tm-table-wrap"><table class="tm-table">
|
||
<thead><tr><th>${this._t("panel.recaps_col_child")}</th><th>${this._t("panel.recaps_col_frequency")}</th><th>${this._t("panel.recaps_col_next")}</th></tr></thead>
|
||
<tbody>
|
||
${children.map(c => `<tr data-recap-row="${this._esc(c.id)}">${this._recapChildRow(c)}</tr>`).join("")}
|
||
</tbody>
|
||
</table></div>
|
||
</div>` : ""}
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.recaps_notify_label")}<small>${this._t("panel.recaps_notify_hint")}</small></div>
|
||
<div class="tm-recap-switches">
|
||
<label><ha-switch data-setting="recap_notify_children" ${s.recap_notify_children !== false ? "checked" : ""}></ha-switch> ${this._t("panel.recaps_notify_children")}</label>
|
||
<label><ha-switch data-setting="recap_notify_parents" ${s.recap_notify_parents !== false ? "checked" : ""}></ha-switch> ${this._t("panel.recaps_notify_parents")}</label>
|
||
</div>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.recaps_send_time_label")}<small>${this._t("panel.recaps_send_time_hint")}</small></div>
|
||
<input type="time" class="tm-input" data-setting="recap_send_time" value="${this._esc(s.recap_send_time || "08:00")}" style="max-width:140px">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.recaps_retention_label")}<small>${this._t("panel.recaps_retention_hint")}</small></div>
|
||
<select class="tm-select" data-setting="recap_retention">
|
||
${["1y", "2y", "forever"].map(v => `<option value="${v}" ${v === retention ? "selected" : ""}>${this._esc(this._t("panel.recaps_retention_" + v))}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.recaps_compare_label")}<small>${this._t("panel.recaps_compare_hint")}</small></div>
|
||
<ha-switch data-setting="recap_compare" ${s.recap_compare !== false ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
${children.length ? `
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.recaps_preview_label")}<small>${this._t("panel.recaps_preview_hint")}</small></div>
|
||
<div class="tm-recap-preview">
|
||
<select class="tm-select" data-recap-preview-child aria-label="${this._esc(this._t("panel.recaps_col_child"))}">
|
||
${children.map(c => `<option value="${this._esc(c.id)}">${this._esc(c.name)}</option>`).join("")}
|
||
</select>
|
||
<button type="button" class="tm-btn" data-act="recap-preview"><ha-icon icon="mdi:eye-outline"></ha-icon> ${this._t("panel.recaps_preview_btn")}</button>
|
||
</div>
|
||
</div>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_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) => `
|
||
<div class="tm-setting-row tm-vacation-row">
|
||
<input type="text" class="tm-input" data-vac-field="name" data-vac-idx="${i}" value="${this._esc(p.name || "")}" placeholder="${this._esc(this._t("panel.vacation_name_placeholder"))}">
|
||
<div class="tm-time-pair">
|
||
<input type="date" class="tm-input" data-vac-field="start" data-vac-idx="${i}" value="${this._esc(p.start || "")}">
|
||
<span>–</span>
|
||
<input type="date" class="tm-input" data-vac-field="end" data-vac-idx="${i}" value="${this._esc(p.end || "")}">
|
||
</div>
|
||
<button type="button" class="tm-icon-btn tm-period-del" data-act="vac-remove" data-idx="${i}" title="${this._esc(this._t("panel.vacation_delete_title"))}"><ha-icon icon="mdi:trash-can-outline"></ha-icon></button>
|
||
</div>`).join("");
|
||
const calValue = this._settingsEntityDraft?.vacation_calendar
|
||
?? this._state?.settings?.vacation_calendar ?? "";
|
||
return `
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_vacation_title")}</h3><p class="tm-meta">${this._t("panel.settings_vacation_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
${rows || `<div class="tm-meta" style="padding: 4px 20px 8px;">${this._t("panel.vacation_none")}</div>`}
|
||
<div class="tm-period-actions">
|
||
<button type="button" class="tm-btn" data-act="vac-add"><ha-icon icon="mdi:plus"></ha-icon> ${this._t("panel.vacation_add")}</button>
|
||
</div>
|
||
<div style="padding: 12px 20px 4px;">
|
||
${this._entityPickerField(this._t("panel.vacation_calendar_label"), "vacation_calendar", calValue, ["calendar"], this._t("panel.vacation_calendar_hint"))}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_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}
|
||
<div class="tm-shell">
|
||
<div data-zone="sidebar">${this._sidebar()}</div>
|
||
<div class="tm-main">
|
||
<div data-zone="topbar">${this._topbar()}</div>
|
||
<div data-zone="mtabs">${this._mobileTabs()}</div>
|
||
<div data-zone="scope">${this._scopeBanner()}</div>
|
||
<div data-zone="approval">${this._approvalBanner()}</div>
|
||
<div class="tm-body">
|
||
<div class="tm-body-inner" data-zone="body">${this._renderBody()}</div>
|
||
</div>
|
||
<div data-zone="bnav">${this._bottomNav()}</div>
|
||
<div data-zone="dialog">${this._dialog ? this._renderDialog() : ""}</div>
|
||
<div data-zone="tpl">${this._renderSaveTemplateDialog()}</div>
|
||
<div data-zone="palette">${this._renderPalette()}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 `
|
||
<aside class="tm-sidebar">
|
||
<div class="tm-brand">
|
||
<div class="tm-brand-mark"><ha-icon icon="mdi:checkbox-marked-circle-plus-outline"></ha-icon></div>
|
||
<div class="tm-brand-text">
|
||
TaskMate
|
||
<small>v${PANEL_VERSION}</small>
|
||
</div>
|
||
<a class="tm-brand-wiki" href="https://github.com/tempus2016/taskmate/wiki" target="_blank" rel="noopener noreferrer" title="${this._esc(this._t("panel.wiki_tooltip"))}" aria-label="${this._esc(this._t("panel.wiki_tooltip"))}">
|
||
<ha-icon icon="mdi:book-open-variant"></ha-icon>
|
||
</a>
|
||
<button type="button" class="tm-rail-btn" data-act="nav-rail" title="${this._esc(railLabel)}" aria-label="${this._esc(railLabel)}" aria-pressed="${rail ? "true" : "false"}">
|
||
<ha-icon class="tm-rtl-flip" icon="${rail ? "mdi:chevron-double-right" : "mdi:chevron-double-left"}"></ha-icon>
|
||
</button>
|
||
</div>
|
||
<nav class="tm-nav">
|
||
${groups.map(g => {
|
||
// A group holding the open section never folds away; the icon-only
|
||
// sidebar has no headings to unfold it with, so it shows them all.
|
||
const closed = !rail && !!g.head && this._navClosed && this._navClosed.has(g.key)
|
||
&& !g.items.some(it => it.id === this._activeTab);
|
||
return `
|
||
<div class="tm-nav-group">
|
||
${g.head ? `
|
||
<button type="button" class="tm-nav-head" data-act="nav-group" data-grp="${g.key}" aria-expanded="${closed ? "false" : "true"}">
|
||
<ha-icon class="tm-nav-chev" icon="mdi:chevron-down"></ha-icon>
|
||
<span>${this._esc(g.head)}</span>
|
||
</button>` : ""}
|
||
${closed ? "" : g.items.map(it => {
|
||
const active = it.id === this._activeTab;
|
||
const urgent = (it.id === "activity" || it.id === "today") && it.count > 0;
|
||
const showCount = it.count != null && it.count > 0;
|
||
return `
|
||
<button type="button" class="tm-nav-item ${active ? "tm-nav-active" : ""}" data-act="tab" data-tab="${it.id}" title="${this._esc(it.label)}">
|
||
<span class="tm-nav-icon"><ha-icon icon="${it.icon}"></ha-icon></span>
|
||
<span class="tm-nav-label">${this._esc(it.label)}</span>
|
||
${showCount ? `<span class="tm-nav-badge ${urgent ? "tm-nav-badge-urgent" : ""}">${it.count}</span>` : ""}
|
||
</button>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</nav>
|
||
</aside>
|
||
`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-topbar">
|
||
<button type="button" class="tm-menu-btn" data-act="toggle-ha-menu" aria-label="Menu">
|
||
<ha-icon icon="mdi:menu"></ha-icon>
|
||
</button>
|
||
<div class="tm-crumbs">
|
||
<span class="tm-crumbs-root">TaskMate</span>
|
||
<span class="tm-crumbs-sep">/</span>
|
||
<strong>${this._esc(crumbLabel)}</strong>
|
||
</div>
|
||
${this._state ? `
|
||
<button type="button" class="tm-search-btn" data-act="palette-open" aria-label="${this._esc(searchLabel)}" title="${this._esc(searchLabel)}">
|
||
<ha-icon icon="mdi:magnify"></ha-icon>
|
||
<span class="tm-search-btn-text">${this._esc(searchLabel)}</span>
|
||
<kbd>${mac ? "⌘K" : "Ctrl K"}</kbd>
|
||
</button>
|
||
${this._scopeChips()}
|
||
` : ""}
|
||
${pendingCount > 0 && this._activeTab !== "activity" && this._activeTab !== "today" ? `
|
||
<button type="button" class="tm-approval-pill" data-act="switch-to-activity" title="${this._t("panel.pending_tooltip", {count: pendingCount})}">
|
||
<span class="tm-approval-dot"></span>
|
||
${this._t("panel.topbar_pending", {count: pendingCount})}
|
||
</button>
|
||
` : ""}
|
||
${this._state ? this._newMenu() : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ---- 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 `<span class="tm-av ${cls}" style="--kc:${this._childColor(child.id)}"><ha-icon icon="${this._esc(child.avatar || "mdi:account-circle")}"></ha-icon></span>`;
|
||
}
|
||
|
||
_scopeChips() {
|
||
const children = (this._state && this._state.children) || [];
|
||
if (children.length < 2) return "";
|
||
const current = this._scopeId();
|
||
return `
|
||
<div class="tm-scope" role="group" aria-label="${this._esc(this._t("panel.scope_label"))}">
|
||
<button type="button" class="tm-scope-btn tm-scope-all ${current ? "" : "tm-scope-on"}" data-act="scope" data-child="" aria-pressed="${current ? "false" : "true"}">${this._t("panel.scope_all")}</button>
|
||
${children.map(c => `
|
||
<button type="button" class="tm-scope-btn ${current === c.id ? "tm-scope-on" : ""}" data-act="scope" data-child="${this._esc(c.id)}"
|
||
aria-pressed="${current === c.id ? "true" : "false"}" title="${this._esc(this._t("panel.scope_only", { name: c.name }))}">
|
||
${this._childAvatar(c)}<span class="tm-scope-name">${this._esc(c.name)}</span>
|
||
</button>`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_scopeBanner() {
|
||
const id = this._scopeId();
|
||
if (!id) return "";
|
||
const child = (this._state.children || []).find(c => c.id === id);
|
||
return `
|
||
<div class="tm-scope-banner">
|
||
<ha-icon icon="mdi:filter-variant"></ha-icon>
|
||
<span>${this._t("panel.scope_showing", { name: `<strong>${this._esc(child.name)}</strong>` })}</span>
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="scope" data-child="">${this._t("panel.scope_clear")}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ---- "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 `
|
||
<div class="tm-new-wrap">
|
||
<button type="button" class="tm-btn tm-btn-raised tm-new-btn" data-act="new-menu" aria-haspopup="menu" aria-expanded="${open ? "true" : "false"}" title="${this._esc(this._t("panel.new_btn"))}">
|
||
<ha-icon icon="mdi:plus"></ha-icon><span class="tm-new-text">${this._t("panel.new_btn")}</span>
|
||
</button>
|
||
${open ? `
|
||
<div class="tm-new-menu" role="menu">
|
||
${this._newMenuItems().map(it => `
|
||
<button type="button" class="tm-new-item" role="menuitem" data-act="${it.act}">
|
||
<ha-icon icon="${it.icon}"></ha-icon><span>${this._esc(it.label)}</span>
|
||
</button>`).join("")}
|
||
</div>` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ---- 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 `
|
||
<nav class="tm-bnav" aria-label="${this._esc(this._t("panel.nav_sections"))}">
|
||
${items.map(it => `
|
||
<button type="button" class="tm-bnav-item ${it.id === this._activeTab ? "tm-bnav-on" : ""}" data-act="tab" data-tab="${it.id}">
|
||
<ha-icon icon="${it.icon}"></ha-icon><span>${this._esc(it.label)}</span>
|
||
${it.id === "today" && today > 0 ? `<span class="tm-bnav-dot">${today}</span>` : ""}
|
||
</button>`).join("")}
|
||
<button type="button" class="tm-bnav-item ${inBar ? "" : "tm-bnav-on"}" data-act="toggle-mobile-nav">
|
||
<ha-icon icon="mdi:dots-grid"></ha-icon><span>${this._t("panel.nav_more")}</span>
|
||
</button>
|
||
</nav>
|
||
`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-mobilenav ${open ? "tm-mobilenav-open" : ""}">
|
||
<button type="button" class="tm-mnav-trigger" data-act="toggle-mobile-nav"
|
||
aria-haspopup="true" aria-expanded="${open ? "true" : "false"}">
|
||
<span class="tm-mnav-ico"><ha-icon icon="${this._esc(current.icon)}"></ha-icon></span>
|
||
<span class="tm-mnav-main">
|
||
<span class="tm-mnav-crumb">TaskMate</span>
|
||
<span class="tm-mnav-title">${this._esc(current.label)}</span>
|
||
</span>
|
||
${curShowCount ? `<span class="tm-mnav-count ${curUrgent ? "tm-mnav-count-urgent" : ""}">${current.count}</span>` : ""}
|
||
<span class="tm-mnav-chev"><ha-icon icon="mdi:chevron-down"></ha-icon></span>
|
||
</button>
|
||
${open ? `
|
||
<div class="tm-mnav-scrim" data-act="close-mobile-nav"></div>
|
||
<nav class="tm-mnav-sheet">
|
||
${groups.map(g => `
|
||
${g.head ? `<div class="tm-mnav-grp-head">${this._esc(g.head)}</div>` : ""}
|
||
${g.items.map(it => {
|
||
const active = it.id === this._activeTab;
|
||
const urgent = (it.id === "activity" || it.id === "today") && it.count > 0;
|
||
const showCount = it.count != null && it.count > 0;
|
||
return `
|
||
<button type="button" class="tm-mnav-item ${active ? "tm-mnav-item-active" : ""}" data-act="tab" data-tab="${it.id}">
|
||
<ha-icon class="tm-mnav-item-ico" icon="${this._esc(it.icon)}"></ha-icon>
|
||
<span class="tm-mnav-item-label">${this._esc(it.label)}</span>
|
||
${showCount ? `<span class="tm-mnav-count ${urgent ? "tm-mnav-count-urgent" : ""}">${it.count}</span>` : ""}
|
||
</button>`;
|
||
}).join("")}
|
||
`).join("")}
|
||
</nav>
|
||
` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_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 <strong> prefix — issue #573).
|
||
if (completionsP) parts.push(this._t("panel.topbar_chore_count", {count: `<strong>${completionsP}</strong>`}));
|
||
if (rewardsP) parts.push(this._t("panel.topbar_reward_count", {count: `<strong>${rewardsP}</strong>`}));
|
||
if (swapsP) parts.push(this._t("panel.topbar_swap_count", {count: `<strong>${swapsP}</strong>`}));
|
||
return `
|
||
<div class="tm-approval-banner">
|
||
<ha-icon icon="mdi:bell-ring-outline"></ha-icon>
|
||
<span>${this._t("panel.topbar_awaiting_approval", {details: parts.join(" · ")})}</span>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="switch-to-activity">${this._t("panel.btn_review")}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderBody() {
|
||
if (this._loading && !this._state) return `<div class="tm-card tm-loading">${this._t("panel.loading")}</div>`;
|
||
if (this._error) return `
|
||
<div class="tm-card tm-card-error">
|
||
<h2>${this._t("panel.error_failed_to_load")}</h2>
|
||
<p>${this._esc(this._error)}</p>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="retry">${this._t("panel.btn_retry")}</button>
|
||
</div>`;
|
||
if (!this._state) return `<div class="tm-card">${this._t("panel.error_no_state")}</div>`;
|
||
|
||
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 `<div class="tm-card">${this._t("panel.tab_unknown")}</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 = `
|
||
<div class="tm-chip-row" style="margin-bottom:14px">
|
||
${["fairness", "friction", "projection", ...(this._ratingOn() || view === "quality" ? ["quality"] : []), "health"].map(v => `
|
||
<button type="button" class="tm-chip-btn ${v === view ? "tm-chip-on" : ""}"
|
||
data-act="insights-view" data-id="${v}">
|
||
${this._t("panel.insights_view_" + v)}
|
||
</button>
|
||
`).join("")}
|
||
</div>`;
|
||
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 `<div class="tm-card"><div class="tm-empty">${this._t("panel.insights_loading")}</div></div>`;
|
||
}
|
||
const days = this._frictionDays || 30;
|
||
const problems = report.chores.filter(c => c.verdict !== "fine");
|
||
|
||
return `
|
||
<div class="tm-card">
|
||
<div class="tm-card-head">
|
||
<h2>${this._t("panel.insights_friction_title")}</h2>
|
||
<div class="tm-chip-row">
|
||
${[14, 30, 90].map(d => `
|
||
<button type="button" class="tm-chip-btn ${d === days ? "tm-chip-on" : ""}"
|
||
data-act="friction-window" data-id="${d}">
|
||
${this._t("panel.insights_last_days", { days: d })}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.insights_friction_intro", { start: report.start, end: report.end })}</span>
|
||
|
||
${report.chores.length === 0 ? `
|
||
<div class="tm-empty">${this._t("panel.insights_friction_none")}</div>
|
||
` : `
|
||
<div class="tm-verdict tm-verdict-${report.problem_count ? "warn" : "ok"}">
|
||
${report.problem_count
|
||
? this._t("panel.insights_friction_problems", { count: report.problem_count })
|
||
: this._t("panel.insights_friction_healthy")}
|
||
</div>
|
||
<table class="tm-table tm-friction">
|
||
<thead><tr>
|
||
<th>${this._t("panel.insights_col_chore")}</th>
|
||
<th>${this._t("panel.insights_col_done")}</th>
|
||
<th>${this._t("panel.insights_col_rate")}</th>
|
||
<th>${this._t("panel.insights_col_last")}</th>
|
||
<th>${this._t("panel.insights_col_verdict")}</th>
|
||
<th>${this._t("panel.insights_col_suggestion")}</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${(problems.length ? problems : report.chores).map(c => `
|
||
<tr>
|
||
<td><strong>${this._esc(c.name)}</strong>${c.outstanding_misses
|
||
? ` <span class="tm-fric-misses" title="${this._t("panel.insights_outstanding_misses")}">⚠ ${c.outstanding_misses}</span>` : ""}</td>
|
||
<td>${c.completed}${c.expected ? ` / ${c.expected}` : ""}</td>
|
||
<td>${c.rate === null ? "—" : c.rate + "%"}</td>
|
||
<td>${c.days_since === null
|
||
? this._t("panel.insights_never")
|
||
: this._t("panel.insights_days_ago", { days: c.days_since })}</td>
|
||
<td><span class="tm-fric-verdict tm-fric-${c.verdict}">${this._t("panel.insights_verdict_" + c.verdict)}</span></td>
|
||
<td>${this._t("panel.insights_suggest_" + c.suggestion)}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
<span class="tm-field-hint">${this._t("panel.insights_friction_note")}</span>
|
||
`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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 `<div class="tm-card"><div class="tm-empty">${this._t("panel.insights_loading")}</div></div>`;
|
||
}
|
||
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 `
|
||
<div class="tm-card">
|
||
<div class="tm-card-head">
|
||
<h2>${this._t("panel.insights_projection_title")}</h2>
|
||
<div class="tm-chip-row">
|
||
${[7, 14, 28].map(d => `
|
||
<button type="button" class="tm-chip-btn ${d === days ? "tm-chip-on" : ""}"
|
||
data-act="projection-window" data-id="${d}">
|
||
${this._t("panel.insights_next_days", { days: d })}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.insights_projection_intro", { start: report.start, end: report.end })}</span>
|
||
|
||
<div class="tm-proj-totals">
|
||
${kids.map(k => `
|
||
<div class="tm-proj-kid">
|
||
<div class="tm-fair-name">${this._esc(k.name)}</div>
|
||
<div class="tm-fair-bar"><i style="width:${Math.round(k.points / maxPoints * 100)}%"></i></div>
|
||
<div class="tm-fair-figs">
|
||
${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 })}
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
|
||
${report.unassigned_points > 0 ? `
|
||
<div class="tm-verdict tm-verdict-warn">
|
||
${this._t("panel.insights_projection_unassigned", { points: report.unassigned_points })}
|
||
</div>` : ""}
|
||
|
||
<table class="tm-table tm-projection">
|
||
<thead><tr>
|
||
<th>${this._t("panel.insights_col_day")}</th>
|
||
${kids.map(k => `<th>${this._esc(k.name)}</th>`).join("")}
|
||
</tr></thead>
|
||
<tbody>
|
||
${report.by_day.map(d => `
|
||
<tr>
|
||
<td><strong>${this._t("panel.day_" + d.weekday.slice(0, 3))}</strong> <span class="tm-proj-date">${d.date.slice(5)}</span></td>
|
||
${kids.map(k => {
|
||
const cell = d.children.find(c => c.id === k.id) || { points: 0, chores: 0 };
|
||
return `<td>${cell.chores ? `${this._num(cell.points)} <span class="tm-proj-count">(${this._num(cell.chores)})</span>` : "—"}</td>`;
|
||
}).join("")}
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
<span class="tm-field-hint">${this._t("panel.insights_projection_note")}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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 `<div class="tm-card"><div class="tm-empty">${this._t("panel.insights_loading")}</div></div>`;
|
||
}
|
||
const kb = (report.storage_bytes / 1024).toFixed(1);
|
||
const counts = report.counts;
|
||
|
||
return `
|
||
<div class="tm-card">
|
||
<div class="tm-card-head">
|
||
<h2>${this._t("panel.health_title")}</h2>
|
||
<button type="button" class="tm-btn" data-act="health-refresh">${this._t("panel.health_recheck")}</button>
|
||
</div>
|
||
|
||
<div class="tm-verdict tm-verdict-${report.healthy ? "ok" : "warn"}">
|
||
${report.healthy
|
||
? this._t("panel.health_all_good")
|
||
: this._t("panel.health_issues", { count: report.issues.filter(i => i.severity !== "info").length })}
|
||
</div>
|
||
|
||
${report.issues.length ? `
|
||
<div class="tm-health-issues">
|
||
${report.issues.map(i => `
|
||
<div class="tm-health-issue tm-health-${i.severity}">
|
||
<span class="tm-health-sev">${this._t("panel.health_sev_" + i.severity)}</span>
|
||
<span class="tm-health-msg">${this._esc(i.message)}</span>
|
||
<button type="button" class="tm-btn tm-btn-small" data-act="health-goto" data-id="${this._esc(i.where)}">
|
||
${this._t("panel.health_goto")}
|
||
</button>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
|
||
<div class="tm-health-counts">
|
||
${Object.entries(counts).map(([key, value]) => `
|
||
<div class="tm-health-stat">
|
||
<div class="tm-health-num">${value}</div>
|
||
<div class="tm-health-label">${this._t("panel.health_count_" + key)}</div>
|
||
</div>
|
||
`).join("")}
|
||
<div class="tm-health-stat">
|
||
<div class="tm-health-num">${kb} KB</div>
|
||
<div class="tm-health-label">${this._t("panel.health_count_storage")}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 `<div class="tm-card"><div class="tm-empty">${this._t("panel.insights_loading")}</div></div>`;
|
||
}
|
||
|
||
const windows = [7, 14, 30];
|
||
const maxShare = Math.max(1, ...report.children.map(c => c.share_completions));
|
||
|
||
return `
|
||
<div class="tm-card">
|
||
<div class="tm-card-head">
|
||
<h2>${this._t("panel.insights_fairness_title")}</h2>
|
||
<div class="tm-chip-row">
|
||
${windows.map(d => `
|
||
<button type="button" class="tm-chip-btn ${d === days ? "tm-chip-on" : ""}"
|
||
data-act="insights-window" data-id="${d}">
|
||
${this._t("panel.insights_last_days", { days: d })}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.insights_fairness_intro", {
|
||
start: report.start, end: report.end, fair: report.fair_share,
|
||
})}</span>
|
||
|
||
${report.total_completions === 0 ? `
|
||
<div class="tm-empty">${this._t("panel.insights_no_data")}</div>
|
||
` : `
|
||
<div class="tm-verdict tm-verdict-${report.balanced ? "ok" : "warn"}">
|
||
${report.balanced
|
||
? this._t("panel.insights_balanced")
|
||
: this._t("panel.insights_unbalanced")}
|
||
</div>
|
||
<div class="tm-fairness">
|
||
${report.children.map(c => `
|
||
<div class="tm-fair-row">
|
||
<div class="tm-fair-name">${this._esc(c.name)}</div>
|
||
<div class="tm-fair-bar">
|
||
<i class="tm-fair-${c.status}" style="width:${Math.round(c.share_completions / maxShare * 100)}%"></i>
|
||
<span class="tm-fair-target" style="inset-inline-start:${Math.round(report.fair_share / maxShare * 100)}%"></span>
|
||
</div>
|
||
<div class="tm-fair-figs">
|
||
<strong>${c.completions}</strong> ${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}%
|
||
</div>
|
||
<div class="tm-fair-status tm-fair-${c.status}">${this._t("panel.insights_status_" + c.status)}</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.insights_fairness_note", { tolerance: report.tolerance })}</span>
|
||
`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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 `<div class="tm-card"><div class="tm-empty">${this._t("panel.insights_loading")}</div></div>`;
|
||
}
|
||
const stars = (avg) => {
|
||
if (avg === null || avg === undefined) return `<span class="tm-meta">—</span>`;
|
||
const full = Math.round(avg);
|
||
return `<span class="tm-q-stars" aria-hidden="true">${"★".repeat(full)}<span class="tm-q-off">${"☆".repeat(3 - full)}</span></span>
|
||
<span class="tm-numeric">${avg.toFixed(1)}</span>`;
|
||
};
|
||
const table = (title, rows) => `
|
||
<h3 class="tm-section-title" style="margin-top:16px">${this._esc(title)}</h3>
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table tm-quality">
|
||
<thead><tr>
|
||
<th></th>
|
||
<th>${this._t("panel.insights_quality_col_average")}</th>
|
||
<th>${this._t("panel.insights_quality_col_rated")}</th>
|
||
<th>★</th><th>★★</th><th>★★★</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${rows.map(r => `
|
||
<tr class="tm-row">
|
||
<td><strong>${this._esc(r.name)}</strong></td>
|
||
<td>${stars(r.average)}</td>
|
||
<td class="tm-numeric">${this._ltrNums(`${r.rated} / ${r.total}`)}</td>
|
||
<td class="tm-numeric">${r.counts["1"]}</td>
|
||
<td class="tm-numeric">${r.counts["2"]}</td>
|
||
<td class="tm-numeric">${r.counts["3"]}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
return `
|
||
<div class="tm-card">
|
||
<div class="tm-card-head">
|
||
<h2>${this._t("panel.insights_quality_title")}</h2>
|
||
<div class="tm-chip-row">
|
||
${[7, 30, 90].map(d => `
|
||
<button type="button" class="tm-chip-btn ${d === days ? "tm-chip-on" : ""}"
|
||
data-act="quality-window" data-id="${d}">
|
||
${this._t("panel.insights_last_days", { days: d })}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.insights_quality_intro", { start: report.start, end: report.end })}</span>
|
||
${report.enabled ? "" : `<div class="tm-verdict tm-verdict-warn">${this._t("panel.insights_quality_disabled")}</div>`}
|
||
${!report.rated_completions ? `
|
||
<div class="tm-empty">${this._t("panel.insights_quality_none")}</div>
|
||
` : `
|
||
<div class="tm-verdict tm-verdict-ok">${this._t("panel.insights_quality_overall", {
|
||
average: Number(report.average).toFixed(1), rated: report.rated_completions, total: report.total_completions,
|
||
})}</div>
|
||
${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))}
|
||
`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 `
|
||
<div class="tm-search-wrap">
|
||
<ha-icon icon="mdi:magnify" class="tm-search-icon"></ha-icon>
|
||
<input class="tm-search" placeholder="${this._esc(placeholder)}" data-filter="true" value="${this._esc(this._filter)}">
|
||
${this._filter ? `<button type="button" class="tm-search-clear" data-act="clear-filter" title="${this._t("panel.tooltip_clear")}">✕</button>` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.child_title")} <span class="tm-toolbar-count">${all.length}</span></h2>
|
||
${all.length > 0 ? this._searchBox(this._t("panel.search_children")) : ""}
|
||
${all.length >= 2 ? `<button type="button" class="tm-btn" data-act="gift-points">${this._t("panel.gift_btn")}</button>` : ""}
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-child">${this._t("panel.btn_add_child")}</button>
|
||
</div>
|
||
${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 ? `<div class="tm-card tm-empty"><p>${this._t("panel.empty_no_match", {kind: this._t("panel.tab_children").toLowerCase(), filter: this._esc(this._filter)})}</p></div>` : `
|
||
<div class="tm-grid">
|
||
${children.map(c => this._renderChildCard(c, pointsName)).join("")}
|
||
<button type="button" class="tm-add-tile" data-act="add-child"><span class="tm-add-plus">+</span>${this._t("panel.child_add_tile")}</button>
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
_renderChildCard(child, pointsName) {
|
||
return `
|
||
<article class="tm-card tm-child-card">
|
||
<div class="tm-child-head">
|
||
<div class="tm-avatar">${this._mdi(child.avatar)}</div>
|
||
<div class="tm-child-name">
|
||
<h3>${this._esc(child.name || this._t("panel.child_unnamed"))}</h3>
|
||
${this._idBadge(child.id)}
|
||
<div class="tm-meta">${child.availability_entity ? `<code title="${this._esc(child.availability_entity)}">${this._esc(child.availability_entity)}</code>${child.availability_inverted ? ` <em>${this._t("panel.child_inverted")}</em>` : ""}` : `${this._t("panel.child_no_availability")}`}${child.unavailability_entity ? ` · ${this._t("panel.child_busy_label")} <code title="${this._esc(child.unavailability_entity)}">${this._esc(child.unavailability_entity)}</code>` : ""}</div>
|
||
</div>
|
||
</div>
|
||
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr;">
|
||
<div class="tm-stat tm-stat-highlight"><div class="tm-stat-value">${this._fmtNum(child.points || 0)}</div><div class="tm-stat-label">${this._esc(pointsName)}</div></div>
|
||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(child.level || 1)}</div><div class="tm-stat-label">${this._t("panel.child_stat_level")}</div></div>
|
||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(child.total_points_earned || 0)}</div><div class="tm-stat-label">${this._t("panel.child_stat_earned")}</div></div>
|
||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(child.total_chores_completed || 0)}</div><div class="tm-stat-label">${this._t("panel.child_stat_done")}</div></div>
|
||
</div>
|
||
${this._renderAdjustStrip(child, pointsName)}
|
||
<div class="tm-card-foot">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="edit-child" data-id="${this._esc(child.id)}">${this._t("panel.btn_edit")}</button>
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="reorder-chores-for-child" data-id="${this._esc(child.id)}" title="${this._t("panel.chore_order_title")}">⇅ ${this._t("panel.chore_order_title")}</button>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="delete-child" data-id="${this._esc(child.id)}">${this._t("panel.btn_delete")}</button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
// 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 `<button type="button" class="tm-btn tm-btn-sm ${delta < 0 ? "tm-btn-danger" : "tm-btn-raised"}"
|
||
data-act="adjust-points" data-id="${id}" data-delta="${delta}"
|
||
title="${this._esc(label)}" aria-label="${this._esc(label)}">${delta < 0 ? "−" : "+"}${amount}</button>`;
|
||
};
|
||
const customLabel = this._t("panel.adjust_custom_title", { child: name });
|
||
return `
|
||
<div class="tm-points-adjust">
|
||
<span class="tm-points-adjust-set">${amounts.slice().reverse().map(a => btn(-a)).join("")}</span>
|
||
<span class="tm-points-adjust-gap"></span>
|
||
<span class="tm-points-adjust-set">
|
||
${amounts.map(a => btn(a)).join("")}
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="adjust-points-custom" data-id="${id}"
|
||
title="${this._esc(customLabel)}" aria-label="${this._esc(customLabel)}">⋯</button>
|
||
</span>
|
||
</div>`;
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-timeline">
|
||
${events.map(ev => `
|
||
<div class="tm-timeline-row">
|
||
<div class="tm-timeline-time">${this._esc(this._timeAgo(ev.ts))}</div>
|
||
<div class="tm-timeline-icon tm-timeline-${ev.kind}"><ha-icon icon="${ev.kind === 'completion' ? 'mdi:check-circle' : ev.kind === 'claim' ? 'mdi:gift' : ev.kind === 'inspection' ? 'mdi:magnify-scan' : ev.points >= 0 ? 'mdi:plus-circle' : 'mdi:minus-circle'}"></ha-icon></div>
|
||
<div class="tm-timeline-body">
|
||
<div><strong>${this._esc(ev.child)}</strong> · ${this._esc(ev.label)}${ev.kind === "completion" && ev.completion_id ? ` ${this._inspSlot(ev.completion_id)}` : ""}</div>
|
||
${ev.note ? `<div class="tm-meta tm-insp-note">“${this._esc(ev.note)}”</div>` : ""}
|
||
</div>
|
||
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points == null ? "" : `${ev.points >= 0 ? '+' : ''}${this._num(ev.points)}`}</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 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 `
|
||
<div class="tm-approval-item">
|
||
<div class="tm-approval-icon"><ha-icon icon="${c.bonus_subtask_id ? 'mdi:star-plus' : c.bounty_id ? 'mdi:flag-outline' : 'mdi:checkbox-marked-circle-outline'}"></ha-icon></div>
|
||
${this._safePhotoUrl(c.photo_url) ? `<a class="tm-approval-photo" href="${this._esc(this._safePhotoUrl(c.photo_url))}" target="_blank" rel="noopener" data-act="view-photo" data-photo="${this._esc(this._safePhotoUrl(c.photo_url))}" data-cap="${this._esc(photoCap)}" title="${this._t("panel.activity_view_photo")}"><img src="${this._esc(this._safePhotoUrl(c.photo_url))}" alt="" loading="lazy"></a>` : ""}
|
||
<div class="tm-approval-body">
|
||
<div class="tm-approval-line">${this._t("panel.activity_completed_text", {child: this._esc((child && child.name) || "?"), chore: this._esc(choreName)})}</div>
|
||
<div class="tm-meta">${this._timeAgo(c.completed_at)} · ${chorePoints} ${this._t("panel.activity_points")}${redoIds.has(c.id) ? ` · ${this._esc(this._t("panel.insp_redo_queue"))}` : ""}</div>
|
||
</div>
|
||
<div class="tm-approval-actions">
|
||
${this._ratingOn() ? this._ratingPicker(c.id) : ""}
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="reject-chore" data-id="${this._esc(c.id)}">${this._t("panel.activity_reject")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="approve-chore" data-id="${this._esc(c.id)}">${this._t("panel.activity_approve")}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
${pendingClaims.map(c => {
|
||
const reward = rewardById[c.reward_id];
|
||
const child = childById[c.child_id];
|
||
return `
|
||
<div class="tm-approval-item">
|
||
<div class="tm-approval-icon"><ha-icon icon="mdi:gift-outline"></ha-icon></div>
|
||
<div class="tm-approval-body">
|
||
<div class="tm-approval-line">${this._t("panel.activity_claimed_text", {child: this._esc((child && child.name) || "?"), reward: this._esc((reward && reward.name) || this._t("panel.activity_deleted_reward"))})}</div>
|
||
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${this._num(reward.cost)} ${this._t("panel.activity_points")}` : ""}</div>
|
||
</div>
|
||
<div class="tm-approval-actions">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="reject-reward" data-id="${this._esc(c.id)}">${this._t("panel.activity_reject")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="approve-reward" data-id="${this._esc(c.id)}">${this._t("panel.activity_approve")}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
${pendingSwaps.map(sw => {
|
||
const chore = choreById[sw.chore_id];
|
||
const req = childById[sw.requester_id];
|
||
const from = childById[sw.from_child_id];
|
||
return `
|
||
<div class="tm-approval-item">
|
||
<div class="tm-approval-icon"><ha-icon icon="mdi:swap-horizontal"></ha-icon></div>
|
||
<div class="tm-approval-body">
|
||
<div class="tm-approval-line">${this._t("panel.swap_request_text", {child: this._esc((req && req.name) || "?"), chore: this._esc((chore && chore.name) || "?")})}</div>
|
||
<div class="tm-meta">${from ? this._t("panel.swap_from", {name: this._esc(from.name)}) + " · " : ""}${this._timeAgo(sw.created_at)}</div>
|
||
</div>
|
||
<div class="tm-approval-actions">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="reject-swap" data-id="${this._esc(sw.id)}">${this._t("panel.activity_reject")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="approve-swap" data-id="${this._esc(sw.id)}">${this._t("panel.activity_approve")}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
${pendingWishes.map(w => {
|
||
const child = childById[w.child_id];
|
||
return `
|
||
<div class="tm-approval-item">
|
||
<div class="tm-approval-icon"><ha-icon icon="mdi:heart-outline"></ha-icon></div>
|
||
<div class="tm-approval-body">
|
||
<div class="tm-approval-line">${this._t("panel.today_wish_text", {child: this._esc((child && child.name) || "?"), wish: this._esc(w.name)})}</div>
|
||
<div class="tm-meta">${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))}</div>
|
||
</div>
|
||
<div class="tm-approval-actions">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="wish-decline" data-id="${this._esc(w.id)}">${this._t("panel.wish_decline")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="wish-approve" data-id="${this._esc(w.id)}">${this._t("panel.wish_approve")}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
`;
|
||
return {
|
||
count: pendingCompletions.length + pendingClaims.length + pendingSwaps.length + pendingWishes.length + inspections.length,
|
||
chores: pendingCompletions.length,
|
||
html,
|
||
};
|
||
}
|
||
|
||
_approveAllButton(chores) {
|
||
if (!chores) return "";
|
||
return `<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="approve-all-chores" style="margin-inline-start:auto"${this._approvingAll ? " disabled" : ""}>${this._approvingAll
|
||
? `<span class="tm-btn-spinner"></span>${this._t("panel.activity_approve_all_busy")}`
|
||
: this._t("panel.activity_approve_all")}</button>`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.activity_title")}</h2>
|
||
</div>
|
||
|
||
<!-- Pending approvals -->
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title">${this._t("panel.activity_pending_approvals")}
|
||
${queue.count > 0
|
||
? `<span class="tm-pill tm-pill-warn">${queue.count}</span>`
|
||
: `<span class="tm-pill tm-pill-success">${this._t("panel.activity_pending_all_clear")}</span>`}
|
||
${this._approveAllButton(queue.chores)}
|
||
</h3>
|
||
${queue.count === 0 ? `
|
||
<p class="tm-meta">${this._t("panel.activity_no_items")}</p>
|
||
` : `
|
||
<div class="tm-approval-list">${queue.html}</div>
|
||
`}
|
||
</div>
|
||
|
||
<!-- Recent activity feed -->
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title">${this._t("panel.activity_recent")}</h3>
|
||
${recent.length === 0 ? `<p class="tm-meta">${this._t("panel.activity_no_recent")}</p>` : this._timelineHtml(recent)}
|
||
</div>
|
||
|
||
<!-- Audit log -->
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title">${this._t("panel.activity_audit_log")}
|
||
<span class="tm-pill">${this._t(transactions.length === 1 ? "panel.activity_transaction_count" : "panel.activity_transaction_count_plural", {count: transactions.length})}</span>
|
||
</h3>
|
||
${transactions.length === 0 ? `<p class="tm-meta">${this._t("panel.activity_no_points_transactions")}</p>` : `
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr><th>${this._t("panel.activity_table_when")}</th><th>${this._t("panel.activity_table_child")}</th><th>${this._t("panel.activity_table_points")}</th><th>${this._t("panel.activity_table_reason")}</th><th></th></tr></thead>
|
||
<tbody>
|
||
${[...transactions].reverse().map(t => {
|
||
const child = childById[t.child_id];
|
||
const undoable = this._txnReversible(t.reason);
|
||
return `
|
||
<tr class="tm-row">
|
||
<td class="tm-meta">${this._esc(this._timeAgo(t.created_at))}</td>
|
||
<td>${this._esc((child && child.name) || "?")}</td>
|
||
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${this._num(t.points)}</strong></td>
|
||
<td>${this._esc(this._translateReason(t.reason) || "—")}</td>
|
||
<td>${undoable ? `<button type="button" class="tm-icon-btn" data-act="undo-tx" data-id="${this._esc(t.id)}" title="${this._esc(this._t("panel.activity_undo"))}"><ha-icon class="tm-rtl-flip" icon="mdi:undo-variant"></ha-icon></button>` : ""}</td>
|
||
</tr>
|
||
`;
|
||
}).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// -- 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
|
||
? `<span class="tm-today-needs">${this._t(queue.count === 1 ? "panel.today_needs_one" : "panel.today_needs_many", { count: queue.count })}</span>`
|
||
: this._t("panel.today_all_clear");
|
||
|
||
if ((this._state.children || []).length === 0) {
|
||
return `
|
||
<div class="tm-toolbar"><h2 class="tm-toolbar-title">${this._esc(greet)}</h2></div>
|
||
${this._wizardTodayEmpty()}
|
||
`;
|
||
}
|
||
|
||
const recent = this._activityEvents().slice(0, 6);
|
||
return `
|
||
<div class="tm-today-head">
|
||
<h2 class="tm-toolbar-title">${this._esc(greet)}</h2>
|
||
<div class="tm-meta">${this._esc(dateText)} · ${needs}</div>
|
||
</div>
|
||
${this._wizardNudge()}
|
||
|
||
<div class="tm-today-kids">
|
||
${kids.map(c => this._renderTodayKid(c, pointsName)).join("")}
|
||
</div>
|
||
|
||
<div class="tm-today-cols">
|
||
<div class="tm-today-col">
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title"><ha-icon icon="mdi:inbox-outline"></ha-icon>${this._t("panel.today_needs_title")}
|
||
${queue.count > 0 ? `<span class="tm-pill tm-pill-warn">${queue.count}</span>` : ""}
|
||
${this._approveAllButton(queue.chores)}
|
||
</h3>
|
||
${queue.count === 0
|
||
? `<p class="tm-meta tm-today-clear"><ha-icon icon="mdi:check-circle-outline"></ha-icon>${this._t("panel.today_needs_empty")}</p>`
|
||
: `<div class="tm-approval-list">${queue.html}</div>`}
|
||
</div>
|
||
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title"><ha-icon icon="mdi:calendar-check-outline"></ha-icon>${this._t("panel.today_board_title")}
|
||
<span class="tm-seg" role="tablist">
|
||
<button type="button" role="tab" class="${this._boardView === "week" ? "" : "tm-seg-on"}" aria-selected="${this._boardView === "week" ? "false" : "true"}" data-act="board-view" data-view="today">${this._t("panel.today_board_today")}</button>
|
||
<button type="button" role="tab" class="${this._boardView === "week" ? "tm-seg-on" : ""}" aria-selected="${this._boardView === "week" ? "true" : "false"}" data-act="board-view" data-view="week">${this._t("panel.today_board_week")}</button>
|
||
</span>
|
||
</h3>
|
||
${this._boardView === "week" ? this._renderTodayWeek(kids) : this._renderTodayBoard(kids)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-today-col">
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title"><ha-icon icon="mdi:lightning-bolt-outline"></ha-icon>${this._t("panel.today_quick_title")}</h3>
|
||
<div class="tm-today-quick">
|
||
<button type="button" class="tm-btn" data-act="add-chore"><ha-icon icon="mdi:check-circle-outline"></ha-icon>${this._t("panel.new_chore")}</button>
|
||
<button type="button" class="tm-btn" data-act="add-bounty"><ha-icon icon="mdi:flag-outline"></ha-icon>${this._t("panel.new_bounty")}</button>
|
||
<button type="button" class="tm-btn" data-act="tab" data-tab="bonuses"><ha-icon icon="mdi:flash-outline"></ha-icon>${this._t("panel.today_quick_bonus")}</button>
|
||
<button type="button" class="tm-btn" data-act="tab" data-tab="penalties"><ha-icon icon="mdi:alert-circle-outline"></ha-icon>${this._t("panel.today_quick_penalty")}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title"><ha-icon icon="mdi:pulse"></ha-icon>${this._t("panel.activity_recent")}
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="tab" data-tab="activity" style="margin-inline-start:auto">${this._t("panel.today_view_all")}</button>
|
||
</h3>
|
||
${recent.length === 0 ? `<p class="tm-meta">${this._t("panel.activity_no_recent")}</p>` : this._timelineHtml(recent)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_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 = `<span class="tm-pill">${this._t("panel.today_nothing_due")}</span>`;
|
||
else if (done >= due) dayPill = `<span class="tm-pill tm-pill-success">${this._t("panel.today_all_done")}</span>`;
|
||
else dayPill = `<span class="tm-pill">${this._t("panel.today_left", { count: due - done })}</span>`;
|
||
}
|
||
const scopeLabel = this._t("panel.scope_only", { name: child.name });
|
||
return `
|
||
<div class="tm-card tm-kid" style="--kc:${this._childColor(child.id)}">
|
||
<button type="button" class="tm-kid-main" data-act="scope" data-child="${this._esc(child.id)}" title="${this._esc(scopeLabel)}" aria-label="${this._esc(scopeLabel)}">
|
||
<span class="tm-ring" style="--p:${pct}" role="img" aria-label="${this._esc(this._t("panel.today_ring_label", { done, due }))}"><span>${entry && due ? `${done}/${due}` : "–"}</span></span>
|
||
<span class="tm-kid-text">
|
||
<span class="tm-kid-name">${this._esc(child.name)}</span>
|
||
<span class="tm-kid-pts"><strong class="tm-numeric">${this._fmtNum(child.points)}</strong> ${this._esc(pointsName)}</span>
|
||
</span>
|
||
</button>
|
||
<button type="button" class="tm-icon-btn tm-kid-adj" data-act="adjust-points-custom" data-id="${this._esc(child.id)}" title="${this._esc(this._t("panel.today_adjust_points"))}" aria-label="${this._esc(this._t("panel.today_adjust_points"))}">
|
||
<ha-icon icon="mdi:plus-minus-variant"></ha-icon>
|
||
</button>
|
||
<div class="tm-kid-pills">
|
||
${streak > 0 ? `<span class="tm-pill"><ha-icon icon="mdi:fire"></ha-icon>${this._ltrNums(this._t("panel.today_streak", { count: streak }))}</span>` : ""}
|
||
${dayPill}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderTodayBoard(kids) {
|
||
const board = this._state.today && this._state.today.board;
|
||
if (!board) return `<p class="tm-meta">${this._t("panel.today_board_unavailable")}</p>`;
|
||
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 `<p class="tm-meta">${this._t("panel.today_board_empty")}</p>`;
|
||
const statusLabel = st => this._t(`panel.today_status_${st}`);
|
||
return `
|
||
<div class="tm-board-wrap">
|
||
<div class="tm-board" style="--cols:${kids.length}">
|
||
<div class="tm-board-h"></div>
|
||
${kids.map(k => `<div class="tm-board-h">${this._childAvatar(k, "tm-av-sm")}<span>${this._esc(k.name)}</span></div>`).join("")}
|
||
${rows.map(p => `
|
||
<div class="tm-board-slot"><ha-icon icon="${this._esc(p.icon)}"></ha-icon><span>${this._esc(p.label)}</span></div>
|
||
${kids.map(k => {
|
||
const items = cells[`${p.id}|${k.id}`] || [];
|
||
return `<div class="tm-board-cell">${items.length ? items.map(it => `
|
||
<button type="button" class="tm-bchip tm-bchip-${it.status}" data-act="edit-chore" data-id="${this._esc(it.chore.id)}" title="${this._esc(`${it.chore.name} · ${statusLabel(it.status)}`)}">
|
||
<i class="tm-bdot" aria-hidden="true"></i><span>${this._esc(it.chore.name)}${it.limit > 1 ? ` <small>${it.count}/${it.limit}</small>` : ""}</span>
|
||
<span class="tm-sr">${this._esc(statusLabel(it.status))}</span>
|
||
</button>`).join("") : `<span class="tm-board-none">–</span>`}</div>`;
|
||
}).join("")}
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="tm-board-legend">
|
||
${["done", "pending", "todo", "missed"].map(st => `<span class="tm-bchip tm-bchip-${st}"><i class="tm-bdot" aria-hidden="true"></i>${this._esc(statusLabel(st))}</span>`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderTodayWeek(kids) {
|
||
const history = this._state.today && this._state.today.history;
|
||
if (!history) return `<p class="tm-meta">${this._t("panel.today_board_unavailable")}</p>`;
|
||
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 `
|
||
<div class="tm-board-wrap">
|
||
<div class="tm-week" style="--days:${first.length || 7}">
|
||
<div></div>
|
||
${first.map(d => `<div class="tm-week-h">${this._esc(dayName(d.date))}</div>`).join("")}
|
||
${kids.map(k => `
|
||
<div class="tm-week-kid">${this._childAvatar(k, "tm-av-sm")}<span>${this._esc(k.name)}</span></div>
|
||
${(history[k.id] || []).map(d => {
|
||
if (d.due == null) return `<div class="tm-week-cell tm-week-none" title="${this._esc(this._t("panel.today_week_none"))}">–</div>`;
|
||
const pct = d.due ? Math.round((d.done / d.due) * 100) : 100;
|
||
return `<div class="tm-week-cell" style="--kc:${this._childColor(k.id)};--p:${pct}" title="${this._esc(`${dayName(d.date)} · ${d.done}/${d.due}`)}">${d.due ? `${d.done}/${d.due}` : "–"}</div>`;
|
||
}).join("")}
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
${gaps ? `<p class="tm-meta tm-week-hint">${this._t("panel.today_week_hint")}</p>` : ""}
|
||
`;
|
||
}
|
||
|
||
// -- 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 `<div class="tm-pal-empty">${this._t("panel.palette_no_results")}</div>`;
|
||
const hi = this._palette ? this._palette.hi : 0;
|
||
let last = null;
|
||
return results.map((c, i) => {
|
||
const head = c.group !== last ? `<div class="tm-pal-group">${this._esc(c.group)}</div>` : "";
|
||
last = c.group;
|
||
const attrs = Object.entries(c.data)
|
||
.map(([k, v]) => `data-${k}="${this._esc(v)}"`)
|
||
.join(" ");
|
||
return `${head}<button type="button" class="tm-pal-item ${i === hi ? "tm-pal-hi" : ""}" role="option" aria-selected="${i === hi ? "true" : "false"}" data-idx="${i}" ${attrs}>
|
||
<ha-icon icon="${this._esc(c.icon)}"></ha-icon><span>${this._esc(c.label)}</span>
|
||
</button>`;
|
||
}).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 `
|
||
<div class="tm-pal-scrim" data-act="palette-scrim">
|
||
<div class="tm-pal" role="dialog" aria-modal="true" aria-label="${this._esc(ph)}">
|
||
<div class="tm-pal-head">
|
||
<ha-icon icon="mdi:magnify"></ha-icon>
|
||
<input class="tm-pal-input" data-palette="q" placeholder="${this._esc(ph)}" value="${this._esc(this._palette.q)}" autocomplete="off" spellcheck="false" aria-label="${this._esc(ph)}">
|
||
<kbd>Esc</kbd>
|
||
</div>
|
||
<div class="tm-pal-list" role="listbox">${this._paletteListHtml()}</div>
|
||
<div class="tm-pal-foot">${this._t("panel.palette_hint")}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-wz-nudge" style="--kc:${this._childColor(hit.child.id)}">
|
||
${this._childAvatar(hit.child)}
|
||
<span class="tm-wz-nudge-text">${this._t("wizard.nudge_text", { name: `<strong>${this._esc(hit.child.name)}</strong>`, age: hit.age })}</span>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-raised" data-act="wz-nudge" data-id="${id}" data-v="${hit.group}">
|
||
<ha-icon icon="mdi:auto-fix"></ha-icon>${this._t("wizard.nudge_btn")}
|
||
</button>
|
||
<button type="button" class="tm-icon-btn" data-act="wz-nudge-dismiss" data-id="${id}" data-v="${hit.group}" title="${this._esc(this._t("wizard.nudge_dismiss"))}" aria-label="${this._esc(this._t("wizard.nudge_dismiss"))}">
|
||
<ha-icon icon="mdi:close"></ha-icon>
|
||
</button>
|
||
</div>`;
|
||
}
|
||
|
||
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 `<span class="tm-wz-kav ${off ? "tm-wz-kav-off" : ""}" style="--kc:${this._esc(kid.color)};--s:${size}px" title="${this._esc(kid.name)}"><ha-icon icon="${this._esc(kid.icon)}"></ha-icon></span>`;
|
||
}
|
||
|
||
_wizardUnit(n) {
|
||
return `<span class="tm-wz-unit">${this._fmtNum(n)}<ha-icon icon="${this._esc(this._wz.picon || "mdi:star")}"></ha-icon></span>`;
|
||
}
|
||
|
||
_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 ? `<i class="tm-wz-sep ${i <= step ? "tm-wz-done" : ""}"></i>` : ""}
|
||
<button type="button" class="tm-wz-step ${i === step ? "tm-wz-on" : i < step ? "tm-wz-done" : ""}" data-act="wz-go" data-v="${i}" ${last || i === 5 ? "disabled" : ""} ${i === step ? 'aria-current="step"' : ""}>
|
||
<span class="tm-wz-n">${i < step ? `<ha-icon icon="mdi:check"></ha-icon>` : i + 1}</span><span class="tm-wz-l">${this._t(`wizard.step_${id}`)}</span>
|
||
</button>`).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 `
|
||
<div class="tm-wz">
|
||
<div class="tm-wz-box">
|
||
<div class="tm-wz-top">
|
||
<span class="tm-wz-title"><ha-icon icon="mdi:auto-fix"></ha-icon>${this._t("wizard.title")}</span>
|
||
${last ? "" : `<button type="button" class="tm-icon-btn tm-wz-x" data-act="wz-close" title="${this._esc(this._t("wizard.close_hint"))}" aria-label="${this._esc(this._t("wizard.close_hint"))}"><ha-icon icon="mdi:close"></ha-icon></button>`}
|
||
</div>
|
||
<nav class="tm-wz-steps" aria-label="${this._esc(this._t("wizard.title"))}">
|
||
${steps}
|
||
<span class="tm-wz-mlabel">${this._t("wizard.step_of", { n: step + 1, total: WZ_STEPS.length, name: this._t(`wizard.step_${WZ_STEPS[step]}`) })}</span>
|
||
</nav>
|
||
<div class="tm-wz-body">${body}</div>
|
||
${last ? "" : `
|
||
<div class="tm-wz-foot">
|
||
${step
|
||
? `<button type="button" class="tm-btn" data-act="wz-back" ${wz.busy ? "disabled" : ""}><ha-icon class="tm-rtl-flip" icon="mdi:chevron-left"></ha-icon>${this._t("wizard.back")}</button>`
|
||
: `<button type="button" class="tm-btn tm-wz-text-btn" data-act="wz-close">${this._t("wizard.skip")}</button>`}
|
||
<span class="tm-wz-sp"></span>
|
||
${step === 0 && wz.mode === "fresh" ? `<button type="button" class="tm-btn tm-wz-text-btn" data-act="wz-restore">${this._t("wizard.restore")}</button>` : ""}
|
||
${step === 1 ? `<span class="tm-wz-foot-hint">${typed ? this._esc(this._t("wizard.foot_will_add", { name: typed })) : ""}</span>` : ""}
|
||
${step === 4
|
||
? `<button type="button" class="tm-btn tm-btn-raised tm-wz-create" data-act="wz-create" ${wz.busy || !this._wizardPlanHasWork(this._wizardPlan()) ? "disabled" : ""}>${wz.busy ? `<span class="tm-btn-spinner"></span>${this._t("wizard.creating")}` : this._t(wz.mode === "existing" ? "wizard.create_existing" : "wizard.create")}</button>`
|
||
: `<button type="button" class="tm-btn tm-btn-raised" data-act="wz-next">${this._t(nextKey)}<ha-icon class="tm-rtl-flip" icon="mdi:chevron-right"></ha-icon></button>`}
|
||
</div>`}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_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 ? `<div class="tm-wz-banner"><ha-icon icon="mdi:information-outline"></ha-icon><div>${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) })}</div></div>` : ""}
|
||
<div class="tm-wz-hero">
|
||
<span class="tm-wz-hero-ic"><ha-icon icon="mdi:auto-fix"></ha-icon></span>
|
||
<div>
|
||
<h2 class="tm-wz-h">${this._t(ex ? "wizard.welcome_title_existing" : "wizard.welcome_title")}</h2>
|
||
<p class="tm-wz-p tm-wz-p0">${this._t(ex ? "wizard.welcome_copy_existing" : "wizard.welcome_copy")}</p>
|
||
</div>
|
||
</div>
|
||
<div class="tm-wz-tiles">
|
||
${tiles.map(([icon, color, id], i) => `
|
||
<div class="tm-wz-tile"><span class="tm-wz-tile-ic" style="--c:${color}"><ha-icon icon="${icon}"></ha-icon></span>
|
||
<div><b>${i + 1} · ${this._t(`wizard.step_${id}`)}</b><span>${this._t(`wizard.tile_${id}`)}</span></div></div>`).join("")}
|
||
</div>
|
||
<div class="tm-wz-lbl tm-wz-lbl-lg">${this._t("wizard.pname_question")}</div>
|
||
<div class="tm-chip-row">
|
||
${WZ_POINT_NAMES.map(([k, icon]) => {
|
||
const on = !wz.pnameCustom && wz.pname === this._t(`wizard.pname_${k}`);
|
||
return `<button type="button" class="tm-chip-btn ${on ? "tm-chip-on" : ""}" data-act="wz-pname" data-v="${k}" aria-pressed="${on}"><ha-icon icon="${icon}"></ha-icon>${this._t(`wizard.pname_${k}`)}</button>`;
|
||
}).join("")}
|
||
<button type="button" class="tm-chip-btn ${wz.pnameCustom ? "tm-chip-on" : ""}" data-act="wz-pname" data-v="custom" aria-pressed="${wz.pnameCustom}"><ha-icon icon="mdi:pencil-outline"></ha-icon>${this._t("wizard.pname_custom")}</button>
|
||
</div>
|
||
${wz.pnameCustom ? `<input type="text" class="tm-input tm-wz-pname" data-wz-in="pname" maxlength="120" value="${this._esc(wz.pname)}" aria-label="${this._esc(this._t("wizard.pname_question"))}">` : ""}
|
||
<p class="tm-wz-hint">${this._t("wizard.pname_hint")}</p>`;
|
||
}
|
||
|
||
_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(`<span><ha-icon icon="mdi:cake-variant-outline"></ha-icon>${this._esc(when)} · ${this._t("wizard.age_n", { age })}</span>`);
|
||
} else if (k.mode === "age" && age != null) {
|
||
bits.push(`<span>${this._t("wizard.age_n", { age })}</span>`);
|
||
}
|
||
const band = this._wizardBand(k);
|
||
if (band) {
|
||
const g = this._wzCatalogue.age_groups.find(x => x.id === band) || {};
|
||
bits.push(`<span class="tm-wz-tag" style="--c:${this._esc(g.color || "#888")}">${this._t("wizard.group_chores", { group: this._wizardGroupLabel(band) })}</span>`);
|
||
}
|
||
return bits.join(`<span class="tm-wz-faint">·</span>`);
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-wz-kid">
|
||
${this._wizardKav(k)}
|
||
<div class="tm-wz-grow">
|
||
<div class="tm-wz-kid-name">${this._esc(k.name)}${k.existing ? `<span class="tm-pill tm-pill-success">${this._t("wizard.already_set_up")}</span>` : ""}</div>
|
||
<div class="tm-wz-kid-meta">${needsBand ? `<span class="tm-wz-warn"><ha-icon icon="mdi:information-outline"></ha-icon>${this._t("wizard.no_age_yet")}</span>` : this._wizardKidMeta(k)}</div>
|
||
${bandPick ? `<div class="tm-chip-row tm-wz-bandpick">${groups.map(g => `<button type="button" class="tm-chip-btn ${k.band === g.id ? "tm-chip-on" : ""}" data-act="wz-kband" data-id="${this._esc(k.key)}" data-v="${g.id}">${this._wizardGroupLabel(g.id)}</button>`).join("")}</div>` : ""}
|
||
</div>
|
||
${k.existing ? "" : `
|
||
<button type="button" class="tm-icon-btn" data-act="wz-edit-kid" data-id="${this._esc(k.key)}" title="${this._esc(this._t("panel.btn_edit"))}" aria-label="${this._esc(this._t("panel.btn_edit"))}"><ha-icon icon="mdi:pencil-outline"></ha-icon></button>
|
||
<button type="button" class="tm-icon-btn" data-act="wz-del-kid" data-id="${this._esc(k.key)}" title="${this._esc(this._t("wizard.remove"))}" aria-label="${this._esc(this._t("wizard.remove"))}"><ha-icon icon="mdi:delete-outline"></ha-icon></button>`}
|
||
</div>`;
|
||
}).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 = `<div class="tm-wz-inline"><input type="date" class="tm-input tm-wz-date" data-wz-in="bday" max="${today}" value="${this._esc(f.bday)}" aria-label="${this._esc(this._t("wizard.age_mode_bday"))}">
|
||
<span class="tm-meta tm-wz-age-now">${pa != null ? this._t("wizard.age_n", { age: pa }) : ""}</span></div>
|
||
<p class="tm-wz-hint">${this._t("wizard.bday_hint")}</p>`;
|
||
} else if (f.mode === "age") {
|
||
ageCtl = `<div class="tm-wz-stepper"><button type="button" data-act="wz-fage" data-d="-1" aria-label="−">−</button><span>${this._num(f.age)}</span><button type="button" data-act="wz-fage" data-d="1" aria-label="+">+</button></div>`;
|
||
} else {
|
||
ageCtl = `<div class="tm-chip-row">${groups.map(g => `<button type="button" class="tm-chip-btn ${f.band === g.id ? "tm-chip-on" : ""}" data-act="wz-fband" data-v="${g.id}">${this._wizardGroupLabel(g.id)}</button>`).join("")}</div>
|
||
<p class="tm-wz-hint">${this._t("wizard.group_hint")}</p>`;
|
||
}
|
||
const typed = (f.name || "").trim();
|
||
return `
|
||
<h2 class="tm-wz-h">${this._t("wizard.children_title")}</h2>
|
||
<p class="tm-wz-p">${this._t("wizard.children_copy")}</p>
|
||
${rows ? `<div class="tm-wz-kids">${rows}</div>` : ""}
|
||
<div class="tm-wz-add" style="--kc:${this._esc(f.color)}">
|
||
<div>
|
||
<h4><ha-icon icon="mdi:plus"></ha-icon>${this._t("wizard.add_child")}</h4>
|
||
<label class="tm-wz-lbl" for="tm-wz-name">${this._t("wizard.name")}</label>
|
||
<input id="tm-wz-name" type="text" class="tm-input tm-wz-full" data-wz-in="name" maxlength="120" placeholder="${this._esc(this._t("wizard.name_placeholder"))}" value="${this._esc(f.name)}">
|
||
<div class="tm-wz-lbl">${this._t("wizard.age")}</div>
|
||
<div class="tm-seg tm-wz-seg" role="tablist">${modes.map(([m, key]) => `<button type="button" role="tab" class="${f.mode === m ? "tm-seg-on" : ""}" aria-selected="${f.mode === m}" data-act="wz-fmode" data-v="${m}">${this._t(key)}</button>`).join("")}</div>
|
||
<div class="tm-wz-agectl">${ageCtl}</div>
|
||
<div class="tm-wz-lbl">${this._t("wizard.avatar")}</div>
|
||
<div class="tm-wz-icons">${WZ_KID_ICONS.map(ic => `<button type="button" class="${f.icon === ic ? "tm-wz-on" : ""}" data-act="wz-ficon" data-v="${ic}" aria-pressed="${f.icon === ic}"><ha-icon icon="${ic}"></ha-icon></button>`).join("")}</div>
|
||
<div class="tm-wz-lbl">${this._t("wizard.colour")}</div>
|
||
<div class="tm-wz-swatches">${WZ_KID_COLORS.map(c => `<button type="button" class="${f.color === c ? "tm-wz-on" : ""}" style="background:${c}" data-act="wz-fcolor" data-v="${c}" aria-pressed="${f.color === c}" aria-label="${c}"></button>`).join("")}</div>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-wz-addbtn" data-act="wz-add-kid" ${typed ? "" : "disabled"}><ha-icon icon="mdi:plus"></ha-icon><span>${this._esc(this._t("wizard.add_named", { name: typed || this._t("wizard.child_word") }))}</span></button>
|
||
</div>
|
||
<div>
|
||
<div class="tm-wz-lbl tm-wz-lbl-top">${this._t("wizard.preview")}</div>
|
||
<div class="tm-wz-mini" style="--hd:${this._esc(f.color)}">
|
||
<div class="tm-wz-mini-head">
|
||
<span class="tm-wz-mini-av"><ha-icon icon="${this._esc(f.icon)}"></ha-icon></span>
|
||
<div class="tm-wz-grow"><div class="tm-wz-mini-name">${this._esc(typed || this._t("wizard.new_child"))}</div>
|
||
<div class="tm-wz-mini-sub">${pa != null ? this._t("wizard.age_n", { age: pa }) : this._t("wizard.ready_for_chores")}</div></div>
|
||
<span class="tm-wz-mini-pts"><ha-icon icon="${this._esc(wz.picon)}"></ha-icon>0</span>
|
||
</div>
|
||
<div class="tm-wz-mini-body">
|
||
<div class="tm-wz-mini-row"><ha-icon icon="mdi:check"></ha-icon>${this._t("wizard.preview_chores")}</div>
|
||
<div class="tm-wz-mini-row tm-wz-dim"><ha-icon icon="mdi:gift-outline"></ha-icon>${this._t("wizard.preview_rewards")}</div>
|
||
</div>
|
||
</div>
|
||
<p class="tm-wz-hint">${this._t("wizard.preview_hint")}</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_wizardSuggestionRow(g, s) {
|
||
const c = this._wz.chores[s.id];
|
||
const name = this._wizardChoreName(s);
|
||
if (this._wizardHave("chores", name)) {
|
||
return `<div class="tm-wz-sg tm-wz-have" style="--bc:${this._esc(g.color)}"><span class="tm-wz-cbx"><ha-icon icon="mdi:check"></ha-icon></span>
|
||
<span class="tm-wz-ic"><ha-icon icon="${this._esc(s.icon)}"></ha-icon></span>
|
||
<div class="tm-wz-nm"><b>${this._esc(name)}</b><small>${this._t("wizard.have_chore")}</small></div>
|
||
<span class="tm-wz-who"></span><span class="tm-wz-pts"><span class="tm-pill tm-pill-success">${this._t("wizard.already_have")}</span></span></div>`;
|
||
}
|
||
const el = this._wizardEligible(g.id);
|
||
return `
|
||
<div class="tm-wz-sg ${c.on ? "tm-wz-on" : ""} ${el.length ? "" : "tm-wz-have"}" style="--bc:${this._esc(g.color)}" data-act="wz-sg" data-id="${s.id}" role="checkbox" aria-checked="${c.on}" tabindex="0">
|
||
<span class="tm-wz-cbx"><ha-icon icon="mdi:check"></ha-icon></span>
|
||
<span class="tm-wz-ic"><ha-icon icon="${this._esc(s.icon)}"></ha-icon></span>
|
||
<div class="tm-wz-nm"><b>${this._esc(name)}</b><small>${el.length ? this._esc(this._wizardFreqLabel(s)) : this._t("wizard.nobody_old_enough")}</small></div>
|
||
<span class="tm-wz-who">${el.map(k => `<button type="button" data-act="wz-sg-who" data-id="${s.id}" data-k="${this._esc(k.key)}" aria-pressed="${c.who.includes(k.key)}" title="${this._esc(k.name)}">${this._wizardKav(k, 26, !c.who.includes(k.key))}</button>`).join("")}</span>
|
||
<span class="tm-wz-pts"><span class="tm-wz-mstep"><button type="button" data-act="wz-sg-pts" data-id="${s.id}" data-d="-1" aria-label="−">−</button><span>${this._wizardUnit(c.pts)}</span><button type="button" data-act="wz-sg-pts" data-id="${s.id}" data-d="1" aria-label="+">+</button></span></span>
|
||
</div>`;
|
||
}
|
||
|
||
_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 `
|
||
<section class="tm-wz-band ${open ? "" : "tm-wz-band-empty"}">
|
||
<div class="tm-wz-band-h">
|
||
<span class="tm-wz-band-ic" style="background:${this._esc(g.color)}"><ha-icon icon="${this._esc(g.icon)}"></ha-icon></span>
|
||
<div class="tm-wz-grow">
|
||
<div class="tm-wz-band-age">${this._wizardGroupLabel(g.id)}${kids.length ? `<span class="tm-wz-stack">${kids.map(k => this._wizardKav(k, 22)).join("")}</span>` : ""}</div>
|
||
<div class="tm-wz-band-sub">${open ? this._t(`wizard.age_group_sub.${g.id}`) : this._t("wizard.nobody_in_group")}</div>
|
||
</div>
|
||
${open
|
||
? `<span class="tm-pill ${onN ? "tm-pill-accent" : ""}">${this._t("wizard.picked_n", { count: onN })}</span>
|
||
${this._wizardEligible(g.id).length ? `<button type="button" class="tm-wz-lnk" data-act="wz-band-all" data-v="${g.id}">${this._t(allOn ? "wizard.clear" : "wizard.pick_all")}</button>` : ""}`
|
||
: `<button type="button" class="tm-wz-lnk" data-act="wz-show-all">${this._t("wizard.show_anyway")}</button>`}
|
||
</div>
|
||
${open ? list.map(s => this._wizardSuggestionRow(g, s)).join("") : ""}
|
||
</section>`;
|
||
}).join("");
|
||
const packs = this._wizardPacks();
|
||
const packBands = packs.filter(p => wz.packs.includes(p.id)).map(p => `
|
||
<section class="tm-wz-band">
|
||
<div class="tm-wz-band-h">
|
||
<span class="tm-wz-band-ic" style="background:var(--tm-accent)"><ha-icon icon="${this._esc(p.icon || "mdi:clipboard-list")}"></ha-icon></span>
|
||
<div class="tm-wz-grow"><div class="tm-wz-band-age">${this._esc(this._wizardPackName(p))}<span class="tm-pill">${this._t("wizard.template_pack")}</span></div>
|
||
<div class="tm-wz-band-sub">${this._t("wizard.pack_sub")}</div></div>
|
||
<button type="button" class="tm-wz-lnk" data-act="wz-pack" data-v="${this._esc(p.id)}">${this._t("wizard.remove")}</button>
|
||
</div>
|
||
${(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 `<div class="tm-wz-sg ${dupe ? "tm-wz-have" : "tm-wz-on"} tm-wz-static" style="--bc:var(--tm-accent)">
|
||
<span class="tm-wz-cbx"><ha-icon icon="mdi:check"></ha-icon></span>
|
||
<span class="tm-wz-ic"><ha-icon icon="${this._esc(p.icon || "mdi:clipboard-list")}"></ha-icon></span>
|
||
<div class="tm-wz-nm"><b>${this._esc(name)}</b><small>${dupe ? this._t("wizard.pack_dupe") : this._esc(`${days} · ${this._t(`panel.time_${c.time_category || "anytime"}`)}`)}</small></div>
|
||
<span class="tm-wz-who">${wz.kids.map(k => this._wizardKav(k, 26)).join("")}</span>
|
||
<span class="tm-wz-pts">${this._wizardUnit(c.points || 0)}</span>
|
||
</div>`;
|
||
}).join("")}
|
||
</section>`).join("");
|
||
return `
|
||
<h2 class="tm-wz-h">${this._t("wizard.chores_title")}</h2>
|
||
<p class="tm-wz-p">${this._t("wizard.chores_copy")}</p>
|
||
<div class="tm-wz-cols">
|
||
<div>${bands}${packBands}</div>
|
||
<aside class="tm-wz-aside">
|
||
<div class="tm-wz-panel"><h4>${this._t("wizard.per_week")}</h4>
|
||
${wz.kids.map(k => { const w = this._wizardWeekly(k); return `
|
||
<div class="tm-wz-wk">${this._wizardKav(k, 28)}<div class="tm-wz-grow"><b>${this._esc(k.name)}</b><div class="tm-wz-faint">${this._wizardCount("chores", w.n)}</div></div>${this._wizardUnit(w.pts)}</div>`; }).join("")}
|
||
<p class="tm-wz-hint">${this._t("wizard.per_week_hint")}</p>
|
||
</div>
|
||
<div class="tm-wz-panel"><h4>${this._t("wizard.packs_title")}</h4>
|
||
${packs.map(p => { const on = wz.packs.includes(p.id); return `
|
||
<button type="button" class="tm-wz-pack ${on ? "tm-wz-on" : ""}" data-act="wz-pack" data-v="${this._esc(p.id)}" aria-pressed="${on}">
|
||
<span class="tm-wz-cbx"><ha-icon icon="mdi:check"></ha-icon></span><ha-icon icon="${this._esc(p.icon || "mdi:clipboard-list")}"></ha-icon>
|
||
<span class="tm-wz-grow">${this._esc(this._wizardPackName(p))}</span><span class="tm-wz-faint">${(p.chores || []).length}</span>
|
||
</button>`; }).join("")}
|
||
<p class="tm-wz-hint">${this._t("wizard.packs_hint")}</p>
|
||
</div>
|
||
</aside>
|
||
</div>`;
|
||
}
|
||
|
||
_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 `<div class="tm-wz-rw tm-wz-have"><div class="tm-wz-rw-top"><span class="tm-wz-ic"><ha-icon icon="${this._esc(icon)}"></ha-icon></span><b>${this._esc(name)}</b></div>
|
||
<div class="tm-wz-rw-foot"><span class="tm-pill tm-pill-success">${this._t("wizard.already_have")}</span></div></div>`;
|
||
return `<div class="tm-wz-rw ${pick.on ? "tm-wz-on" : ""}" data-act="${custom ? "wz-rw-custom-toggle" : "wz-rw"}" data-id="${this._esc(id)}" role="checkbox" aria-checked="${pick.on}" tabindex="0">
|
||
<span class="tm-wz-cbx"><ha-icon icon="mdi:check"></ha-icon></span>
|
||
<div class="tm-wz-rw-top"><span class="tm-wz-ic"><ha-icon icon="${this._esc(icon)}"></ha-icon></span><b>${this._esc(name)}</b></div>
|
||
<div class="tm-wz-rw-foot"><span class="tm-wz-mstep"><button type="button" data-act="wz-rw-cost" data-id="${this._esc(id)}" data-d="-5" aria-label="−">−</button><span>${this._wizardUnit(pick.cost)}</span><button type="button" data-act="wz-rw-cost" data-id="${this._esc(id)}" data-d="5" aria-label="+">+</button></span>
|
||
<span class="tm-wz-when">${this._esc(this._wizardEvery(pick.cost, top))}</span></div>
|
||
</div>`;
|
||
};
|
||
const tiers = this._wzCatalogue.reward_tiers.map(tier => `
|
||
<div class="tm-wz-tier">${this._t(`wizard.tier.${tier}`)}</div>
|
||
<div class="tm-wz-rw-grid">${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("")}</div>`).join("");
|
||
const custom = wz.custom.length ? `
|
||
<div class="tm-wz-tier">${this._t("wizard.tier.custom")}</div>
|
||
<div class="tm-wz-rw-grid">${wz.custom.map(r => card(r.key, r.name, "mdi:gift-outline", r, false, true)).join("")}</div>` : "";
|
||
const earn = top ? this._t("wizard.rewards_earns", {
|
||
name: this._esc(top[0].name), amount: `<b class="tm-wz-gold">${top[1]} ${this._esc(wz.pname)}</b>` }) : "";
|
||
return `
|
||
<h2 class="tm-wz-h">${this._t("wizard.rewards_title")}</h2>
|
||
<p class="tm-wz-p">${this._t("wizard.rewards_copy")} ${earn}</p>
|
||
<div class="tm-wz-cols">
|
||
<div>
|
||
${tiers}${custom}
|
||
<div class="tm-wz-rw-add">
|
||
<input type="text" class="tm-input" data-wz-in="rw-name" maxlength="200" placeholder="${this._esc(this._t("wizard.own_reward_placeholder"))}" value="${this._esc(wz.rwName)}" aria-label="${this._esc(this._t("wizard.own_reward_placeholder"))}">
|
||
<input type="number" class="tm-input tm-wz-cost" data-wz-in="rw-cost" min="0" step="5" value="${this._num(wz.rwCost, 20)}" aria-label="${this._esc(this._t("wizard.cost"))}">
|
||
<button type="button" class="tm-btn" data-act="wz-rw-add"><ha-icon icon="mdi:plus"></ha-icon>${this._t("wizard.own_reward_add")}</button>
|
||
</div>
|
||
</div>
|
||
<aside class="tm-wz-aside"><div class="tm-wz-panel"><h4>${this._t("wizard.tips")}</h4>
|
||
<ul class="tm-wz-tips"><li>${this._t("wizard.tip_1")}</li><li>${this._t("wizard.tip_2")}</li><li>${this._t("wizard.tip_3")}</li></ul></div></aside>
|
||
</div>`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-wz-rv-kid">
|
||
<div class="tm-wz-mini-head" style="--hd:${this._esc(k.color)}">
|
||
<span class="tm-wz-mini-av"><ha-icon icon="${this._esc(k.icon)}"></ha-icon></span>
|
||
<div class="tm-wz-grow"><div class="tm-wz-mini-name">${this._esc(k.name)}</div>
|
||
<div class="tm-wz-mini-sub">${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")}</div></div>
|
||
<span class="tm-wz-mini-pts">${this._t("wizard.per_week_short", { amount: w.pts })}<ha-icon icon="${this._esc(wz.picon)}"></ha-icon></span>
|
||
</div>
|
||
<ul>${names.slice(0, 5).map(n => `<li>${this._esc(n)}</li>`).join("") || `<li class="tm-wz-faint">${this._t("wizard.no_new_chores")}</li>`}</ul>
|
||
${names.length > 5 ? `<div class="tm-wz-more">${this._t("wizard.n_more", { count: names.length - 5 })}</div>` : ""}
|
||
</div>`;
|
||
}).join("");
|
||
const appr = [["none", "wizard.appr_none"], ["big", "wizard.appr_big"], ["all", "wizard.appr_all"]];
|
||
const sw = (act, on, title, hint) => `
|
||
<div class="tm-wz-tog"><div class="tm-wz-grow">${title}<small>${hint}</small></div>
|
||
<button type="button" class="tm-wz-sw ${on ? "tm-wz-on" : ""}" data-act="${act}" role="switch" aria-checked="${on}" aria-label="${this._esc(title)}"></button></div>`;
|
||
return `
|
||
<h2 class="tm-wz-h">${this._t("wizard.review_title")}</h2>
|
||
<p class="tm-wz-p">${intro}</p>
|
||
<div class="tm-wz-rv-kids">${kidCards}</div>
|
||
<div class="tm-wz-cols">
|
||
<div class="tm-wz-panel tm-wz-opts"><h4>${this._t("wizard.defaults")}</h4>
|
||
<div class="tm-wz-tog"><div class="tm-wz-grow">${this._t("wizard.appr_title")}<small>${this._t("wizard.appr_hint")}</small></div>
|
||
<div class="tm-seg tm-wz-seg" role="tablist">${appr.map(([v, key]) => `<button type="button" role="tab" class="${wz.approval === v ? "tm-seg-on" : ""}" aria-selected="${wz.approval === v}" data-act="wz-appr" data-v="${v}">${this._t(key)}</button>`).join("")}</div></div>
|
||
${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"))}
|
||
</div>
|
||
<div class="tm-wz-panel"><h4>${this._t("wizard.rewards_n", { count: plan.rewards.length })}</h4>
|
||
${plan.rewards.map(r => `<div class="tm-wz-wk tm-wz-wk-sm"><ha-icon icon="${this._esc(r.icon)}"></ha-icon><span class="tm-wz-grow">${this._esc(r.name)}</span>${this._wizardUnit(r.cost)}</div>`).join("") || `<p class="tm-wz-hint">${this._t("wizard.no_rewards")}</p>`}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_wizardStepDone() {
|
||
const wz = this._wz;
|
||
const r = wz.result || {};
|
||
const conf = Array.from({ length: 36 }, (_, i) =>
|
||
`<i style="inset-inline-start:${(i * 37) % 100}%;top:${(i * 53) % 60}%;background:${WZ_KID_COLORS[i % 8]};transform:rotate(${i * 29}deg)"></i>`).join("");
|
||
// Only what was actually added: never a row of zeros.
|
||
const phrase = (noun, n) => (n ? `<span>${this._wizardCount(noun, n)}</span>` : "");
|
||
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 `
|
||
<div class="tm-wz-finish">
|
||
<div class="tm-wz-confetti" aria-hidden="true">${conf}</div>
|
||
<div class="tm-wz-done-ic"><ha-icon icon="mdi:check"></ha-icon></div>
|
||
<h2 class="tm-wz-h tm-wz-h-big">${this._t("wizard.done_title")}</h2>
|
||
<p class="tm-wz-p tm-wz-center">${this._t(copy)}</p>
|
||
<div class="tm-wz-stats">
|
||
${phrase("children", r.children_added || 0)}
|
||
${phrase("chores", r.chores_added || 0)}
|
||
${phrase("rewards", r.rewards_added || 0)}
|
||
${r.dashboard === "created" ? `<span>${this._t("wizard.one_dashboard")}</span>` : ""}
|
||
</div>
|
||
${dashNote ? `<p class="tm-wz-hint tm-wz-center">${dashNote}</p>` : ""}
|
||
<div class="tm-wz-next">
|
||
<div><b><ha-icon icon="mdi:tablet"></ha-icon>${this._t("wizard.next_tablet_title")}</b>${this._t("wizard.next_tablet")}</div>
|
||
<div><b><ha-icon icon="mdi:home-outline"></ha-icon>${this._t("wizard.next_today_title")}</b>${this._t("wizard.next_today")}</div>
|
||
<div><b><ha-icon icon="mdi:auto-fix"></ha-icon>${this._t("wizard.next_change_title")}</b>${this._t("wizard.next_change")}</div>
|
||
</div>
|
||
<div class="tm-wz-done-acts">
|
||
${dash ? `<button type="button" class="tm-btn" data-act="wz-dashboard"><ha-icon icon="mdi:view-dashboard-outline"></ha-icon>${this._t("wizard.open_dashboard")}</button>` : ""}
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="wz-today">${this._t("wizard.go_today")}<ha-icon class="tm-rtl-flip" icon="mdi:chevron-right"></ha-icon></button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderWizardConfirmDialog() {
|
||
const d = this._dialog.data;
|
||
return this._dialogShell(this._t("wizard.confirm_title"),
|
||
`<p>${this._t("wizard.confirm_have", { children: this._wizardCount("children", d.children), chores: this._wizardCount("chores", d.chores), rewards: this._wizardCount("rewards", d.rewards) })}</p>
|
||
<ul class="tm-wz-confirm">
|
||
<li>${this._t("wizard.confirm_add")}</li>
|
||
<li>${this._t("wizard.confirm_have_shown")}</li>
|
||
<li>${this._t("wizard.confirm_never")}</li>
|
||
</ul>`,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="wz-open-confirmed"><ha-icon icon="mdi:auto-fix"></ha-icon>${this._t("wizard.open_btn")}</button>`);
|
||
}
|
||
|
||
// Settings → Setup wizard (#980), at the top of Settings.
|
||
_wizardSettingsSection() {
|
||
return `
|
||
<div class="tm-section tm-wz-settings">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label"><span class="tm-wz-settings-t"><ha-icon icon="mdi:auto-fix"></ha-icon>${this._t("wizard.title")}</span><small>${this._t("wizard.settings_hint")}</small></div>
|
||
<div><button type="button" class="tm-btn tm-btn-raised" data-act="wz-open"><ha-icon icon="mdi:auto-fix"></ha-icon>${this._t("wizard.run_btn")}</button></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// The Today page with no children yet: start the wizard or go it alone.
|
||
_wizardTodayEmpty() {
|
||
return `
|
||
<div class="tm-card tm-wz-empty">
|
||
<div class="tm-wz-empty-ic"><ha-icon icon="mdi:auto-fix"></ha-icon></div>
|
||
<h2>${this._t("wizard.empty_title")}</h2>
|
||
<p>${this._t("wizard.empty_copy")}</p>
|
||
<div class="tm-wz-empty-acts">
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="wz-open"><ha-icon icon="mdi:auto-fix"></ha-icon>${this._t("wizard.start_btn")}</button>
|
||
<button type="button" class="tm-btn" data-act="add-child"><ha-icon icon="mdi:plus"></ha-icon>${this._t("wizard.add_child_myself")}</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_wizardStyles() {
|
||
return `<style>
|
||
/* Setup wizard (#980) */
|
||
.tm-wz { container: tm-wz / inline-size; max-width: 1020px; margin: 0 auto; }
|
||
.tm-wz-box { background: var(--tm-surface-0); border: 1px solid var(--tm-border); border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; }
|
||
.tm-wz-top { display: flex; align-items: center; gap: 10px; padding: 14px 20px 0; }
|
||
.tm-wz-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 15px; flex: 1; }
|
||
.tm-wz-title ha-icon { color: var(--tm-accent); --mdc-icon-size: 18px; }
|
||
.tm-wz-x ha-icon { --mdc-icon-size: 20px; }
|
||
.tm-wz-steps { display: flex; align-items: center; gap: 8px; padding: 14px 20px; border-bottom: 1px solid var(--tm-border-soft); }
|
||
.tm-wz-step { display: flex; align-items: center; gap: 8px; color: var(--tm-text-faint); font: 500 13px/1.3 inherit; font-family: inherit; white-space: nowrap; background: none; border: 0; padding: 0; cursor: pointer; }
|
||
.tm-wz-step:disabled { cursor: default; }
|
||
.tm-wz-n { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--tm-border-strong); display: grid; place-items: center; font-size: 11.5px; font-weight: 700; flex: none; --mdc-icon-size: 14px; }
|
||
.tm-wz-step.tm-wz-on { color: var(--tm-text); }
|
||
.tm-wz-step.tm-wz-on .tm-wz-n { border-color: var(--tm-accent); background: var(--tm-accent); color: #fff; }
|
||
.tm-wz-step.tm-wz-done { color: var(--tm-text-muted); }
|
||
.tm-wz-step.tm-wz-done .tm-wz-n { border-color: var(--tm-positive); background: var(--tm-positive); color: #fff; }
|
||
.tm-wz-sep { flex: 1; height: 2px; min-width: 10px; background: var(--tm-border); border-radius: 2px; }
|
||
.tm-wz-sep.tm-wz-done { background: var(--tm-positive); }
|
||
.tm-wz-mlabel { display: none; font-size: 12.5px; color: var(--tm-text-muted); }
|
||
.tm-wz-body { padding: 26px 28px; min-height: 480px; }
|
||
.tm-wz-foot { display: flex; gap: 10px; align-items: center; padding: 14px 20px; border-top: 1px solid var(--tm-border-soft); background: var(--tm-surface-2); }
|
||
.tm-wz-foot .tm-btn ha-icon { --mdc-icon-size: 18px; }
|
||
.tm-wz-sp { flex: 1; }
|
||
.tm-wz-text-btn { border-color: transparent; background: none; color: var(--tm-text-muted); }
|
||
.tm-wz-text-btn:hover { color: var(--tm-text); background: none; border-color: transparent; }
|
||
.tm-wz-foot-hint { font-size: 12.5px; color: var(--tm-text-faint); }
|
||
.tm-wz-create { background: var(--tm-positive); border-color: var(--tm-positive); }
|
||
.tm-wz-create:hover { background: color-mix(in srgb, var(--tm-positive), #000 12%); border-color: color-mix(in srgb, var(--tm-positive), #000 12%); }
|
||
.tm-wz-h { font-size: 22px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; }
|
||
.tm-wz-h-big { font-size: 26px; }
|
||
.tm-wz-p { color: var(--tm-text-muted); margin: 0 0 20px; max-width: 680px; }
|
||
.tm-wz-p0 { margin: 0; }
|
||
.tm-wz-center { margin-inline: auto; text-align: center; }
|
||
.tm-wz-hint { font-size: 12.5px; color: var(--tm-text-faint); margin: 6px 0 0; }
|
||
.tm-wz-faint { color: var(--tm-text-faint); font-size: 12px; }
|
||
.tm-wz-gold { color: var(--tm-gold); }
|
||
.tm-wz-grow { flex: 1; min-width: 0; }
|
||
.tm-wz-lbl { display: block; font-size: 12.5px; font-weight: 600; color: var(--tm-text-muted); margin: 14px 0 6px; }
|
||
.tm-wz-lbl-lg { font-size: 14px; color: var(--tm-text); }
|
||
.tm-wz-lbl-top { margin-top: 0; }
|
||
.tm-wz-full { width: 100%; box-sizing: border-box; }
|
||
.tm-wz-pname { margin-top: 10px; max-width: 260px; }
|
||
.tm-wz-banner { display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px; border-radius: 12px; background: var(--tm-accent-soft); border: 1px solid var(--tm-accent-border); margin-bottom: 18px; font-size: 13.5px; }
|
||
.tm-wz-banner ha-icon { color: var(--tm-accent); flex: none; --mdc-icon-size: 18px; }
|
||
.tm-wz-hero { display: flex; gap: 18px; align-items: center; margin-bottom: 22px; }
|
||
.tm-wz-hero-ic { width: 72px; height: 72px; border-radius: 20px; background: linear-gradient(135deg, #03a9f4, #9b59b6); display: grid; place-items: center; color: #fff; flex: none; --mdc-icon-size: 36px; }
|
||
.tm-wz-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 24px; }
|
||
.tm-wz-tile { background: var(--tm-bg); border: 1px solid var(--tm-border); border-radius: 12px; padding: 14px; display: flex; gap: 12px; align-items: flex-start; }
|
||
.tm-wz-tile b { display: block; margin-bottom: 2px; }
|
||
.tm-wz-tile span:not(.tm-wz-tile-ic) { font-size: 12.5px; color: var(--tm-text-muted); }
|
||
.tm-wz-tile-ic { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; flex: none; background: color-mix(in srgb, var(--c), transparent 82%); color: var(--c); --mdc-icon-size: 18px; }
|
||
.tm-wz .tm-chip-btn { display: inline-flex; align-items: center; gap: 5px; }
|
||
.tm-wz .tm-chip-btn ha-icon { --mdc-icon-size: 14px; }
|
||
/* Children */
|
||
.tm-wz-kids { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
||
.tm-wz-kid { display: flex; align-items: center; gap: 14px; padding: 10px 14px; border: 1px solid var(--tm-border); border-radius: 12px; background: var(--tm-bg); }
|
||
.tm-wz-kid-name { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.tm-wz-kid-meta { font-size: 12.5px; color: var(--tm-text-muted); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 2px; }
|
||
.tm-wz-kid-meta ha-icon { --mdc-icon-size: 14px; margin-inline-end: 4px; vertical-align: -2px; }
|
||
.tm-wz-warn { color: var(--tm-warning); }
|
||
.tm-wz-bandpick { margin-top: 8px; }
|
||
.tm-wz-tag { font-size: 11.5px; font-weight: 700; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--c), transparent 78%); color: var(--tm-text); white-space: nowrap; }
|
||
.tm-wz-kav { width: var(--s, 44px); height: var(--s, 44px); border-radius: 50%; background: var(--kc); display: grid; place-items: center; color: #fff; flex: none; box-sizing: border-box; border: 2px solid rgba(255, 255, 255, 0.25); --mdc-icon-size: calc(var(--s, 44px) * 0.5); }
|
||
.tm-wz-kav-off { opacity: 0.3; filter: grayscale(0.6); }
|
||
.tm-wz-add { border: 1px dashed var(--tm-border-strong); border-radius: 14px; padding: 16px; display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 20px; }
|
||
.tm-wz-add h4 { margin: 0 0 4px; font-size: 14px; display: flex; align-items: center; gap: 6px; }
|
||
.tm-wz-add h4 ha-icon { --mdc-icon-size: 16px; }
|
||
.tm-wz-seg { margin-inline-start: 0; }
|
||
.tm-wz-agectl { margin-top: 10px; }
|
||
.tm-wz-inline { display: flex; align-items: center; gap: 12px; }
|
||
.tm-wz-date { width: 200px; }
|
||
.tm-wz-stepper { display: inline-flex; align-items: center; border: 1px solid var(--tm-border); border-radius: 8px; overflow: hidden; }
|
||
.tm-wz-stepper button { width: 34px; height: 34px; border: 0; background: var(--tm-surface-2); color: var(--tm-text); cursor: pointer; font-size: 18px; font-family: inherit; }
|
||
.tm-wz-stepper span { min-width: 54px; text-align: center; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
.tm-wz-icons { display: grid; grid-template-columns: repeat(8, 1fr); gap: 6px; }
|
||
.tm-wz-icons button { height: 36px; border-radius: 9px; border: 1px solid var(--tm-border); background: var(--tm-bg); color: var(--tm-text-muted); display: grid; place-items: center; cursor: pointer; --mdc-icon-size: 18px; }
|
||
.tm-wz-icons button.tm-wz-on { border-color: var(--kc); background: color-mix(in srgb, var(--kc), transparent 80%); color: var(--tm-text); }
|
||
.tm-wz-swatches { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.tm-wz-swatches button { width: 28px; height: 28px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; }
|
||
.tm-wz-swatches button.tm-wz-on { box-shadow: 0 0 0 2px var(--tm-surface-0), 0 0 0 4px var(--tm-text); }
|
||
.tm-wz-addbtn { margin-top: 16px; }
|
||
.tm-wz-mini, .tm-wz-rv-kid { border-radius: 12px; overflow: hidden; background: var(--tm-surface-0); border: 1px solid var(--tm-border); }
|
||
.tm-wz-mini-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--hd); color: #fff; }
|
||
.tm-wz-mini-av { width: 40px; height: 40px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); border: 2px solid rgba(255, 255, 255, 0.35); display: grid; place-items: center; flex: none; --mdc-icon-size: 22px; box-sizing: border-box; }
|
||
.tm-wz-mini-name { font-weight: 700; font-size: 1.1rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tm-wz-mini-sub { font-size: 12.5px; opacity: 0.9; }
|
||
.tm-wz-mini-pts { display: inline-flex; align-items: center; gap: 4px; background: rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.28); padding: 4px 10px; border-radius: 20px; font-weight: 800; white-space: nowrap; --mdc-icon-size: 15px; }
|
||
.tm-wz-mini-pts ha-icon { color: #ffd54f; }
|
||
.tm-wz-mini-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
|
||
.tm-wz-mini-row { height: 34px; border-radius: 12px; border: 2px solid color-mix(in srgb, var(--kc), transparent 30%); background: color-mix(in srgb, var(--kc), transparent 92%); display: flex; align-items: center; padding: 0 10px; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--tm-text-muted); --mdc-icon-size: 14px; }
|
||
.tm-wz-dim { opacity: 0.6; }
|
||
/* Chores */
|
||
.tm-wz-cols { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 22px; align-items: start; }
|
||
.tm-wz-aside { position: sticky; top: 0; display: flex; flex-direction: column; gap: 14px; }
|
||
.tm-wz-panel { background: var(--tm-bg); border: 1px solid var(--tm-border); border-radius: 12px; padding: 14px; }
|
||
.tm-wz-panel h4 { margin: 0 0 10px; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--tm-text-muted); }
|
||
.tm-wz-band { border: 1px solid var(--tm-border); border-radius: 14px; margin-bottom: 14px; overflow: hidden; background: var(--tm-bg); }
|
||
.tm-wz-band-h { display: flex; align-items: center; gap: 12px; padding: 12px 14px; background: var(--tm-surface-2); border-bottom: 1px solid var(--tm-border-soft); }
|
||
.tm-wz-band-empty .tm-wz-band-h { border-bottom: 0; }
|
||
.tm-wz-band-age { font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.tm-wz-band-sub { font-size: 12.5px; color: var(--tm-text-muted); }
|
||
.tm-wz-band-ic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; flex: none; --mdc-icon-size: 20px; }
|
||
.tm-wz-stack { display: inline-flex; }
|
||
.tm-wz-stack .tm-wz-kav + .tm-wz-kav { margin-inline-start: -8px; }
|
||
.tm-wz-lnk { background: none; border: 0; color: var(--tm-accent); font: inherit; font-size: 12.5px; cursor: pointer; white-space: nowrap; padding: 4px; }
|
||
.tm-wz-sg { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto auto; grid-template-areas: "cb ic nm who pts"; gap: 12px; align-items: center; padding: 9px 14px; border-top: 1px solid var(--tm-border-soft); cursor: pointer; }
|
||
.tm-wz-band-h + .tm-wz-sg { border-top: 0; }
|
||
.tm-wz-sg.tm-wz-on { background: color-mix(in srgb, var(--tm-accent), transparent 94%); }
|
||
.tm-wz-cbx { grid-area: cb; width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--tm-border-strong); display: grid; place-items: center; color: transparent; background: var(--tm-surface-0); box-sizing: border-box; --mdc-icon-size: 14px; flex: none; }
|
||
.tm-wz-on > .tm-wz-cbx { background: var(--tm-accent); border-color: var(--tm-accent); color: #fff; }
|
||
.tm-wz-sg .tm-wz-ic { grid-area: ic; width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; background: var(--tm-surface-2); color: var(--tm-text-muted); --mdc-icon-size: 18px; }
|
||
.tm-wz-sg.tm-wz-on .tm-wz-ic { color: #fff; background: var(--bc); }
|
||
.tm-wz-nm { grid-area: nm; min-width: 0; }
|
||
.tm-wz-nm b { font-weight: 600; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.tm-wz-nm small { font-size: 12px; color: var(--tm-text-muted); }
|
||
.tm-wz-who { grid-area: who; display: flex; gap: 4px; }
|
||
.tm-wz-who button { padding: 0; border: 0; background: none; cursor: pointer; }
|
||
.tm-wz-pts { grid-area: pts; }
|
||
.tm-wz-have, .tm-wz-static { cursor: default; }
|
||
.tm-wz-have { opacity: 0.5; }
|
||
.tm-wz-have .tm-wz-cbx { visibility: hidden; }
|
||
.tm-wz-mstep { display: inline-flex; align-items: center; border: 1px solid var(--tm-border); border-radius: 8px; overflow: hidden; background: var(--tm-surface-0); }
|
||
.tm-wz-mstep button { width: 26px; height: 28px; border: 0; background: var(--tm-surface-2); color: var(--tm-text); cursor: pointer; font-size: 15px; font-family: inherit; }
|
||
.tm-wz-mstep > span { min-width: 48px; text-align: center; }
|
||
.tm-wz-unit { display: inline-flex; align-items: center; gap: 3px; font-weight: 700; font-size: 13px; color: var(--tm-gold); font-variant-numeric: tabular-nums; white-space: nowrap; --mdc-icon-size: 13px; }
|
||
.tm-wz-wk { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--tm-border-soft); }
|
||
.tm-wz-wk:first-of-type { border-top: 0; }
|
||
.tm-wz-wk-sm { padding: 6px 0; --mdc-icon-size: 16px; }
|
||
.tm-wz-pack { display: flex; align-items: center; gap: 10px; padding: 7px 0; font: inherit; font-size: 13px; cursor: pointer; width: 100%; background: none; border: 0; color: var(--tm-text); text-align: start; --mdc-icon-size: 16px; }
|
||
.tm-wz-pack > ha-icon { color: var(--tm-accent); }
|
||
.tm-wz-pack .tm-wz-cbx { width: 18px; height: 18px; border-radius: 5px; --mdc-icon-size: 12px; }
|
||
/* Rewards */
|
||
.tm-wz-tier { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--tm-text-muted); margin: 16px 0 8px; }
|
||
.tm-wz-tier:first-child { margin-top: 0; }
|
||
.tm-wz-rw-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||
.tm-wz-rw { border: 1px solid var(--tm-border); border-radius: 14px; background: var(--tm-bg); padding: 14px; display: flex; flex-direction: column; gap: 10px; cursor: pointer; position: relative; }
|
||
.tm-wz-rw.tm-wz-on { border-color: #9b59b6; background: color-mix(in srgb, #9b59b6 9%, var(--tm-bg)); }
|
||
.tm-wz-rw-top { display: flex; gap: 10px; align-items: center; padding-inline-end: 26px; }
|
||
.tm-wz-rw-top b { font-size: 14px; line-height: 1.25; }
|
||
.tm-wz-rw .tm-wz-ic { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: var(--tm-surface-2); color: var(--tm-text-muted); flex: none; --mdc-icon-size: 20px; }
|
||
.tm-wz-rw.tm-wz-on .tm-wz-ic { background: linear-gradient(135deg, #9b59b6, #8e44ad); color: #fff; }
|
||
.tm-wz-rw > .tm-wz-cbx { position: absolute; top: 10px; inset-inline-end: 10px; width: 20px; height: 20px; }
|
||
.tm-wz-rw.tm-wz-on > .tm-wz-cbx { background: #9b59b6; border-color: #9b59b6; }
|
||
.tm-wz-rw-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||
.tm-wz-when { font-size: 11.5px; color: var(--tm-text-faint); white-space: nowrap; }
|
||
.tm-wz-rw-add { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||
.tm-wz-rw-add .tm-input { flex: 1; min-width: 160px; }
|
||
.tm-wz-rw-add .tm-wz-cost { flex: 0 0 90px; min-width: 0; }
|
||
.tm-wz-tips { margin: 0; padding-inline-start: 18px; font-size: 12.5px; color: var(--tm-text-muted); line-height: 1.6; }
|
||
/* Review */
|
||
.tm-wz-rv-kids { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||
.tm-wz-rv-kid .tm-wz-mini-av { width: 36px; height: 36px; --mdc-icon-size: 20px; }
|
||
.tm-wz-rv-kid .tm-wz-mini-name { font-size: 1.05rem; }
|
||
.tm-wz-rv-kid ul { margin: 0; padding-block: 10px 12px; padding-inline: 30px 14px; font-size: 12.5px; color: var(--tm-text-muted); line-height: 1.6; }
|
||
.tm-wz-more { font-size: 12px; color: var(--tm-text-faint); padding: 0 14px 12px; }
|
||
.tm-wz-tog { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-top: 1px solid var(--tm-border-soft); font-size: 13.5px; flex-wrap: wrap; }
|
||
.tm-wz-opts h4 + .tm-wz-tog { border-top: 0; }
|
||
.tm-wz-tog small { display: block; color: var(--tm-text-faint); font-size: 12px; }
|
||
.tm-wz-sw { width: 36px; height: 20px; border-radius: 999px; background: var(--tm-border-strong); position: relative; cursor: pointer; flex: none; border: 0; padding: 0; }
|
||
.tm-wz-sw::after { content: ""; position: absolute; top: 2px; inset-inline-start: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: inset-inline-start 0.15s; }
|
||
.tm-wz-sw.tm-wz-on { background: var(--tm-accent); }
|
||
.tm-wz-sw.tm-wz-on::after { inset-inline-start: 18px; }
|
||
/* Done */
|
||
.tm-wz-finish { text-align: center; padding: 26px 10px 10px; position: relative; }
|
||
.tm-wz-done-ic { width: 92px; height: 92px; border-radius: 50%; margin: 0 auto 16px; display: grid; place-items: center; background: linear-gradient(135deg, #2ecc71, #16a085); color: #fff; box-shadow: 0 10px 30px rgba(46, 204, 113, 0.35); --mdc-icon-size: 48px; position: relative; }
|
||
.tm-wz-confetti { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||
.tm-wz-confetti i { position: absolute; width: 9px; height: 14px; border-radius: 2px; opacity: 0.7; }
|
||
.tm-wz-finish > :not(.tm-wz-confetti) { position: relative; }
|
||
.tm-wz-stats { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin: 14px 0 24px; position: relative; }
|
||
.tm-wz-stats span { background: var(--tm-bg); border: 1px solid var(--tm-border); border-radius: 999px; padding: 6px 14px; font-size: 13px; }
|
||
.tm-wz-next { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; text-align: start; max-width: 860px; margin: 0 auto; position: relative; }
|
||
.tm-wz-next div { background: var(--tm-bg); border: 1px solid var(--tm-border); border-radius: 12px; padding: 14px; font-size: 13px; color: var(--tm-text-muted); }
|
||
.tm-wz-next b { display: flex; gap: 8px; align-items: center; color: var(--tm-text); margin-bottom: 4px; font-size: 14px; --mdc-icon-size: 18px; }
|
||
.tm-wz-next b ha-icon { color: var(--tm-accent); }
|
||
.tm-wz-done-acts { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-top: 26px; position: relative; }
|
||
/* Entry points */
|
||
.tm-wz-empty { text-align: center; padding: 50px 20px; }
|
||
.tm-wz-empty-ic { width: 84px; height: 84px; border-radius: 24px; margin: 0 auto 16px; background: linear-gradient(135deg, #03a9f4, #9b59b6); display: grid; place-items: center; color: #fff; --mdc-icon-size: 40px; }
|
||
.tm-wz-empty h2 { margin: 0 0 6px; font-size: 22px; }
|
||
.tm-wz-empty p { color: var(--tm-text-muted); margin: 0 auto 20px; max-width: 520px; }
|
||
.tm-wz-empty-acts { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
|
||
.tm-wz-settings { border-color: var(--tm-accent-border); }
|
||
.tm-wz-settings-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; --mdc-icon-size: 16px; }
|
||
.tm-wz-settings-t ha-icon { color: var(--tm-accent); }
|
||
.tm-wz-confirm { margin: 8px 0 0; padding-inline-start: 20px; line-height: 1.7; }
|
||
.tm-wz-nudge { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; margin-bottom: 16px; border-radius: 12px; background: var(--tm-accent-soft); border: 1px solid var(--tm-accent-border); }
|
||
.tm-wz-nudge-text { flex: 1; min-width: 180px; }
|
||
.tm-wz-nudge .tm-btn ha-icon { --mdc-icon-size: 16px; }
|
||
@container tm-wz (max-width: 640px) {
|
||
.tm-wz-step .tm-wz-l, .tm-wz-sep { display: none; }
|
||
.tm-wz-steps { gap: 6px; }
|
||
.tm-wz-mlabel { display: block; margin-inline-start: auto; }
|
||
.tm-wz-body { padding: 18px 14px; min-height: 0; }
|
||
.tm-wz-h { font-size: 19px; }
|
||
.tm-wz-cols, .tm-wz-add { grid-template-columns: 1fr; }
|
||
.tm-wz-aside { position: static; }
|
||
.tm-wz-tiles { grid-template-columns: 1fr; }
|
||
.tm-wz-hero-ic { width: 54px; height: 54px; --mdc-icon-size: 28px; }
|
||
.tm-wz-rw-grid { grid-template-columns: 1fr; }
|
||
.tm-wz-sg { grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: "cb ic nm pts" ". . who who"; row-gap: 6px; padding: 9px 10px; }
|
||
.tm-wz-next { grid-template-columns: 1fr; }
|
||
.tm-wz-foot { padding: 12px 14px; flex-wrap: wrap; }
|
||
.tm-wz-icons { grid-template-columns: repeat(6, 1fr); }
|
||
.tm-wz-band-sub { display: none; }
|
||
.tm-wz-date { width: 170px; }
|
||
}
|
||
</style>`;
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.chore_title")} <span class="tm-toolbar-count">${all.length}</span></h2>
|
||
${all.length > 0 ? this._searchBox(this._t("panel.search_chores")) : ""}
|
||
<button type="button" class="tm-btn" data-act="reorder-chores-global">${this._t("panel.btn_reorder_chores")}</button>
|
||
<button type="button" class="tm-btn" data-act="bulk-add-chore">${this._t("panel.btn_bulk_add")}</button>
|
||
<button type="button" class="tm-btn" data-act="tpl-add-from">${this._t("panel.btn_from_template")}</button>
|
||
<button type="button" class="tm-btn" data-act="tpl-save-from">${this._t("panel.btn_save_as_template")}</button>
|
||
${all.length > 0 ? `<button type="button" class="tm-btn ${this._bulkMode ? "tm-btn-on" : ""}" data-act="bulk-toggle">${this._bulkMode ? this._t("panel.bulk_done") : this._t("panel.bulk_select")}</button>` : ""}
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-chore">${this._t("panel.btn_add_chore")}</button>
|
||
</div>
|
||
${this._bulkMode && all.length > 0 ? `
|
||
<div class="tm-bulk-bar">
|
||
<span class="tm-bulk-count">${this._t("panel.bulk_selected", { count: this._bulkSel.size })}</span>
|
||
<button type="button" class="tm-btn" data-act="bulk-enable" ${this._bulkSel.size ? "" : "disabled"}>${this._t("panel.bulk_enable")}</button>
|
||
<button type="button" class="tm-btn" data-act="bulk-disable" ${this._bulkSel.size ? "" : "disabled"}>${this._t("panel.bulk_disable")}</button>
|
||
<button type="button" class="tm-btn" data-act="bulk-delete" ${this._bulkSel.size ? "" : "disabled"}>${this._t("panel.bulk_delete")}</button>
|
||
<select class="tm-select" data-role="bulk-reassign">
|
||
<option value="">${this._t("panel.bulk_reassign_to")}</option>
|
||
<option value="__all__">${this._t("panel.common_all_children")}</option>
|
||
${(this._state.children || []).map(c => `<option value="${this._esc(c.id)}">${this._esc(c.name)}</option>`).join("")}
|
||
</select>
|
||
<button type="button" class="tm-btn" data-act="bulk-reassign" ${this._bulkSel.size ? "" : "disabled"}>${this._t("panel.bulk_apply")}</button>
|
||
</div>` : ""}
|
||
${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 ? `<div class="tm-card tm-empty"><p>${this._t("panel.empty_no_match", {kind: this._t("panel.tab_chores").toLowerCase(), filter: this._esc(this._filter)})}</p></div>` : `
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr>
|
||
${this._bulkMode ? "<th></th>" : ""}<th class="${this._bulkMode ? "" : "tm-col-sticky"}">${this._t("panel.chore_table_name")}</th><th>${this._t("panel.chore_table_points")}</th><th>${this._t("panel.chore_table_period")}</th><th>${this._t("panel.chore_table_assigned")}</th><th>${this._t("panel.chore_table_current")}</th><th>${this._t("panel.chore_table_schedule")}</th><th>${this._t("panel.chore_table_approval")}</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${chores.map(c => this._renderChoreRow(c, childById)).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p class="tm-meta tm-table-hint">${this._t("panel.chore_tip_rename")}</p>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
_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"
|
||
? `<span class="tm-text-muted">${this._t("panel.assign_unassigned_short")}</span>`
|
||
: (c.assigned_to || []).length === 0
|
||
? `<span class="tm-text-muted">${this._t("panel.common_all_children")}</span>`
|
||
: this._esc((c.assigned_to || []).map(id => (childById[id] && childById[id].name) || "?").join(", "));
|
||
const currentName = isRotation && curChild && childById[curChild]
|
||
? `<strong>${this._esc(childById[curChild].name)}</strong>`
|
||
: isRotation ? `<span class="tm-text-muted">—</span>` : "";
|
||
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
|
||
? ` <span class="tm-pill tm-pill-accent">${this._t("panel.chore_weekly_target_pill", { count: Number(c.weekly_target) })}</span>`
|
||
: "";
|
||
const teamPill = Number(c.team_size) >= 2
|
||
? ` <span class="tm-pill tm-pill-accent">${this._t("panel.chore_team_pill", { count: Number(c.team_size) })}</span>`
|
||
: "";
|
||
const modeBadge = c.assignment_mode && c.assignment_mode !== "everyone"
|
||
? `<span class="tm-pill tm-pill-${this._esc(c.assignment_mode)}">${this._t(`panel.assign_${c.assignment_mode}_short`)}</span>` : "";
|
||
const nameCell = renaming
|
||
? `<input class="tm-inline-input" type="text" data-field="_inlineRename" value="${this._esc(this._inlineRename.value)}" autofocus>
|
||
<button type="button" class="tm-icon-btn" data-act="rename-chore-commit" title="${this._t("panel.tooltip_save")}">✓</button>
|
||
<button type="button" class="tm-icon-btn" data-act="rename-chore-cancel" title="${this._t("panel.tooltip_cancel")}">✕</button>`
|
||
: `<strong data-rename="chore" data-id="${this._esc(c.id)}" title="${this._t("panel.tooltip_rename")}">${this._esc(c.name)}</strong>${c.enabled === false ? ` <span class="tm-pill">${this._t("panel.common_disabled")}</span>` : ""}${this._idBadge(c.id)}`;
|
||
return `
|
||
<tr class="tm-row ${c.enabled === false ? "tm-row-disabled" : ""}">
|
||
${this._bulkMode ? `<td><input type="checkbox" data-act="bulk-toggle-row" data-id="${this._esc(c.id)}" ${this._bulkSel.has(c.id) ? "checked" : ""}></td>` : ""}
|
||
<td class="${this._bulkMode ? "" : "tm-col-sticky"}"><div class="tm-name-cell">
|
||
<span class="tm-name-main">${nameCell}</span>
|
||
${renaming ? "" : `<span class="tm-name-actions">
|
||
<button type="button" class="tm-icon-btn" data-act="edit-chore" data-id="${this._esc(c.id)}" title="${this._t("panel.btn_edit")}">✏</button>
|
||
<button type="button" class="tm-icon-btn" data-act="toggle-row-menu" data-id="${this._esc(c.id)}" title="${this._t("panel.row_menu_more")}" aria-haspopup="menu" aria-label="${this._t("panel.row_menu_more")}">⋮</button>
|
||
</span>`}
|
||
</div></td>
|
||
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${this._num(c.timed_rate_points)}/${this._num(c.timed_rate_minutes, 1)} min` : this._num(c.points)}</strong></td>
|
||
<td><span class="tm-pill">${this._esc(this._timeCategoryLabel(c.time_category))}</span></td>
|
||
<td>${assignedNames} ${modeBadge}${teamPill}</td>
|
||
<td>${currentName}</td>
|
||
<td><span class="tm-pill ${schedClass} tm-pill-dot">${this._esc(schedLabel)}</span>${weeklyPill}</td>
|
||
<td>${c.requires_approval ? `<span class='tm-yes'>${this._t("panel.common_yes")}</span>` : `<span class='tm-no'>${this._t("panel.common_no")}</span>`}</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.reward_title")} <span class="tm-toolbar-count">${all.length}</span></h2>
|
||
${all.length > 0 ? this._searchBox(this._t("panel.search_rewards")) : ""}
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-reward">${this._t("panel.btn_add_reward")}</button>
|
||
</div>
|
||
${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 ? `<div class="tm-card tm-empty"><p>${this._t("panel.empty_no_match", {kind: this._t("panel.tab_rewards").toLowerCase(), filter: this._esc(this._filter)})}</p></div>` : `
|
||
<div class="tm-grid">
|
||
${rewards.map(r => this._renderRewardCard(r, pointsName)).join("")}
|
||
<button type="button" class="tm-add-tile" data-act="add-reward"><span class="tm-add-plus">+</span>${this._t("panel.reward_add_tile")}</button>
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
/** Reward-list pill summarising a time lock (#857), or "" when unrestricted. */
|
||
_rewardTimeLockPill(r) {
|
||
if (!r.time_lock_enabled) return "";
|
||
const summary = window.__taskmate_time_lock_label
|
||
? window.__taskmate_time_lock_label(
|
||
{ days: r.available_days, from: r.available_from, until: r.available_until },
|
||
(k, pr) => this._t(k, pr))
|
||
: "";
|
||
const label = summary || this._t("panel.reward_timelock_badge");
|
||
return `<span class="tm-pill tm-pill-timelock">${this._mdi("mdi:clock-outline")} ${this._esc(label)}</span>`;
|
||
}
|
||
|
||
_renderRewardCard(r, pointsName) {
|
||
const totalPooled = (this._state.pool_allocations || []).filter(a => a.reward_id === r.id).reduce((s, a) => s + (a.allocated_points || 0), 0);
|
||
const showProgress = (r.pool_enabled || r.is_jackpot) && r.cost > 0;
|
||
const pct = showProgress ? Math.min(100, Math.round(totalPooled / r.cost * 100)) : 0;
|
||
return `
|
||
<article class="tm-card tm-reward-card">
|
||
<div class="tm-child-head">
|
||
<div class="tm-avatar tm-avatar-reward">${this._mdi(r.icon || "mdi:gift")}</div>
|
||
<div class="tm-child-name">
|
||
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""} ${r.streak_freeze ? `<span class="tm-pill tm-pill-pool">❄️ ${this._t("panel.reward_badge_streak_freeze")}</span>` : ""} ${this._rewardTimeLockPill(r)}</h3>
|
||
${this._idBadge(r.id)}
|
||
<div class="tm-meta">${this._esc(r.description || "")}</div>
|
||
</div>
|
||
</div>
|
||
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr ${r.expires_at ? "1fr" : ""};">
|
||
<div class="tm-stat"><div class="tm-stat-value tm-numeric tm-cost">${this._fmtNum(r.cost)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_cost", {points_name: pointsName})}</div></div>
|
||
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(r.quantity)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
|
||
${r.expires_at ? `<div class="tm-stat"><div class="tm-stat-value" style="font-size: 14px;">${this._esc(r.expires_at)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_expires")}</div></div>` : ""}
|
||
</div>
|
||
${showProgress ? `
|
||
<div class="tm-progress"><span style="width:${pct}%"></span></div>
|
||
<div class="tm-progress-text"><span${this.getAttribute("dir") === "rtl" ? ' dir="ltr"' : ""}><strong>${this._fmtNum(totalPooled)}</strong> / ${this._fmtNum(r.cost)}</span><span>${pct}%</span></div>
|
||
` : ""}
|
||
<div class="tm-card-foot">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="edit-reward" data-id="${this._esc(r.id)}">${this._t("panel.btn_edit")}</button>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="delete-reward" data-id="${this._esc(r.id)}">${this._t("panel.btn_delete")}</button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
// -- 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
|
||
? `<img class="tm-wl-thumb" style="width:${size}px;height:${size}px" src="${this._esc(src)}" alt="" loading="lazy">`
|
||
: `<span class="tm-wl-thumb tm-wl-thumb-ph" style="width:${size}px;height:${size}px">${this._mdi("mdi:gift-outline")}</span>`;
|
||
}
|
||
|
||
_wishLinkChip(w) {
|
||
const link = this._safeWishLink(w.link);
|
||
return link
|
||
? `<a class="tm-pill tm-pill-accent tm-wl-link" href="${this._esc(link.href)}" target="_blank" rel="noopener noreferrer">${this._mdi("mdi:link-variant")} ${this._esc(link.host)}</a>`
|
||
: "";
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-wl-split"><i class="tm-wl-me" style="width:${pct(saved)}%"></i><i class="tm-wl-fam" style="width:${pct(Math.min(pledged, Math.max(0, target - saved)))}%"></i></div>
|
||
<div class="tm-meta tm-numeric">${this._ltrNums(`${this._fmtNum(Math.min(target, saved + pledged))} / ${this._fmtNum(target)}`)}</div>`;
|
||
}
|
||
|
||
_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 = `
|
||
<div class="tm-chip-row">
|
||
<button type="button" class="tm-chip-btn ${filter ? "" : "tm-chip-on"}" data-act="wish-filter" data-id="">${this._t("panel.wish_filter_all")}</button>
|
||
${children.map(c => `<button type="button" class="tm-chip-btn ${filter === c.id ? "tm-chip-on" : ""}" data-act="wish-filter" data-id="${this._esc(c.id)}">${this._esc(c.name)}</button>`).join("")}
|
||
</div>`;
|
||
|
||
const pendingHtml = pending.length === 0 ? `<p class="tm-meta">${this._t("panel.wish_none_pending")}</p>` : pending.map(w => {
|
||
const typed = (this._wishTargets || {})[w.id];
|
||
const value = typed != null ? typed : this._num(w.target);
|
||
return `
|
||
<div class="tm-approval-item tm-wl-appr">
|
||
${this._wishThumb(w, 52)}
|
||
<div class="tm-approval-body">
|
||
<div class="tm-approval-line"><strong>${this._esc(w.name)}</strong> ${this._wishLinkChip(w)}</div>
|
||
<div class="tm-meta">${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))}</div>
|
||
</div>
|
||
<label class="tm-wl-target">
|
||
<span class="tm-meta">${this._t("panel.wish_target")}</span>
|
||
<input class="tm-input" type="number" min="1" max="100000" data-wish-target="${this._esc(w.id)}" value="${this._esc(value)}">
|
||
</label>
|
||
<div class="tm-approval-actions">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="wish-decline" data-id="${this._esc(w.id)}">${this._t("panel.wish_decline")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="wish-approve" data-id="${this._esc(w.id)}">${this._t("panel.wish_approve")}</button>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
|
||
const activeHtml = active.length === 0 ? `<p class="tm-meta">${this._t("panel.wish_none_active")}</p>` : `
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table tm-wl-table">
|
||
<thead><tr>
|
||
<th class="tm-col-sticky">${this._t("panel.wish_col_wish")}</th><th class="tm-wl-progress">${this._t("panel.wish_col_progress")}</th>
|
||
<th>${this._t("panel.wish_col_saved")}</th><th>${this._t("panel.wish_col_pledged")}</th>
|
||
<th>${this._t("panel.wish_col_status")}</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${active.map(w => {
|
||
const status = w.status === "redeem_requested"
|
||
? `<span class="tm-pill tm-pill-success">${this._t("panel.wish_status_redeem")}</span>`
|
||
: w.funded ? `<span class="tm-pill tm-pill-success">${this._t("panel.wish_status_funded")}</span>`
|
||
: `<span class="tm-pill tm-pill-accent">${this._t("panel.wish_status_saving")}</span>`;
|
||
const main = w.status === "redeem_requested" && w.claim_id
|
||
? `<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="wish-fulfil" data-id="${this._esc(w.claim_id)}">${this._mdi("mdi:gift-outline")} ${this._t("panel.wish_fulfil")}</button>`
|
||
: (!w.funded && w.status === "active")
|
||
? `<button type="button" class="tm-btn tm-btn-sm" data-act="wish-pledge" data-id="${this._esc(w.id)}">${this._mdi("mdi:heart-outline")} ${this._t("panel.wish_pledge")}</button>`
|
||
: "";
|
||
return `
|
||
<tr class="tm-row">
|
||
<td class="tm-col-sticky tm-cell-wrap"><div class="tm-name-cell tm-name-cell-wrap">
|
||
<div class="tm-name-main tm-wl-cell">${this._wishThumb(w, 32)}<div><strong>${this._esc(w.name)}</strong><div class="tm-meta">${who(w)} ${this._wishLinkChip(w)}</div></div></div>
|
||
<div class="tm-name-actions tm-wl-acts">${main}
|
||
<button type="button" class="tm-icon-btn" data-act="wish-delete" data-id="${this._esc(w.id)}" title="${this._esc(this._t("panel.wish_delete"))}"><ha-icon icon="mdi:delete-outline"></ha-icon></button>
|
||
</div>
|
||
</div></td>
|
||
<td class="tm-wl-progress">${this._wishBar(w)}</td>
|
||
<td class="tm-numeric">${this._fmtNum(w.saved)}</td>
|
||
<td class="tm-numeric">${this._fmtNum(w.pledged)}</td>
|
||
<td>${status}</td>
|
||
</tr>`;
|
||
}).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
|
||
const pledgesHtml = pledges.length === 0 ? `<p class="tm-meta">${this._t("panel.wish_none_pledges")}</p>` : `
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr>
|
||
<th class="tm-col-sticky">${this._t("panel.wish_col_from")}</th><th>${this._t("panel.wish_col_to")}</th><th>${this._t("panel.wish_col_amount")}</th>
|
||
<th>${this._t("panel.wish_col_message")}</th><th>${this._t("panel.wish_col_date")}</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${pledges.map(p => `
|
||
<tr class="tm-row">
|
||
<td class="tm-col-sticky tm-cell-wrap"><div class="tm-name-cell tm-name-cell-icon">
|
||
<div class="tm-name-main"><strong>${this._esc(p.name)}</strong></div>
|
||
${p.wish.status === "active" || p.wish.status === "redeem_requested"
|
||
? `<div class="tm-name-actions"><button type="button" class="tm-icon-btn" data-act="wish-unpledge" data-id="${this._esc(p.wish.id)}" data-pledge="${this._esc(p.id)}" title="${this._esc(this._t("panel.wish_unpledge"))}"><ha-icon icon="mdi:delete-outline"></ha-icon></button></div>`
|
||
: ""}
|
||
</div></td>
|
||
<td>${who(p.wish)} · ${this._esc(p.wish.name)}</td>
|
||
<td class="tm-numeric tm-wl-gold">+${this._fmtNum(p.points)}</td>
|
||
<td class="tm-meta">${this._esc(p.message || "—")}</td>
|
||
<td class="tm-meta">${this._esc(this._timeAgo(p.created_at))}</td>
|
||
</tr>`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
|
||
const historyHtml = history.length === 0 ? `<p class="tm-meta">${this._t("panel.wish_none_history")}</p>` : `
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<tbody>
|
||
${history.map(w => `
|
||
<tr class="tm-row">
|
||
<td class="tm-col-sticky tm-cell-wrap"><div class="tm-name-cell tm-name-cell-icon">
|
||
<div class="tm-name-main tm-wl-cell">${this._wishThumb(w, 32)}<div><strong>${this._esc(w.name)}</strong><div class="tm-meta">${who(w)}</div></div></div>
|
||
<div class="tm-name-actions"><button type="button" class="tm-icon-btn" data-act="wish-delete" data-id="${this._esc(w.id)}" title="${this._esc(this._t("panel.wish_delete"))}"><ha-icon icon="mdi:delete-outline"></ha-icon></button></div>
|
||
</div></td>
|
||
<td>${w.status === "redeemed"
|
||
? `<span class="tm-pill tm-pill-success">${this._t("panel.wish_status_redeemed")}</span>`
|
||
: `<span class="tm-pill tm-pill-danger">${this._t("panel.wish_status_declined")}</span>${w.decline_reason ? ` <span class="tm-meta">${this._esc(w.decline_reason)}</span>` : ""}`}</td>
|
||
<td class="tm-numeric">${this._fmtNum(w.target)}</td>
|
||
<td class="tm-meta">${this._esc(this._timeAgo(w.redeemed_at || w.declined_at || w.created_at))}</td>
|
||
</tr>`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
|
||
return `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.tab_wishlists")} <span class="tm-toolbar-count">${active.length + pending.length}</span></h2>
|
||
${chips}
|
||
</div>
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title">${this._t("panel.wish_section_pending")}
|
||
${pending.length ? `<span class="tm-pill tm-pill-warn">${pending.length}</span>` : ""}</h3>
|
||
<p class="tm-meta tm-wl-intro">${this._t("panel.wish_section_pending_hint")}</p>
|
||
<div class="tm-approval-list">${pendingHtml}</div>
|
||
</div>
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title">${this._t("panel.wish_section_active")}</h3>
|
||
${activeHtml}
|
||
</div>
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title">${this._t("panel.wish_section_pledges")}</h3>
|
||
<p class="tm-meta tm-wl-intro">${this._t("panel.wish_section_pledges_hint")}</p>
|
||
${pledgesHtml}
|
||
</div>
|
||
<div class="tm-card">
|
||
<h3 class="tm-section-title">${this._t("panel.wish_section_history")}</h3>
|
||
${historyHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_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 ? `<div class="tm-chip-row tm-wl-quick">${names.map(n => `<button type="button" class="tm-chip-btn ${d.name === n ? "tm-chip-on" : ""}" data-act="wish-pledge-who" data-name="${this._esc(n)}">${this._esc(n)}</button>`).join("")}</div>` : ""}
|
||
${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) })))}
|
||
<div class="tm-chip-row tm-wl-quick">
|
||
${[10, 20, 50].filter(n => n < remaining).map(n => `<button type="button" class="tm-chip-btn ${amount === n ? "tm-chip-on" : ""}" data-act="wish-pledge-amt" data-points="${n}">${n}</button>`).join("")}
|
||
<button type="button" class="tm-chip-btn ${amount === remaining ? "tm-chip-on" : ""}" data-act="wish-pledge-amt" data-points="${remaining}">${this._t("panel.wish_pledge_finish", { points: this._fmtNum(remaining) })}</button>
|
||
</div>
|
||
${this._field(this._t("panel.wish_pledge_message", { name: child.name || "" }), "message", d.message)}
|
||
<div class="tm-wl-split tm-wl-split-lg">
|
||
<i class="tm-wl-me" style="width:${pct(this._num(w.saved))}%"></i>
|
||
<i class="tm-wl-fam" style="width:${pct(this._num(w.pledged))}%"></i>
|
||
<i class="tm-wl-new" style="width:${pct(amount)}%"></i>
|
||
</div>
|
||
<p class="tm-meta">${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) }))}</p>`;
|
||
return this._dialogShell(
|
||
this._t("panel.wish_pledge_title", { child: child.name || "", wish: w.name }),
|
||
body,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-wish-pledge">${this._mdi("mdi:heart-outline")} ${this._t("panel.wish_pledge_add")}</button>`
|
||
);
|
||
}
|
||
|
||
_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"))),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-danger" data-act="save-wish-decline">${this._t("panel.wish_decline")}</button>`
|
||
);
|
||
}
|
||
|
||
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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.quest_title")} <span class="tm-toolbar-count">${all.length}</span></h2>
|
||
${all.length > 0 ? this._searchBox(this._t("panel.search_quests")) : ""}
|
||
${haveChores ? `<button type="button" class="tm-btn tm-btn-raised" data-act="add-quest">${this._t("panel.btn_add_quest")}</button>` : ""}
|
||
</div>
|
||
${!haveChores ? `<div class="tm-card tm-empty"><p>${this._t("panel.quest_need_chores")}</p></div>` :
|
||
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 ? `<div class="tm-card tm-empty"><p>${this._t("panel.empty_no_match", {kind: this._t("panel.tab_quests").toLowerCase(), filter: this._esc(this._filter)})}</p></div>` : `
|
||
<div class="tm-grid">
|
||
${quests.map(q => this._renderQuestCard(q, pointsName)).join("")}
|
||
<button type="button" class="tm-add-tile" data-act="add-quest"><span class="tm-add-plus">+</span>${this._t("panel.quest_add_tile")}</button>
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
_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 `<li>${this._esc(name)}</li>`;
|
||
}).join("");
|
||
return `
|
||
<article class="tm-card tm-quest-card${q.active ? "" : " tm-quest-inactive"}">
|
||
<div class="tm-child-head">
|
||
<div class="tm-avatar">${this._mdi(q.icon || "mdi:map-marker-path")}</div>
|
||
<div class="tm-child-name">
|
||
<h3>${this._esc(q.name)}
|
||
${q.repeatable ? `<span class="tm-pill">${this._t("panel.quest_badge_repeatable")}</span>` : ""}
|
||
${q.active ? "" : `<span class="tm-pill tm-pill-muted">${this._t("panel.quest_badge_inactive")}</span>`}
|
||
</h3>
|
||
${this._idBadge(q.id)}
|
||
<div class="tm-meta">${this._esc(q.description || "")}</div>
|
||
</div>
|
||
</div>
|
||
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr;">
|
||
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${steps.length}</div><div class="tm-stat-label">${this._t("panel.quest_stat_steps")}</div></div>
|
||
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._fmtNum(q.bonus_points || 0)}</div><div class="tm-stat-label">${this._t("panel.quest_stat_bonus", {points_name: pointsName})}</div></div>
|
||
</div>
|
||
<ol class="tm-quest-steps">${stepNames}</ol>
|
||
<div class="tm-meta">${this._t("panel.quest_assigned_to")}: ${this._esc(assigned.join(", "))}</div>
|
||
<div class="tm-card-foot">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="edit-quest" data-id="${this._esc(q.id)}">${this._t("panel.btn_edit")}</button>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="delete-quest" data-id="${this._esc(q.id)}">${this._t("panel.btn_delete")}</button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.challenge_title")} <span class="tm-toolbar-count">${all.length}</span></h2>
|
||
${all.length > 0 ? this._searchBox(this._t("panel.search_challenges")) : ""}
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-challenge">${this._t("panel.btn_add_challenge")}</button>
|
||
</div>
|
||
${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 ? `<div class="tm-card tm-empty"><p>${this._t("panel.empty_no_match", {kind: this._t("panel.tab_challenges").toLowerCase(), filter: this._esc(this._filter)})}</p></div>` : `
|
||
<div class="tm-grid">
|
||
${challenges.map(c => this._renderChallengeCard(c, pointsName)).join("")}
|
||
<button type="button" class="tm-add-tile" data-act="add-challenge"><span class="tm-add-plus">+</span>${this._t("panel.challenge_add_tile")}</button>
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
_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 `
|
||
<article class="tm-card tm-quest-card${c.active ? "" : " tm-quest-inactive"}">
|
||
<div class="tm-child-head">
|
||
<div class="tm-avatar">${this._mdi(c.icon || "mdi:trophy-outline")}</div>
|
||
<div class="tm-child-name">
|
||
<h3>${this._esc(c.name)}
|
||
<span class="tm-pill">${this._esc(scopeLabel)}</span>
|
||
${c.active ? "" : `<span class="tm-pill tm-pill-muted">${this._t("panel.quest_badge_inactive")}</span>`}
|
||
</h3>
|
||
${this._idBadge(c.id)}
|
||
<div class="tm-meta">${this._esc(c.description || "")}</div>
|
||
</div>
|
||
</div>
|
||
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr;">
|
||
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(c.target)}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
|
||
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._fmtNum(c.bonus_points || 0)}</div><div class="tm-stat-label">${this._t("panel.quest_stat_bonus", {points_name: pointsName})}</div></div>
|
||
</div>
|
||
<div class="tm-meta">${this._t("panel.quest_assigned_to")}: ${this._esc(assigned.join(", "))}</div>
|
||
<div class="tm-card-foot">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="edit-challenge" data-id="${this._esc(c.id)}">${this._t("panel.btn_edit")}</button>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="delete-challenge" data-id="${this._esc(c.id)}">${this._t("panel.btn_delete")}</button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.tab_bounties")} <span class="tm-toolbar-count">${lists.active.length + lists.pending.length}</span></h2>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-bounty">${this._t("panel.bounty_post_btn")}</button>
|
||
</div>
|
||
${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")) : `
|
||
<div class="tm-chip-row" style="margin-bottom:14px">
|
||
${tabs.map(t => `<button type="button" class="tm-chip-btn ${t.id === sub ? "tm-chip-on" : ""}" data-act="bounty-subtab" data-id="${t.id}">${this._esc(t.label)}</button>`).join("")}
|
||
</div>
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr>
|
||
<th class="tm-col-sticky">${this._t("panel.bounty_col_bounty")}</th><th>${this._t("panel.bounty_col_points")}</th><th>${this._t("panel.bounty_col_eligible")}</th><th>${this._t("panel.bounty_col_status")}</th><th>${this._t("panel.bounty_col_expires")}</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${rows.length === 0
|
||
? `<tr><td colspan="5" class="tm-meta" style="text-align:center;padding:26px">${this._t("panel.bounty_nothing")}</td></tr>`
|
||
: rows.map(b => this._renderBountyRow(b, childById)).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
_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(", "))
|
||
: `<span class="tm-text-muted">${this._t("panel.bounty_all_children")}</span>`;
|
||
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 = `<span class="tm-pill tm-pill-accent">${this._t("panel.bounty_status_open")}</span>`;
|
||
} else if (b.status === "claimed") {
|
||
status = `<span class="tm-pill tm-pill-warn">${this._t("panel.bounty_status_claimed")}</span>
|
||
<div class="tm-meta">${this._t("panel.bounty_claimed_meta", { name: this._esc(name(b.claimed_by)), time: left(b.claim_until) })}</div>`;
|
||
} else if (b.status === "pending") {
|
||
const comp = (this._state.completions || []).find(c => c.id === b.completion_id);
|
||
status = `<span class="tm-pill tm-pill-warn">${this._t("panel.bounty_status_pending")}</span>
|
||
<div class="tm-meta">${this._esc(name(b.claimed_by))}${comp ? ` · ${this._timeAgo(comp.completed_at)}` : ""}</div>`;
|
||
} else if (b.status === "completed") {
|
||
status = `<span class="tm-pill tm-pill-success">${this._t("panel.bounty_status_completed")}</span>
|
||
<div class="tm-meta">${this._t("panel.bounty_completed_meta", { name: this._esc(name(b.claimed_by)), points: this._num(b.points_awarded || b.points) })}</div>`;
|
||
} else if (b.status === "expired") {
|
||
status = `<span class="tm-pill tm-pill-muted">${this._t("panel.bounty_status_expired")}</span>
|
||
<div class="tm-meta">${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" })) : "" })}</div>`;
|
||
}
|
||
if (b.lapse_count > 0) status += `<div class="tm-meta">${this._t("panel.bounty_lapsed_meta", { count: b.lapse_count })}</div>`;
|
||
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) : ""}
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="reject-chore" data-id="${cid}">${this._t("panel.activity_reject")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised tm-btn-sm" data-act="approve-chore" data-id="${cid}">${this._t("panel.activity_approve")}</button>`;
|
||
} else if (b.status === "claimed") {
|
||
actions = `<button type="button" class="tm-btn tm-btn-sm" data-act="release-bounty" data-id="${id}">${this._t("panel.bounty_release")}</button>
|
||
<button type="button" class="tm-icon-btn" data-act="edit-bounty" data-id="${id}" title="${this._t("panel.btn_edit")}">✏</button>`;
|
||
} else if (b.status === "open") {
|
||
actions = `<button type="button" class="tm-icon-btn" data-act="edit-bounty" data-id="${id}" title="${this._t("panel.btn_edit")}">✏</button>
|
||
<button type="button" class="tm-icon-btn" data-act="delete-bounty" data-id="${id}" title="${this._t("panel.bounty_cancel")}"><ha-icon icon="mdi:trash-can-outline"></ha-icon></button>`;
|
||
} else {
|
||
actions = `<button type="button" class="tm-icon-btn" data-act="delete-bounty" data-id="${id}" title="${this._t("panel.btn_delete")}"><ha-icon icon="mdi:trash-can-outline"></ha-icon></button>`;
|
||
}
|
||
return `
|
||
<tr class="tm-row">
|
||
<td class="tm-col-sticky tm-cell-wrap"><div class="tm-name-cell tm-name-cell-wrap">
|
||
<div class="tm-name-main tm-bounty-cell">
|
||
<span class="tm-avatar tm-bounty-ic">${this._mdi(b.icon || "mdi:flag-outline")}</span>
|
||
<div><strong>${this._esc(b.title)}</strong><div class="tm-meta">${this._esc(meta.join(" · "))}</div></div>
|
||
</div>
|
||
<div class="tm-name-actions tm-bounty-actions">${actions}</div>
|
||
</div></td>
|
||
<td><strong class="tm-numeric">${this._num(b.points)}</strong></td>
|
||
<td>${eligible}</td>
|
||
<td>${status}</td>
|
||
<td class="tm-meta">${expires}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
_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 = "") =>
|
||
`<button type="button" class="tm-chip-btn ${on ? "tm-chip-on" : ""}" data-act="${act}" data-id="${this._esc(id)}"${extra}>${label}</button>`;
|
||
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 ? `<p class="tm-meta tm-bounty-note">${this._t("panel.bounty_claimed_lock_note")}</p>` : "",
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bounty_title_label")}</span>
|
||
<input class="tm-input" type="text" data-field="title" maxlength="120" placeholder="${this._esc(this._t("panel.bounty_title_placeholder"))}" value="${this._esc(d.title || "")}"${dis}>
|
||
</div>`,
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bounty_desc_label")}</span>
|
||
<input class="tm-input" type="text" data-field="description" maxlength="500" value="${this._esc(d.description || "")}"${dis}>
|
||
</div>`,
|
||
`<div class="tm-field-row">
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bounty_points_label")}</span>
|
||
<input class="tm-input" type="number" min="1" data-field="points" value="${this._esc(d.points ?? "")}">
|
||
<div class="tm-chip-row" style="margin-top:8px">
|
||
${[10, 25, 50, 100].map(n => chip("bounty-points", n, Number(d.points) === n, String(n))).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bounty_lock_label")}</span>
|
||
<input class="tm-input" type="number" min="1" max="48" data-field="claim_hours" value="${this._esc(d.claim_hours ?? 2)}"${dis}>
|
||
<span class="tm-field-hint">${this._t("panel.bounty_lock_hint")}</span>
|
||
</div>
|
||
</div>`,
|
||
locked ? "" : `<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bounty_icon_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${BOUNTY_ICONS.map(ic => chip("bounty-icon", ic, d.icon === ic, `<ha-icon icon="${ic}"></ha-icon>`, ` title="${this._esc(ic)}"`)).join("")}
|
||
</div>
|
||
</div>
|
||
${this._iconPickerField("", "icon", d.icon)}`,
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bounty_expires_label")}</span>
|
||
<div class="tm-chip-row">${expChips.map(([k, l]) => chip("bounty-exp", k, d.exp_mode === k, this._esc(l))).join("")}</div>
|
||
${d.exp_mode === "pick" ? `<input class="tm-input" type="datetime-local" data-field="exp_pick" style="margin-top:8px;max-width:260px" value="${this._esc(d.exp_pick || "")}">` : ""}
|
||
</div>`,
|
||
locked ? "" : `<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bounty_who_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${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("")}
|
||
</div>
|
||
</div>`,
|
||
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(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-bounty">${this._dialog.mode === "add" ? this._t("panel.bounty_post_save") : this._t("panel.btn_save")}</button>`
|
||
);
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-toolbar">
|
||
<div class="tm-auc-head">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.tab_auctions")} <span class="tm-toolbar-count">${live.length}</span></h2>
|
||
<div class="tm-meta">${this._t("panel.auction_page_sub")}</div>
|
||
</div>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-auction"><ha-icon icon="mdi:gavel"></ha-icon>${this._t("panel.auction_start_btn")}</button>
|
||
</div>
|
||
${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")) : `
|
||
<div class="tm-stats-row tm-auc-stats">
|
||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(live.length)}</div><div class="tm-stat-label">${this._t("panel.auction_stat_live")}</div></div>
|
||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(live.reduce((n, a) => n + (a.bids || []).length, 0))}</div><div class="tm-stat-label">${this._t("panel.auction_stat_bids")}</div></div>
|
||
<div class="tm-stat"><div class="tm-stat-value">${this._ltrNums(`${this._fmtNum(recent.filter(a => a.winner_id).length)} / ${this._fmtNum(recent.length)}`)}</div><div class="tm-stat-label">${this._t("panel.auction_stat_won")}</div></div>
|
||
</div>
|
||
<div class="tm-auc-bar">
|
||
<div class="tm-chip-row">
|
||
<button type="button" class="tm-chip-btn ${sub === "live" ? "tm-chip-on" : ""}" data-act="auction-subtab" data-id="live">${this._esc(this._t("panel.auction_tab_live", { count: live.length }))}</button>
|
||
<button type="button" class="tm-chip-btn ${sub === "closed" ? "tm-chip-on" : ""}" data-act="auction-subtab" data-id="closed">${this._esc(this._t("panel.auction_tab_closed", { count: closed.length }))}</button>
|
||
</div>
|
||
${sub === "live" ? `
|
||
<label class="tm-auc-reveal">
|
||
<span class="tm-meta">${this._t("panel.auction_show_amounts")}</span>
|
||
<ha-switch data-local="auction-reveal" ${reveal ? "checked" : ""}></ha-switch>
|
||
</label>` : ""}
|
||
</div>
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr>${heads.map((h, i) => `<th class="${i === 0 ? "tm-col-sticky" : ""}">${this._t(`panel.auction_${h}`)}</th>`).join("")}</tr></thead>
|
||
<tbody>
|
||
${rows.length === 0
|
||
? `<tr><td colspan="5" class="tm-meta" style="text-align:center;padding:26px">${this._t("panel.auction_nothing")}</td></tr>`
|
||
: rows.map(a => this._renderAuctionRow(a, childById, reveal)).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
_auctionBidChips(a, childById, reveal) {
|
||
const bids = a.bids || [];
|
||
if (!bids.length) return `<span class="tm-text-muted">${this._t(a.status === "open" ? "panel.auction_no_bids_yet" : "panel.auction_no_bids")}</span>`;
|
||
const show = reveal || a.status !== "open";
|
||
return bids.map((b, i) => {
|
||
const child = childById[b.child_id];
|
||
return `<span class="tm-auc-chip" title="${this._esc(child ? child.name : "?")}">
|
||
${child ? this._childAvatar(child) : ""}
|
||
${show ? `<b>${this._num(b.points)} ★</b>` : `<span class="tm-text-muted">${this._t("panel.auction_sealed")}</span>`}
|
||
${i === 0 && a.status === "open" && show ? `<span class="tm-meta">· ${this._t("panel.auction_leading")}</span>` : ""}
|
||
</span>`;
|
||
}).join("");
|
||
}
|
||
|
||
_renderAuctionRow(a, childById, reveal) {
|
||
const id = this._esc(a.id);
|
||
const chore = `
|
||
<div class="tm-name-main tm-bounty-cell">
|
||
<span class="tm-avatar tm-auc-ic">${this._mdi(a.icon || "mdi:gavel")}</span>
|
||
<div><strong>${this._esc(a.chore_name)}</strong><div class="tm-meta">${this._esc(this._t("panel.auction_normally", { date: this._auctionDate(a.occurrence), points: this._num(a.normal_points) }))}</div></div>
|
||
</div>`;
|
||
const max = `<strong class="tm-numeric tm-auc-gold">${this._num(a.max_points)} ★</strong>${a.min_points > 1 ? `<div class="tm-meta">${this._t("panel.auction_min_meta", { points: this._num(a.min_points) })}</div>` : ""}`;
|
||
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 `
|
||
<tr class="tm-row">
|
||
<td class="tm-col-sticky tm-cell-wrap"><div class="tm-name-cell tm-name-cell-wrap">${chore}
|
||
<div class="tm-name-actions tm-bounty-actions">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="auction-close-now" data-id="${id}">${this._t("panel.auction_close_now")}</button>
|
||
<button type="button" class="tm-icon-btn" data-act="auction-cancel" data-id="${id}" title="${this._t("panel.auction_cancel")}"><ha-icon icon="mdi:trash-can-outline"></ha-icon></button>
|
||
</div></div></td>
|
||
<td>${max}</td>
|
||
<td><span class="tm-auc-stack">${who}</span></td>
|
||
<td>${this._auctionBidChips(a, childById, reveal)}</td>
|
||
<td><span class="tm-auc-cdown"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._esc(this._bountyDur(left))}</span><div class="tm-meta">${this._esc(this._auctionWhen(a.closes_at))}</div></td>
|
||
</tr>`;
|
||
}
|
||
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 = `<span class="tm-pill tm-pill-muted">${this._t("panel.auction_status_cancelled")}</span>`;
|
||
status = `<span class="tm-pill tm-pill-muted">${this._t("panel.auction_status_normal")}</span>`;
|
||
} else if (a.winner_id) {
|
||
result = `<div class="tm-auc-win">${winner ? this._childAvatar(winner) : ""}<div><strong>${this._esc(winner ? winner.name : "?")}</strong> ${this._t("panel.auction_won_at", { points: `<strong class="tm-auc-gold">${this._num(a.price)} ★</strong>` })}
|
||
<div class="tm-meta">${this._esc(this._t("panel.auction_assigned_for", { date: this._auctionDate(a.occurrence) }))}</div></div></div>`;
|
||
const st = a.chore_status || "todo";
|
||
status = st === "done"
|
||
? `<span class="tm-pill tm-pill-success">${this._t("panel.auction_status_done", { points: this._num(a.points_awarded) })}</span>`
|
||
: st === "pending" ? `<span class="tm-pill tm-pill-warn">${this._t("panel.auction_status_pending")}</span>`
|
||
: st === "missed" ? `<span class="tm-pill tm-pill-muted">${this._t("panel.auction_status_missed")}</span>`
|
||
: `<span class="tm-pill tm-pill-accent">${this._t("panel.auction_status_todo")}</span>`;
|
||
if (a.occurrence >= todayIso && st !== "done" && st !== "pending") {
|
||
actions = `<button type="button" class="tm-icon-btn" data-act="auction-cancel" data-id="${id}" title="${this._t("panel.auction_cancel")}"><ha-icon icon="mdi:trash-can-outline"></ha-icon></button>`;
|
||
}
|
||
} else {
|
||
result = `<span class="tm-pill tm-pill-muted">${this._t("panel.auction_no_bids")}</span><div class="tm-meta">${this._t("panel.auction_fallback")}</div>`;
|
||
status = `<span class="tm-pill tm-pill-muted">${this._t("panel.auction_status_normal")}</span>`;
|
||
if (a.occurrence > todayIso) {
|
||
actions = `<button type="button" class="tm-btn tm-btn-sm" data-act="auction-reopen" data-id="${id}"><ha-icon icon="mdi:replay"></ha-icon>${this._t("panel.auction_reopen")}</button>`;
|
||
}
|
||
}
|
||
// 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 += `<button type="button" class="tm-icon-btn" data-act="auction-delete" data-id="${id}" title="${this._t("panel.btn_delete")}"><ha-icon icon="mdi:trash-can-outline"></ha-icon></button>`;
|
||
}
|
||
return `
|
||
<tr class="tm-row">
|
||
<td class="tm-col-sticky tm-cell-wrap"><div class="tm-name-cell tm-name-cell-wrap">${chore}
|
||
<div class="tm-name-actions tm-bounty-actions">${actions}</div></div></td>
|
||
<td>${max}</td>
|
||
<td>${this._auctionBidChips(a, childById, true)}</td>
|
||
<td>${result}</td>
|
||
<td>${status}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
/** 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 = "") =>
|
||
`<button type="button" class="tm-chip-btn ${on ? "tm-chip-on" : ""}" data-act="${act}" data-id="${this._esc(id)}"${extra}>${label}</button>`;
|
||
const section = (key, parts) => `
|
||
<section class="tm-drawer-sec">
|
||
<h3 class="tm-drawer-sec-h">${this._t(`panel.auction_sec_${key}`)}</h3>
|
||
${parts.join("")}
|
||
</section>`;
|
||
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
|
||
? `<div class="tm-meta">${this._t("panel.auction_loading")}</div>`
|
||
: d.refusal
|
||
? `<div class="tm-meta tm-auc-warn">${this._esc(d.refusal)}</div>`
|
||
: d.occurrences.length
|
||
? `<div class="tm-chip-row tm-auc-chips">${d.occurrences.map(o => chip("auction-occ", o, d.occurrence === o, `<ha-icon icon="mdi:calendar-blank-outline"></ha-icon>${this._esc(this._auctionDate(o))}`)).join("")}</div>`
|
||
: `<div class="tm-meta tm-auc-warn">${this._t("panel.auction_no_occurrences")}</div>`;
|
||
const body = chores.length === 0 ? `<p class="tm-meta">${this._t("panel.auction_no_chores")}</p>` : [
|
||
section("chore", [
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.auction_chore_label")}</span>
|
||
<select class="tm-input" data-role="auction-chore">
|
||
${chores.map(c => `<option value="${this._esc(c.id)}" ${c.id === d.chore_id ? "selected" : ""}>${this._esc(c.name)}</option>`).join("")}
|
||
</select>
|
||
</div>`,
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.auction_which_label")}</span>
|
||
${occurrences}
|
||
<span class="tm-field-hint">${this._t("panel.auction_which_hint")}</span>
|
||
</div>`,
|
||
]),
|
||
section("price", [
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.auction_max_label")}</span>
|
||
<div class="tm-auc-steprow">
|
||
<div class="tm-auc-stepper">
|
||
<button type="button" data-act="auction-max-step" data-id="-1" aria-label="−">−</button>
|
||
<span>${this._num(d.max_points)} ★</span>
|
||
<button type="button" data-act="auction-max-step" data-id="1" aria-label="+">+</button>
|
||
</div>
|
||
<span class="tm-meta">${this._t("panel.auction_normally_worth", { points: normal })}</span>
|
||
</div>
|
||
<div class="tm-chip-row" style="margin-top:8px">
|
||
${multiples.map(([m, n]) => chip("auction-max-set", n, Number(d.max_points) === n, `${m}× · ${n}`)).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.auction_max_hint")}</span>
|
||
</div>`,
|
||
this._switch(this._t("panel.auction_min_label"), "min_on", !!d.min_on, this._t("panel.auction_min_hint"), true),
|
||
d.min_on ? `<div class="tm-field"><input class="tm-input" type="number" min="1" data-field="min_points" style="max-width:140px" value="${this._esc(d.min_points ?? 1)}"></div>` : "",
|
||
]),
|
||
section("bidding", [
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.auction_closes_label")}</span>
|
||
<div class="tm-chip-row">${closeChips.map(([k, l]) => chip("auction-close-at", k, d.close_mode === k, this._esc(l), k !== "pick" && !this._auctionCloseAt(d, k) ? " disabled" : "")).join("")}</div>
|
||
${d.close_mode === "pick" ? `<input class="tm-input" type="datetime-local" data-field="close_pick" style="margin-top:8px;max-width:260px" value="${this._esc(d.close_pick || "")}">` : ""}
|
||
<span class="tm-field-hint">${this._t("panel.auction_close_hint")}</span>
|
||
</div>`,
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.auction_who_label")}</span>
|
||
<div class="tm-chip-row tm-auc-chips">
|
||
${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("")}
|
||
</div>
|
||
</div>`,
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.auction_nobid_label")}</span>
|
||
<span class="tm-field-hint">${this._t("panel.auction_nobid_copy")}</span>
|
||
</div>`,
|
||
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,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-auction" ${d.loading || !d.occurrence ? "disabled" : ""}><ha-icon icon="mdi:gavel"></ha-icon>${this._t("panel.auction_start_save")}</button>`,
|
||
{ 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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.badge_tab_title")} <span class="tm-toolbar-count">${allBadges.length}</span></h2>
|
||
${sub === "custom" ? `<button type="button" class="tm-btn tm-btn-raised" data-act="add-badge">${this._t("panel.badge_add_btn")}</button>` : ""}
|
||
</div>
|
||
<div class="tm-badge-subtabs">
|
||
${subTabs.map(s => `<button type="button" class="tm-badge-subtab${sub === s.id ? " tm-badge-subtab-active" : ""}" data-act="badges-subtab" data-sub="${s.id}">${this._esc(s.label)}</button>`).join("")}
|
||
</div>
|
||
${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 `<div class="tm-card tm-empty"><p>${this._t("panel.badge_empty_catalogue")}</p></div>`;
|
||
return `
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr><th>${this._t("panel.badge_table_badge")}</th><th>${this._t("panel.badge_table_tier")}</th><th>${this._t("panel.badge_table_criteria")}</th><th>${this._t("panel.badge_table_bonus")}</th><th>${this._t("panel.badge_table_enabled")}</th><th></th></tr></thead>
|
||
<tbody>
|
||
${builtins.map(b => `
|
||
<tr class="tm-row tm-badge-row ${this._tierClass(b.tier)}">
|
||
<td>
|
||
<div class="tm-badge-icon-sm">${this._mdi(b.icon)}</div>
|
||
<span style="margin-inline-start:8px"><strong>${this._esc(this._ltrNums(this._badgeName(b)))}</strong>
|
||
<span class="tm-pill" style="background:var(--tm-surface-2);font-size:10px;margin-inline-start:4px">${this._t("panel.badge_builtin_pill")}</span></span>
|
||
${this._badgeDesc(b) ? `<div class="tm-meta">${this._esc(this._badgeDesc(b))}</div>` : ""}
|
||
</td>
|
||
<td><span class="tm-badge-tier-label">${this._t("badge.tier_" + (b.tier || "bronze"))}</span></td>
|
||
<td><code class="tm-badge-criteria">${this._esc(this._criteriaLabel(b.criteria, b.combinator))}</code></td>
|
||
<td><strong class="${b.point_bonus ? "tm-pos" : "tm-text-muted"}">${b.point_bonus ? `+${b.point_bonus}` : "—"}</strong></td>
|
||
<td>
|
||
<button type="button" class="tm-badge-toggle${b.enabled !== false ? " tm-badge-toggle-on" : ""}"
|
||
data-act="toggle-badge-enabled" data-id="${this._esc(b.id)}" data-enabled="${b.enabled !== false}"
|
||
title="${b.enabled !== false ? this._t("panel.badge_toggle_disable") : this._t("panel.badge_toggle_enable")}">
|
||
</button>
|
||
</td>
|
||
<td class="tm-row-actions"><div>
|
||
<button type="button" class="tm-icon-btn" data-act="edit-badge" data-id="${this._esc(b.id)}" title="${this._t("panel.badge_edit_btn")}">✏</button>
|
||
</div></td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderBadgeCustomTab(allBadges) {
|
||
const customs = allBadges.filter(b => !b.builtin);
|
||
if (!customs.length) return `
|
||
<div class="tm-card tm-empty" style="text-align:center;padding:32px">
|
||
<p style="font-size:24px;margin-bottom:8px">🏅</p>
|
||
<p style="font-weight:600;margin-bottom:4px">${this._t("panel.badge_empty_custom_title")}</p>
|
||
<p class="tm-meta" style="margin-bottom:16px">${this._t("panel.badge_empty_custom_desc")}</p>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-badge">${this._t("panel.badge_add_btn")}</button>
|
||
</div>`;
|
||
return `
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr><th>${this._t("panel.badge_table_badge")}</th><th>${this._t("panel.badge_table_tier")}</th><th>${this._t("panel.badge_table_criteria")}</th><th>${this._t("panel.badge_table_bonus")}</th><th>${this._t("panel.badge_table_enabled")}</th><th></th></tr></thead>
|
||
<tbody>
|
||
${customs.map(b => `
|
||
<tr class="tm-row tm-badge-row ${this._tierClass(b.tier)}">
|
||
<td>
|
||
<div class="tm-badge-icon-sm">${this._mdi(b.icon || "mdi:medal")}</div>
|
||
<span style="margin-inline-start:8px"><strong>${this._esc(b.name)}</strong></span>
|
||
${b.description ? `<div class="tm-meta">${this._esc(b.description)}</div>` : ""}
|
||
</td>
|
||
<td><span class="tm-badge-tier-label">${this._t("badge.tier_" + (b.tier || "bronze"))}</span></td>
|
||
<td><code class="tm-badge-criteria">${this._esc(this._criteriaLabel(b.criteria, b.combinator))}</code></td>
|
||
<td><strong class="${b.point_bonus ? "tm-pos" : "tm-text-muted"}">${b.point_bonus ? `+${b.point_bonus}` : "—"}</strong></td>
|
||
<td>
|
||
<button type="button" class="tm-badge-toggle${b.enabled !== false ? " tm-badge-toggle-on" : ""}"
|
||
data-act="toggle-badge-enabled" data-id="${this._esc(b.id)}" data-enabled="${b.enabled !== false}"
|
||
title="${b.enabled !== false ? this._t("panel.badge_toggle_disable") : this._t("panel.badge_toggle_enable")}">
|
||
</button>
|
||
</td>
|
||
<td class="tm-row-actions"><div>
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="award-badge" data-id="${this._esc(b.id)}">${this._t("panel.badge_award_btn")}</button>
|
||
<button type="button" class="tm-icon-btn" data-act="edit-badge" data-id="${this._esc(b.id)}" title="${this._t("panel.badge_edit_btn")}">✏</button>
|
||
<button type="button" class="tm-icon-btn" data-act="delete-badge" data-id="${this._esc(b.id)}" title="${this._t("panel.badge_delete_btn")}">🗑</button>
|
||
</div></td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_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 = `<button type="button" class="tm-btn" data-act="rebuild-badges" title="${this._t("panel.badge_rebuild_tooltip")}">${this._t("panel.badge_rebuild_btn")}</button>`;
|
||
if (!awards.length) return `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">${rebuildBtn}</div>
|
||
<div class="tm-card tm-empty"><p>${this._t("panel.badge_empty_history")}</p></div>`;
|
||
return `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">${rebuildBtn}</div>
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr><th>${this._t("panel.badge_table_badge")}</th><th>${this._t("panel.badge_table_child")}</th><th>${this._t("panel.badge_table_when")}</th><th>${this._t("panel.badge_table_source")}</th><th></th></tr></thead>
|
||
<tbody>
|
||
${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 `
|
||
<tr class="tm-row tm-badge-row ${tierCls}">
|
||
<td>
|
||
<div class="tm-badge-icon-sm">${this._mdi(badge.icon || "mdi:medal")}</div>
|
||
<span style="margin-inline-start:8px"><strong>${this._esc(this._ltrNums(this._badgeName(badge) || a.badge_id))}</strong></span>
|
||
</td>
|
||
<td><strong style="color:var(--tm-accent)">${this._esc(child.name || a.child_id)}</strong></td>
|
||
<td class="tm-meta">${this._esc(when)}</td>
|
||
<td><span class="tm-badge-src ${srcCls}">${srcLabel}</span></td>
|
||
<td class="tm-row-actions"><div>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="revoke-badge" data-id="${this._esc(a.id)}" data-name="${this._esc(this._badgeName(badge) || "badge")}">${this._t("panel.badge_revoke_btn")}</button>
|
||
</div></td>
|
||
</tr>
|
||
`;
|
||
}).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 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 ? `<div class="tm-field"><span class="tm-pill" style="background:var(--tm-surface-2)">${this._t("panel.badge_builtin_hint")}</span></div>` : "",
|
||
!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") : "",
|
||
`<div class="tm-field-row">
|
||
${!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)}
|
||
</div>`,
|
||
`<div class="tm-field-row">
|
||
${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") }])}
|
||
</div>`,
|
||
children.length > 0 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.badge_assigned_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.assigned_to || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="badge-toggle-assigned" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.badge_assigned_hint")}</span>
|
||
</div>
|
||
` : "",
|
||
!isBuiltin ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.badge_criteria_label")}</span>
|
||
<div class="tm-badge-criteria-block">
|
||
${criteria.length === 0 ? `<p class="tm-field-hint" style="margin:0 0 8px">${this._t("panel.badge_no_criteria_hint")}</p>` : ""}
|
||
${criteria.map((c, idx) => {
|
||
const isBool = BADGE_BOOL_METRICS.includes(c.metric);
|
||
return `
|
||
<div class="tm-badge-criterion-row">
|
||
<select class="tm-select" data-badge-criterion-field="metric" data-badge-criterion-idx="${idx}">
|
||
${BADGE_METRICS.map(m => `<option value="${m.v}"${c.metric === m.v ? " selected" : ""}>${this._t(m.lk)}</option>`).join("")}
|
||
</select>
|
||
${isBool ? `<span class="tm-badge-op">=</span>` : `
|
||
<select class="tm-select tm-badge-op-select" data-badge-criterion-field="operator" data-badge-criterion-idx="${idx}">
|
||
${BADGE_OPERATORS.map(sym => { const v = BADGE_OP_VALUES[sym]; return `<option value="${v}"${(c.operator || ">=") === v ? " selected" : ""}>${sym}</option>`; }).join("")}
|
||
</select>`}
|
||
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${this._num(c.value, 1)}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
|
||
<button type="button" class="tm-icon-btn" data-act="badge-remove-criterion" data-idx="${idx}" title="${this._t("panel.badge_remove_criterion_btn")}">✕</button>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
<button type="button" class="tm-btn" data-act="badge-add-criterion" style="margin-top:6px;width:100%;border-style:dashed">${this._t("panel.badge_add_criterion_btn")}</button>
|
||
${criteria.length >= 2 ? `
|
||
<div class="tm-badge-combinator">
|
||
<span class="tm-field-hint">${this._t("panel.badge_combinator_label")}</span>
|
||
<select class="tm-select" data-field="combinator">
|
||
<option value="AND"${(d.combinator || "AND") !== "OR" ? " selected" : ""}>${this._t("panel.badge_combinator_all")}</option>
|
||
<option value="OR"${d.combinator === "OR" ? " selected" : ""}>${this._t("panel.badge_combinator_any")}</option>
|
||
</select>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
</div>
|
||
` : "",
|
||
].join(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.badge_cancel_btn")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-badge">${this._t("panel.badge_save_btn")}</button>`
|
||
);
|
||
}
|
||
|
||
_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 }),
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.badge_award_to_label")}</span>
|
||
<select class="tm-select" data-field="child_id">
|
||
<option value="">${this._t("panel.badge_award_select_child")}</option>
|
||
${children.map(c => `<option value="${this._esc(c.id)}">${this._esc(c.name)}</option>`).join("")}
|
||
</select>
|
||
</div>`,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.badge_cancel_btn")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="do-award-badge" data-id="${this._esc(d.badge_id)}">${this._t("panel.badge_award_btn")}</button>`
|
||
);
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${labels.plural} <span class="tm-toolbar-count">${allItems.length}</span></h2>
|
||
${allItems.length > 0 ? this._searchBox(kind === "penalty" ? this._t("panel.search_penalties") : this._t("panel.search_bonuses")) : ""}
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-${kind}">${labels.add}</button>
|
||
</div>
|
||
${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 ? `<div class="tm-card tm-empty"><p>${this._t("panel.empty_no_match", {kind: labels.plural.toLowerCase(), filter: this._esc(this._filter)})}</p></div>` : `
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead><tr><th>${this._t("panel.penbon_table_name")}</th><th>${this._t("panel.penbon_table_points")}</th><th>${this._t("panel.penbon_table_assigned")}</th><th></th></tr></thead>
|
||
<tbody>
|
||
${items.map(item => {
|
||
const assignedNames = (item.assigned_to || []).length === 0
|
||
? `<span class="tm-text-muted">${this._t("panel.common_all_children")}</span>`
|
||
: this._esc((item.assigned_to || []).map(id => (childById[id] && childById[id].name) || "?").join(", "));
|
||
return `
|
||
<tr class="tm-row">
|
||
<td><span class="tm-row-icon">${this._mdi(item.icon)}</span><strong>${this._esc(item.name)}</strong>${this._idBadge(item.id)}${item.description ? `<div class="tm-meta">${this._esc(item.description)}</div>` : ""}</td>
|
||
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${this._num(item.points)}</strong></td>
|
||
<td>${assignedNames}</td>
|
||
<td class="tm-row-actions"><div>
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="apply-${kind}" data-id="${this._esc(item.id)}">${this._t("panel.btn_apply")}</button>
|
||
<button type="button" class="tm-icon-btn" data-act="edit-${kind}" data-id="${this._esc(item.id)}" title="${this._t("panel.btn_edit")}">✏</button>
|
||
<button type="button" class="tm-icon-btn" data-act="delete-${kind}" data-id="${this._esc(item.id)}" title="${this._t("panel.btn_delete")}">🗑</button>
|
||
</div></td>
|
||
</tr>
|
||
`;
|
||
}).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.group_title")} <span class="tm-toolbar-count">${all.length}</span></h2>
|
||
${all.length > 0 ? this._searchBox(this._t("panel.search_groups")) : ""}
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="add-group">${this._t("panel.btn_add_group")}</button>
|
||
</div>
|
||
${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 ? `<div class="tm-card tm-empty"><p>${this._t("panel.empty_no_match", {kind: this._t("panel.tab_groups").toLowerCase(), filter: this._esc(this._filter)})}</p></div>` : `
|
||
<div class="tm-grid">
|
||
${groups.map(g => `
|
||
<article class="tm-card">
|
||
<div class="tm-child-head">
|
||
<div class="tm-avatar"><ha-icon icon="${g.policy === 'sticky' ? 'mdi:link-variant' : 'mdi:arrow-split-horizontal'}"></ha-icon></div>
|
||
<div class="tm-child-name">
|
||
<h3>${this._esc(g.name)}</h3>
|
||
${this._idBadge(g.id)}
|
||
<div class="tm-meta"><span class="tm-pill tm-pill-${this._esc(g.policy)}">${this._t(`panel.group_policy_${g.policy}_short`)}</span> · ${this._t("panel.group_chore_count", {count: (g.chore_ids || []).length})}</div>
|
||
</div>
|
||
</div>
|
||
<ul class="tm-group-list">
|
||
${(g.chore_ids || []).map(id => `<li>${this._esc((choreById[id] && choreById[id].name) || this._t("panel.group_missing_chore", {id}))}</li>`).join("")}
|
||
</ul>
|
||
<div class="tm-card-foot">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="edit-group" data-id="${this._esc(g.id)}">${this._t("panel.btn_edit")}</button>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="delete-group" data-id="${this._esc(g.id)}">${this._t("panel.btn_delete")}</button>
|
||
</div>
|
||
</article>
|
||
`).join("")}
|
||
<button type="button" class="tm-add-tile" data-act="add-group"><span class="tm-add-plus">+</span>${this._t("panel.group_add_tile")}</button>
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
// -- 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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.template_title")} <span class="tm-toolbar-count">${templates.length}</span></h2>
|
||
<span style="flex:1"></span>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="tpl-create">${this._t("panel.btn_create_template_toolbar")}</button>
|
||
</div>
|
||
${custom.length > 0 ? `
|
||
<div class="tm-section-divider">${this._t("panel.template_custom_templates")}</div>
|
||
${custom.map(t => this._renderManageTemplateCard(t, false)).join("")}
|
||
` : ""}
|
||
<div class="tm-section-divider">${custom.length > 0 ? this._t("panel.template_builtin_templates") : this._t("panel.template_builtin_packs")}</div>
|
||
${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 `
|
||
<div class="tm-manage-tpl ${locked ? "tm-manage-tpl-locked" : ""}" data-act="tpl-select" data-id="${this._esc(tpl.id)}" style="cursor:pointer">
|
||
<div class="tm-tpl-icon"><ha-icon icon="${this._esc(tpl.icon || "mdi:clipboard-list")}"></ha-icon></div>
|
||
<div class="tm-manage-tpl-info">
|
||
<div class="tm-manage-tpl-name">${this._esc(tpl.name)}${locked ? ' <span class="tm-text-vfaint" style="font-size:12px">🔒</span>' : ""}</div>
|
||
<div class="tm-meta">${this._t("panel.template_pts_total", {count, points: pts})}</div>
|
||
</div>
|
||
<div class="tm-row-actions">
|
||
${locked ? "" : `<button type="button" class="tm-btn tm-btn-sm" data-act="tpl-edit" data-id="${this._esc(tpl.id)}">${this._t("panel.btn_edit")}</button>`}
|
||
${locked ? "" : `<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="tpl-delete" data-id="${this._esc(tpl.id)}">${this._t("panel.btn_delete")}</button>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderTemplatePicker() {
|
||
const templates = this._state.templates || [];
|
||
const builtin = templates.filter(t => t.builtin);
|
||
const custom = templates.filter(t => !t.builtin);
|
||
return `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.template_picker_title")}</h2>
|
||
<span style="flex:1"></span>
|
||
<button type="button" class="tm-btn" data-act="tpl-back">${this._t("panel.btn_cancel")}</button>
|
||
</div>
|
||
<div class="tm-section-divider">${this._t("panel.template_builtin_packs")}</div>
|
||
<div class="tm-grid">
|
||
${builtin.map(t => this._renderTemplatePickerCard(t)).join("")}
|
||
</div>
|
||
${custom.length > 0 ? `
|
||
<div class="tm-section-divider">${this._t("panel.template_custom_packs")}</div>
|
||
<div class="tm-grid">
|
||
${custom.map(t => this._renderTemplatePickerCard(t)).join("")}
|
||
</div>
|
||
` : ""}
|
||
`;
|
||
}
|
||
|
||
_renderTemplatePickerCard(tpl) {
|
||
const count = (tpl.chores || []).length;
|
||
const pts = (tpl.chores || []).reduce((s, c) => s + (c.points || 0), 0);
|
||
return `
|
||
<div class="tm-card tm-tpl-picker-card" data-act="tpl-select" data-id="${this._esc(tpl.id)}" style="margin-bottom:0">
|
||
<div class="tm-tpl-picker-head">
|
||
<div class="tm-tpl-icon"><ha-icon icon="${this._esc(tpl.icon || "mdi:clipboard-list")}"></ha-icon></div>
|
||
<div style="flex:1">
|
||
<div class="tm-tpl-picker-name">${this._esc(tpl.name)}</div>
|
||
<div class="tm-meta">${this._t("panel.template_pts_total", {count, points: pts})}</div>
|
||
</div>
|
||
<span class="tm-pill ${tpl.builtin ? "tm-pill-accent" : "tm-pill-success"}">${tpl.builtin ? this._t("panel.template_builtin") : this._t("panel.template_custom")}</span>
|
||
</div>
|
||
<div class="tm-tpl-chore-pills">
|
||
${(tpl.chores || []).map(c => `<span class="tm-tpl-chore-pill">${this._esc(c.name)}</span>`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderTemplatePreview() {
|
||
const tpl = this._templateSelected;
|
||
if (!tpl) return "";
|
||
const chores = this._templateChores;
|
||
const totalPts = chores.reduce((s, c) => s + (c.points || 0), 0);
|
||
return `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._esc(tpl.name)}</h2>
|
||
<span class="tm-pill ${tpl.builtin ? "tm-pill-accent" : "tm-pill-success"}" style="margin-inline-start:8px">${tpl.builtin ? this._t("panel.template_builtin") : this._t("panel.template_custom")}</span>
|
||
<span style="flex:1"></span>
|
||
<button type="button" class="tm-btn" data-act="tpl-back">${this._t("panel.btn_back")}</button>
|
||
</div>
|
||
<p class="tm-meta" style="margin-bottom:16px">${this._t("panel.template_preview_hint")}</p>
|
||
${chores.map((c, i) => this._renderTemplateChoreCard(c, i)).join("")}
|
||
${chores.length > 0 ? `
|
||
<div class="tm-tpl-confirm-bar">
|
||
<div>${this._t("panel.template_confirm_bar", {count: chores.length, points: totalPts})}</div>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="tpl-apply">${this._t("panel.template_create_btn", {count: chores.length})}</button>
|
||
</div>
|
||
` : `<div class="tm-card tm-empty"><p>${this._t("panel.empty_template_all_removed")}</p></div>`}
|
||
`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-tpl-preview-card">
|
||
<div class="tm-tpl-preview-header" data-act="tpl-toggle-expand" data-idx="${idx}">
|
||
<span class="tm-tpl-expand ${expanded ? "open" : ""}">▶</span>
|
||
<span class="tm-tpl-preview-name">${this._esc(chore.name)}</span>
|
||
<span class="tm-tpl-preview-summary">
|
||
<span>${this._t("panel.pts_display", {count: chore.points || 0})}</span>
|
||
<span>${schedLabel}</span>
|
||
<span>${this._esc(this._timeCategoryLabel(chore.time_category))}</span>
|
||
</span>
|
||
<button type="button" class="tm-tpl-remove" data-act="tpl-remove-chore" data-idx="${idx}" title="${this._t("panel.tooltip_remove_batch")}">✕</button>
|
||
</div>
|
||
${expanded ? `
|
||
<div class="tm-tpl-preview-body">
|
||
<div class="tm-field-row">
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-field="name" data-tpl-idx="${idx}" value="${this._esc(chore.name)}"></div>
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${this._num(chore.points)}" min="0"></div>
|
||
</div>
|
||
<div class="tm-field-row">
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
|
||
<select class="tm-select" data-tpl-field="time_category" data-tpl-idx="${idx}">
|
||
${this._timeCategoryOptions().map(t => `<option value="${this._esc(t.v)}" ${chore.time_category === t.v ? "selected" : ""}>${this._esc(t.l)}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_assignment_mode_label")}</span>
|
||
<select class="tm-select" data-tpl-field="assignment_mode" data-tpl-idx="${idx}">
|
||
${ASSIGNMENT_MODES.map(m => `<option value="${m.v}" ${chore.assignment_mode === m.v ? "selected" : ""}>${this._t(m.lk).split(" — ")[0]}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="tm-field" style="margin-bottom:12px"><span class="tm-field-label">${this._t("panel.template_schedule_days_label")}</span>
|
||
<div class="tm-day-pills">
|
||
${DAYS.map(d => `<span class="tm-day-pill ${(chore.due_days || []).includes(d.v) ? "active" : ""}" data-act="tpl-toggle-day" data-idx="${idx}" data-day="${d.v}">${this._t(d.lk)}</span>`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="tm-field-row">
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_requires_approval_label")}</span>
|
||
<select class="tm-select" data-tpl-field="requires_approval" data-tpl-idx="${idx}">
|
||
<option value="false" ${!chore.requires_approval ? "selected" : ""}>${this._t("panel.template_requires_approval_no")}</option>
|
||
<option value="true" ${chore.requires_approval ? "selected" : ""}>${this._t("panel.template_requires_approval_yes")}</option>
|
||
</select>
|
||
</div>
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_daily_limit_label")}</span>
|
||
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${this._num(chore.daily_limit, 1)}" min="1">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderSaveTemplateDialog() {
|
||
if (!this._saveTemplateDialog) return "";
|
||
const chores = this._state.chores || [];
|
||
return `
|
||
<div class="tm-scrim" data-act="scrim">
|
||
<div class="tm-dialog">
|
||
<div class="tm-dialog-head">
|
||
<h2>${this._t("panel.template_save_title")}</h2>
|
||
<button type="button" class="tm-icon-btn" data-act="tpl-save-cancel">✕</button>
|
||
</div>
|
||
<div class="tm-dialog-body">
|
||
<div class="tm-field" style="margin-bottom:14px"><span class="tm-field-label">${this._t("panel.template_save_name_label")}</span><input class="tm-input" type="text" data-field="tpl_save_name" placeholder="${this._t("panel.template_save_name_placeholder")}"></div>
|
||
<div class="tm-field" style="margin-bottom:14px"><span class="tm-field-label">${this._t("panel.template_save_icon_label")}</span><input class="tm-input" type="text" data-field="tpl_save_icon" placeholder="mdi:clipboard-list" value="mdi:clipboard-list"></div>
|
||
<label class="tm-field-label" style="display:block;margin-bottom:8px">${this._t("panel.template_save_select_chores")}</label>
|
||
<div class="tm-tpl-checklist">
|
||
${chores.map(c => `
|
||
<label class="tm-tpl-check-row">
|
||
<input type="checkbox" data-tpl-chore-check="${this._esc(c.id)}">
|
||
<span>${this._esc(c.name)}</span>
|
||
<span class="tm-text-muted" style="margin-inline-start:auto">${this._t("panel.pts_display", {count: c.points})}</span>
|
||
</label>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="tm-dialog-foot">
|
||
<button type="button" class="tm-btn" data-act="tpl-save-cancel">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="tpl-save-confirm">${this._t("panel.btn_save_template")}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-audit-row">
|
||
<div class="tm-audit-when">${this._esc(when)}</div>
|
||
<div class="tm-audit-user">${this._esc(a.user_name || a.user_id || "—")}</div>
|
||
<div class="tm-audit-action">${this._esc(a.action || "")}</div>
|
||
<div class="tm-audit-target">${this._esc(a.target || "")}</div>
|
||
</div>`;
|
||
}).join("");
|
||
return `
|
||
<div class="tm-section">
|
||
<div class="tm-section-head">
|
||
<div><h3>${this._t("panel.tab_audit")}</h3><p class="tm-meta">${this._t("panel.audit_hint")}</p></div>
|
||
${entries.length ? `<button type="button" class="tm-btn" data-act="audit-clear"><ha-icon icon="mdi:trash-can-outline"></ha-icon> ${this._t("panel.audit_clear")}</button>` : ""}
|
||
</div>
|
||
<div class="tm-section-body">
|
||
${entries.length ? `
|
||
<div class="tm-audit-row tm-audit-head">
|
||
<div>${this._t("panel.audit_when")}</div><div>${this._t("panel.audit_user")}</div>
|
||
<div>${this._t("panel.audit_action")}</div><div>${this._t("panel.audit_target")}</div>
|
||
</div>
|
||
${rows}
|
||
` : `<div class="tm-meta" style="padding: 8px 0;">${this._t("panel.audit_empty")}</div>`}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// -- Settings tab ------------------------------------------------------
|
||
/**
|
||
* Custom completion sounds (#856).
|
||
*
|
||
* Uploaded clips are listed with preview / rename / delete. Deleting one
|
||
* resets any chore using it back to the default sound server-side, so the
|
||
* confirm copy warns about that rather than leaving it to be discovered.
|
||
*/
|
||
_renderSoundsSection() {
|
||
const list = this._customSounds();
|
||
const busy = this._soundBusy;
|
||
return `
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div>
|
||
<h3>${this._t("panel.settings_sounds_title")}</h3>
|
||
<p class="tm-meta">${this._t("panel.settings_sounds_hint")}</p>
|
||
</div></div>
|
||
<div class="tm-section-body tm-section-pad">
|
||
${list.length ? `
|
||
<div class="tm-sound-list">
|
||
${list.map(s => `
|
||
<div class="tm-sound-item">
|
||
<span class="tm-sound-name">${this._esc(s.name)}</span>
|
||
<div class="tm-sound-item-actions">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview"
|
||
data-value="${this._esc(s.value)}"
|
||
aria-label="${this._esc(this._t("panel.sound_preview"))}"
|
||
title="${this._esc(this._t("panel.sound_preview"))}">▶</button>
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-rename"
|
||
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_rename")}</button>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="sound-delete"
|
||
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_delete")}</button>
|
||
</div>
|
||
</div>`).join("")}
|
||
</div>` : `<p class="tm-meta">${this._t("panel.settings_sounds_empty")}</p>`}
|
||
<div class="tm-sound-upload">
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="sound-upload" ${busy ? "disabled" : ""}>
|
||
${busy ? this._t("panel.sound_uploading") : this._t("panel.sound_upload")}
|
||
</button>
|
||
<span class="tm-field-hint">${this._t("panel.settings_sounds_formats")}</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_renderSettingsTab() {
|
||
const s = this._state.settings || {};
|
||
const notifyServices = Object.keys((this._hass && this._hass.services && this._hass.services.notify) || {});
|
||
const notifyOptions = [
|
||
{ v: "", l: this._t("panel.settings_notify_none") },
|
||
...notifyServices.map(k => ({ v: `notify.${k}`, l: `notify.${k}` })),
|
||
];
|
||
return `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.settings_title")}</h2>
|
||
</div>
|
||
<div class="tm-settings">
|
||
${this._wizardSettingsSection()}
|
||
<div class="tm-section">
|
||
<div class="tm-section-head">
|
||
<div>
|
||
<h3>${this._t("panel.settings_currency_title")}</h3>
|
||
<p class="tm-meta">${this._t("panel.settings_currency_hint")}</p>
|
||
</div>
|
||
</div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_points_name_label")}<small>${this._t("panel.settings_points_name_hint")}</small></div>
|
||
<input type="text" class="tm-input" data-setting="points_name" value="${this._esc(s.points_name || this._t("panel.settings_points_default"))}" placeholder="${this._esc(this._t("panel.settings_points_default"))}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_points_icon_label")}<small>${this._t("panel.settings_points_icon_hint")}</small></div>
|
||
<ha-icon-picker data-setting="points_icon" data-current="${this._esc(s.points_icon || 'mdi:star')}"></ha-icon-picker>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_card_design_label")}<small>${this._t("panel.settings_card_design_hint")}</small></div>
|
||
<select class="tm-select" data-setting="card_design">
|
||
${["classic","playroom","console","cleanpro","accessible","graphite"].map(v => `<option value="${v}" ${v === (s.card_design || "classic") ? "selected" : ""}>${this._esc(this._t("common.design." + v))}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_quick_points_label")}<small>${this._t("panel.settings_quick_points_hint")}</small></div>
|
||
<input type="text" class="tm-input" data-setting="quick_point_amounts" value="${this._esc(s.quick_point_amounts == null ? "" : s.quick_point_amounts)}" placeholder="5, 10, 20">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${this._renderSoundsSection()}
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div>
|
||
<h3>${this._t("panel.settings_parents_title")}</h3>
|
||
<p class="tm-meta">${this._t("panel.settings_parents_hint")}</p>
|
||
</div></div>
|
||
<div class="tm-section-body">
|
||
${(this._haUsers || []).filter(u => !u.is_admin).map(u => `
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._esc(u.name)}</div>
|
||
<ha-switch data-parent-user="${this._esc(u.id)}" aria-label="${this._esc(u.name)}" ${(s.parent_user_ids || []).includes(u.id) ? "checked" : ""}></ha-switch>
|
||
</div>`).join("")
|
||
|| `<div class="tm-section-pad"><p class="tm-meta">${this._t("panel.settings_parents_empty")}</p></div>`}
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_require_linked_child_label")}<small>${this._t("panel.settings_require_linked_child_hint")}</small></div>
|
||
<ha-switch data-setting="require_linked_child" ${s.require_linked_child ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_chore_undo_label")}<small>${this._t("panel.settings_chore_undo_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="0" max="3600" step="1" data-setting="chore_undo_seconds" aria-label="${this._esc(this._t("panel.settings_chore_undo_label"))}" value="${this._num(s.chore_undo_seconds, 0)}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_history_title")}</h3></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_retention_label")}<small>${this._t("panel.settings_retention_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${this._num(s.history_days, 90)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_streak_reset_label")}<small>${this._t("panel.settings_streak_reset_hint")}</small></div>
|
||
<select class="tm-select" data-setting="streak_reset_mode">
|
||
${STREAK_MODES.map(m => `<option value="${m.v}" ${m.v === (s.streak_reset_mode || "reset") ? "selected" : ""}>${this._esc(this._t(m.lk))}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_streak_all_chores_label")}<small>${this._t("panel.settings_streak_all_chores_hint")}</small></div>
|
||
<ha-switch data-setting="streak_requires_all_chores" ${s.streak_requires_all_chores ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_streak_freeze_max_label")}<small>${this._t("panel.settings_streak_freeze_max_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="0" max="10" data-setting="streak_freeze_max" value="${this._num(s.streak_freeze_max, 2)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_streak_freeze_earn_label")}<small>${this._t("panel.settings_streak_freeze_earn_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="0" max="365" data-setting="streak_freeze_earn_every" value="${this._num(s.streak_freeze_earn_every, 7)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_weekend_multiplier_label")}<small>${this._t("panel.settings_weekend_multiplier_hint")}</small></div>
|
||
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${this._num(s.weekend_multiplier, 1.0)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_birthday_multiplier_label")}<small>${this._t("panel.settings_birthday_multiplier_hint")}</small></div>
|
||
<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="birthday_points_multiplier" value="${this._num(s.birthday_points_multiplier, 2)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_birthday_chores_off_label")}<small>${this._t("panel.settings_birthday_chores_off_hint")}</small></div>
|
||
<ha-switch data-setting="birthday_chores_off" ${s.birthday_chores_off ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_difficulty_multipliers_label")}<small>${this._t("panel.settings_difficulty_multipliers_hint")}</small></div>
|
||
<div class="tm-difficulty-mults">
|
||
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${this._num(s.difficulty_multiplier_easy, 0.5)}"></label>
|
||
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${this._num(s.difficulty_multiplier_medium, 1.0)}"></label>
|
||
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${this._num(s.difficulty_multiplier_hard, 2.0)}"></label>
|
||
</div>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_quality_rating_label")}<small>${this._t("panel.settings_quality_rating_hint")}</small></div>
|
||
<ha-switch data-setting="quality_rating_enabled" ${s.quality_rating_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_quality_rating_multipliers_label")}<small>${this._t("panel.settings_quality_rating_multipliers_hint")}</small></div>
|
||
<div class="tm-difficulty-mults">
|
||
<label>★<input type="number" class="tm-input" step="0.05" min="0" max="5" data-setting="quality_rating_multiplier_1" value="${this._num(s.quality_rating_multiplier_1, 0.75)}"></label>
|
||
<label>★★<input type="number" class="tm-input" step="0.05" min="0" max="5" data-setting="quality_rating_multiplier_2" value="${this._num(s.quality_rating_multiplier_2, 1.0)}"></label>
|
||
<label>★★★<input type="number" class="tm-input" step="0.05" min="0" max="5" data-setting="quality_rating_multiplier_3" value="${this._num(s.quality_rating_multiplier_3, 1.25)}"></label>
|
||
</div>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_calendar_projection_label")}<small>${this._t("panel.settings_calendar_projection_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${this._num(s.calendar_projection_days, 14)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_surprise_enabled_label")}<small>${this._t("panel.settings_surprise_enabled_hint")}</small></div>
|
||
<ha-switch data-setting="surprise_bonus_enabled" ${s.surprise_bonus_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_surprise_params_label")}<small>${this._t("panel.settings_surprise_params_hint")}</small></div>
|
||
<div class="tm-difficulty-mults">
|
||
<label>${this._t("panel.settings_surprise_chance")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="surprise_bonus_chance" value="${this._num(s.surprise_bonus_chance, 15)}"></label>
|
||
<label>${this._t("panel.settings_surprise_min")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${this._num(s.surprise_bonus_min, 5)}"></label>
|
||
<label>${this._t("panel.settings_surprise_max")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${this._num(s.surprise_bonus_max, 20)}"></label>
|
||
</div>
|
||
</div>
|
||
<div class="tm-setting-row tm-setting-stack">
|
||
<div class="tm-setting-label">${this._t("panel.settings_unlock_allowlist")}<small>${this._t("panel.settings_unlock_allowlist_hint")}</small></div>
|
||
<div class="tm-allowlist">
|
||
${(s.unlock_allowlist || []).map(e => `
|
||
<span class="tm-allow-chip">
|
||
${this._esc(e)}
|
||
<button type="button" data-act="remove-allowlist" data-id="${this._esc(e)}" title="${this._t("panel.tooltip_remove")}">✕</button>
|
||
</span>
|
||
`).join("") || `<span class="tm-field-hint">${this._t("panel.settings_unlock_allowlist_empty")}</span>`}
|
||
</div>
|
||
<div class="tm-field-row" style="margin-top:8px">
|
||
${this._entityPickerField("", "_allowlist_pick", "",
|
||
["switch", "light", "media_player", "input_boolean", "automation", "script", "fan", "climate"],
|
||
this._t("panel.settings_unlock_allowlist_add"))}
|
||
</div>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_roulette_enabled")}<small>${this._t("panel.settings_roulette_enabled_hint")}</small></div>
|
||
<ha-switch data-setting="roulette_enabled" ${s.roulette_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_roulette_section")}</div>
|
||
<div class="tm-difficulty-mults">
|
||
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${this._num(s.roulette_multiplier, 2)}"></label>
|
||
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${this._num(s.roulette_daily_spins, 1)}"></label>
|
||
</div>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_decay_enabled_label")}<small>${this._t("panel.settings_decay_enabled_hint")}</small></div>
|
||
<ha-switch data-setting="points_decay_enabled" ${s.points_decay_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_decay_params_label")}<small>${this._t("panel.settings_decay_params_hint")}</small></div>
|
||
<div class="tm-difficulty-mults">
|
||
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${this._num(s.points_decay_percent, 10)}"></label>
|
||
<label>${this._t("panel.settings_decay_period")}
|
||
<select class="tm-select" data-setting="points_decay_period">
|
||
<option value="weekly" ${s.points_decay_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
|
||
<option value="monthly" ${(s.points_decay_period || "monthly") === "monthly" ? "selected" : ""}>${this._t("panel.reward_restock_monthly")}</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_allow_negative_label")}<small>${this._t("panel.settings_allow_negative_hint")}</small></div>
|
||
<ha-switch data-setting="allow_negative_balance" ${s.allow_negative_balance ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_level_step_label")}<small>${this._t("panel.settings_level_step_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${this._num(s.level_xp_step, 100)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_spendcap_enabled_label")}<small>${this._t("panel.settings_spendcap_enabled_hint")}</small></div>
|
||
<ha-switch data-setting="spend_cap_enabled" ${s.spend_cap_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_spendcap_params_label")}<small>${this._t("panel.settings_spendcap_params_hint")}</small></div>
|
||
<div class="tm-difficulty-mults">
|
||
<label>${this._t("panel.settings_spendcap_amount")}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${this._num(s.spend_cap_amount, 0)}"></label>
|
||
<label>${this._t("panel.settings_decay_period")}
|
||
<select class="tm-select" data-setting="spend_cap_period">
|
||
<option value="weekly" ${s.spend_cap_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
|
||
<option value="monthly" ${(s.spend_cap_period || "weekly") === "monthly" ? "selected" : ""}>${this._t("panel.reward_restock_monthly")}</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_interest_enabled_label")}<small>${this._t("panel.settings_interest_enabled_hint")}</small></div>
|
||
<ha-switch data-setting="interest_enabled" ${s.interest_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_interest_params_label")}<small>${this._t("panel.settings_interest_params_hint")}</small></div>
|
||
<div class="tm-difficulty-mults">
|
||
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${this._num(s.interest_percent, 5)}"></label>
|
||
<label>${this._t("panel.settings_decay_period")}
|
||
<select class="tm-select" data-setting="interest_period">
|
||
<option value="weekly" ${(s.interest_period || "weekly") === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
|
||
<option value="monthly" ${s.interest_period === "monthly" ? "selected" : ""}>${this._t("panel.reward_restock_monthly")}</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${this._renderTimePeriodsSection()}
|
||
|
||
${this._renderVacationSection()}
|
||
|
||
${this._renderInspectionsSection()}
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_bonuses_title")}</h3></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_streak_milestones_label")}<small>${this._t("panel.settings_streak_milestones_hint")}</small></div>
|
||
<ha-switch data-setting="streak_milestones_enabled" ${s.streak_milestones_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_perfect_week_label")}<small>${this._t("panel.settings_perfect_week_hint")}</small></div>
|
||
<ha-switch data-setting="perfect_week_enabled" ${s.perfect_week_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_perfect_week_all_chores_label")}<small>${this._t("panel.settings_perfect_week_all_chores_hint")}</small></div>
|
||
<ha-switch data-setting="perfect_week_requires_all_chores" ${s.perfect_week_requires_all_chores ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_perfect_week_bonus_label")}<small>${this._t("panel.settings_perfect_week_bonus_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${this._num(s.perfect_week_bonus, 50)}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_celebration_notify_label")}<small>${this._t("panel.settings_celebration_notify_hint")}</small></div>
|
||
<ha-switch data-setting="celebration_notify" ${s.celebration_notify ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_celebration_tier_label")}<small>${this._t("panel.settings_celebration_tier_hint")}</small></div>
|
||
<select class="tm-select" data-setting="celebration_notify_min_tier">
|
||
<option value="1" ${String(s.celebration_notify_min_tier ?? 2) === "1" ? "selected" : ""}>${this._t("panel.settings_celebration_tier_1")}</option>
|
||
<option value="2" ${String(s.celebration_notify_min_tier ?? 2) === "2" ? "selected" : ""}>${this._t("panel.settings_celebration_tier_2")}</option>
|
||
<option value="3" ${String(s.celebration_notify_min_tier ?? 2) === "3" ? "selected" : ""}>${this._t("panel.settings_celebration_tier_3")}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_avatars_title")}</h3></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_avatars_label")}<small>${this._t("panel.settings_avatars_hint", {count: (this._state.avatar_catalog || []).length})}</small></div>
|
||
<button type="button" class="tm-btn" data-act="manage-avatars">${this._t("panel.settings_avatars_btn")}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_chore_rotation_title")}</h3></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_skip_confirm_label")}<small>${this._t("panel.settings_skip_confirm_hint")}</small></div>
|
||
<ha-switch data-setting="skip_confirmation_enabled" ${s.skip_confirmation_enabled !== false ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_notifications_title")}</h3></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_notify_label")}<small>${this._t("panel.settings_notify_hint")}</small></div>
|
||
<select class="tm-select" data-setting="notify_service">
|
||
${notifyOptions.map(o => `<option value="${this._esc(o.v)}" ${o.v === (s.notify_service || "") ? "selected" : ""}>${this._esc(o.l)}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${this._renderRecapsSection()}
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.settings_show_ids_label")}</h3><p class="tm-meta">${this._t("panel.settings_show_ids_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.settings_show_ids_label")}</div>
|
||
<ha-switch ${this._showIds ? "checked" : ""} data-local="show-ids"></ha-switch>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.backup_title")}</h3><p class="tm-meta">${this._t("panel.backup_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-period-actions">
|
||
<button type="button" class="tm-btn" data-act="config-export"><ha-icon icon="mdi:download"></ha-icon> ${this._t("panel.backup_export")}</button>
|
||
<button type="button" class="tm-btn" data-act="config-import"><ha-icon icon="mdi:upload"></ha-icon> ${this._t("panel.backup_import")}</button>
|
||
<input type="file" accept="application/json,.json" data-role="config-import-file" style="display:none">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.family_goal_title")}</h3><p class="tm-meta">${this._t("panel.family_goal_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.family_goal_enabled")}</div>
|
||
<ha-switch data-setting="family_goal_enabled" ${s.family_goal_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.family_goal_name")}</div>
|
||
<input type="text" class="tm-input" maxlength="120" data-setting="family_goal_name" value="${this._esc(s.family_goal_name || "")}" placeholder="${this._esc(this._t("panel.family_goal_name_ph"))}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.family_goal_target")}<small>${this._t("panel.family_goal_target_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="1" max="10000000" data-setting="family_goal_target" value="${this._esc(String(s.family_goal_target || 500))}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.family_goal_reward")}</div>
|
||
<input type="text" class="tm-input" maxlength="200" data-setting="family_goal_reward" value="${this._esc(s.family_goal_reward || "")}" placeholder="${this._esc(this._t("panel.family_goal_reward_ph"))}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.allowance_title")}</h3><p class="tm-meta">${this._t("panel.allowance_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.allowance_enabled")}</div>
|
||
<ha-switch data-setting="allowance_enabled" ${s.allowance_enabled ? "checked" : ""}></ha-switch>
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.allowance_rate")}<small>${this._t("panel.allowance_rate_hint")}</small></div>
|
||
<input type="number" class="tm-input" min="1" max="100000" data-setting="allowance_rate" value="${this._esc(String(s.allowance_rate || 10))}">
|
||
</div>
|
||
<div class="tm-setting-row">
|
||
<div class="tm-setting-label">${this._t("panel.allowance_currency")}</div>
|
||
<input type="text" class="tm-input" maxlength="8" data-setting="allowance_currency" value="${this._esc(s.allowance_currency || "")}" placeholder="£">
|
||
</div>
|
||
${(this._state.allowance_payouts || []).length ? `
|
||
<div class="tm-section-pad">
|
||
<h4 class="tm-subhead">${this._t("panel.allowance_ledger")}</h4>
|
||
<div class="tm-table-wrap"><table class="tm-table"><tbody>
|
||
${(this._state.allowance_payouts || []).slice(0, 10).map(p => `
|
||
<tr>
|
||
<td>${this._esc((p.date || "").slice(0, 10))}</td>
|
||
<td>${this._esc(p.child_name || "")}</td>
|
||
<td style="text-align:end">${this._num(p.points)} ${this.getAttribute?.("dir") === "rtl" ? "←" : "→"} ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody></table></div>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-section">
|
||
<div class="tm-section-head"><div><h3>${this._t("panel.ics_title")}</h3><p class="tm-meta">${this._t("panel.ics_hint")}</p></div></div>
|
||
<div class="tm-section-body">
|
||
${this._icsUrl ? `
|
||
<input type="text" readonly value="${this._esc(this._icsUrl)}" data-role="ics-url" style="width:100%;font-family:monospace;font-size:12px;padding:6px;margin-bottom:8px" onclick="this.select()">
|
||
<div class="tm-period-actions">
|
||
<button type="button" class="tm-btn" data-act="ics-copy"><ha-icon icon="mdi:content-copy"></ha-icon> ${this._t("panel.ics_copy")}</button>
|
||
<button type="button" class="tm-btn" data-act="ics-regenerate"><ha-icon icon="mdi:refresh"></ha-icon> ${this._t("panel.ics_regenerate")}</button>
|
||
</div>
|
||
` : `
|
||
<div class="tm-period-actions">
|
||
<button type="button" class="tm-btn" data-act="ics-show"><ha-icon icon="mdi:calendar-export"></ha-icon> ${this._t("panel.ics_show")}</button>
|
||
</div>
|
||
`}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-settings-foot">
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-settings">${this._t("panel.btn_save_settings")}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// -- Notifications tab -------------------------------------------------
|
||
_renderNotificationsTab() {
|
||
if (!this._notifState) {
|
||
return `<div class="tm-card tm-empty"><p>${this._t("panel.notif_loading")}</p></div>`;
|
||
}
|
||
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 `
|
||
<div class="tm-toolbar">
|
||
<h2 class="tm-toolbar-title">${this._t("panel.notif_tab_title")}</h2>
|
||
</div>
|
||
|
||
${this._renderNotifRecipientsSection(ns, services)}
|
||
${this._renderNotifMatrixSection(ns, recipients)}
|
||
${this._renderNotifCustomSection(ns, recipients)}
|
||
|
||
<div class="tm-notif-info-box">
|
||
<strong>${this._t("panel.notif_power_user_note_title")}</strong>
|
||
${this._t("panel.notif_power_user_note_body")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderNotifRecipientsSection(ns, services) {
|
||
const optTags = (selected) => {
|
||
const opts = [`<option value=""${!selected ? " selected" : ""}>${this._t("panel.notif_none")}</option>`];
|
||
for (const s of services) opts.push(`<option value="${this._esc(s)}"${s === selected ? " selected" : ""}>${this._esc(s)}</option>`);
|
||
return opts.join("");
|
||
};
|
||
return `
|
||
<div class="tm-card" style="margin-bottom:16px">
|
||
<h3>${this._t("panel.notif_section_recipients")}</h3>
|
||
<p class="tm-meta">${this._t("panel.notif_section_recipients_desc")}</p>
|
||
<div class="tm-grid-2">
|
||
<div>
|
||
<h4>${this._t("panel.notif_recipients_children")}</h4>
|
||
${ns.recipients.children.map(c => {
|
||
const cid = c.id.replace(/^child:/, "");
|
||
return `
|
||
<div class="tm-notif-recipient" data-quiet-row style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--tm-border,#3a3a3a)">
|
||
<div style="flex:1;min-width:120px"><strong>${this._esc(c.name)}</strong></div>
|
||
<select class="tm-notif-select" data-act="notif-set-child-notify" data-child-id="${this._esc(cid)}">
|
||
${optTags(c.notify_service)}
|
||
</select>
|
||
<div class="tm-meta" style="display:flex;align-items:center;gap:6px;width:100%" title="${this._t("panel.notif_quiet_hours_desc")}">
|
||
<ha-icon icon="mdi:bell-sleep" style="--mdc-icon-size:16px"></ha-icon>
|
||
<span>${this._t("panel.notif_quiet_hours_label")}</span>
|
||
<input type="time" class="tm-notif-time-input" data-act="notif-set-child-quiet" data-quiet-bound="start" data-child-id="${this._esc(cid)}" value="${this._esc(c.quiet_hours_start || "")}" style="margin:0;width:90px">
|
||
<span>–</span>
|
||
<input type="time" class="tm-notif-time-input" data-act="notif-set-child-quiet" data-quiet-bound="end" data-child-id="${this._esc(cid)}" value="${this._esc(c.quiet_hours_end || "")}" style="margin:0;width:90px">
|
||
</div>
|
||
</div>
|
||
`;}).join("")}
|
||
</div>
|
||
<div>
|
||
<h4>${this._t("panel.notif_recipients_parents")}</h4>
|
||
${ns.recipients.parents.map(p => `
|
||
<div class="tm-notif-recipient" style="display:flex;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--tm-border,#3a3a3a)">
|
||
<input type="text" value="${this._esc(p.name)}" data-act="notif-rename-parent" data-parent-id="${this._esc(p.id)}" style="flex:1;min-width:60px;background:transparent;border:none;color:inherit;font-size:14px;font-weight:500">
|
||
<select class="tm-notif-select" data-act="notif-set-parent-notify" data-parent-id="${this._esc(p.id)}">
|
||
${optTags(p.notify_service)}
|
||
</select>
|
||
<button type="button" class="tm-icon-btn" data-act="notif-delete-parent" data-parent-id="${this._esc(p.id)}" title="${this._t("panel.notif_remove_parent")}">×</button>
|
||
</div>
|
||
`).join("")}
|
||
<button type="button" class="tm-btn" data-act="notif-add-parent" style="margin-top:10px;width:100%">+ ${this._t("panel.notif_add_parent")}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderNotifMatrixSection(ns, recipients) {
|
||
return `
|
||
<div class="tm-card" style="margin-bottom:16px">
|
||
<h3>${this._t("panel.notif_section_matrix")}</h3>
|
||
<p class="tm-meta">${this._t("panel.notif_section_matrix_desc")}</p>
|
||
<div class="tm-meta" style="margin:4px 0 12px;display:flex;flex-wrap:wrap;align-items:center;gap:8px">
|
||
<label style="font-weight:500">${this._t("panel.notif_nav_url_global_label")}</label>
|
||
<input type="text" class="tm-input" style="flex:1;min-width:180px"
|
||
value="${this._esc((ns.settings && ns.settings.notification_nav_url) || "")}"
|
||
data-act="notif-set-nav-url-global" placeholder="/taskmate-admin">
|
||
<div class="tm-meta" style="flex-basis:100%">${this._t("panel.notif_nav_url_global_hint")}</div>
|
||
</div>
|
||
<div class="tm-meta" style="margin:4px 0 12px;display:flex;flex-wrap:wrap;align-items:center;gap:8px">
|
||
<label style="font-weight:500">${this._t("panel.notif_group_global_label")}</label>
|
||
<input type="text" class="tm-input" style="flex:1;min-width:180px" maxlength="64"
|
||
value="${this._esc((ns.settings && ns.settings.notification_group) || "")}"
|
||
data-act="notif-set-group-global" placeholder="taskmate">
|
||
<div class="tm-meta" style="flex-basis:100%">${this._t("panel.notif_group_global_hint")}</div>
|
||
</div>
|
||
<div class="tm-table-wrap">
|
||
<table class="tm-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="min-width:280px">${this._t("panel.notif_matrix_col_notification")}</th>
|
||
${recipients.map(r => `<th style="text-align:center;min-width:80px">${this._esc(r.name)}</th>`).join("")}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${ns.types.map(t => this._renderNotifMatrixRow(t, ns.config[t.id] || { master_enabled: false, routes: {} }, recipients, ns.settings)).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderNotifMatrixRow(t, c, recipients, settings) {
|
||
const active = !!c.master_enabled;
|
||
return `
|
||
<tr class="${active ? "" : "tm-row-disabled"}">
|
||
<td>
|
||
<div style="display:flex;gap:10px;align-items:flex-start">
|
||
<input type="checkbox" class="tm-notif-switch" data-act="notif-set-master" data-type-id="${this._esc(t.id)}" ${active ? "checked" : ""}>
|
||
<div style="max-width:240px;white-space:normal">
|
||
<strong>${this._t("notification." + t.id + ".name")}</strong>
|
||
<div class="tm-meta">${this._t("notification." + t.id + ".description")}</div>
|
||
${t.id === "streak_at_risk" ? `
|
||
<div class="tm-meta" style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||
${this._t("panel.notif_streak_cutoff_label")}
|
||
<input type="time" class="tm-notif-time-input" value="${this._esc(settings.streak_at_risk_cutoff_time || "20:00")}" data-act="notif-set-streak-cutoff" style="display:inline;margin:0;width:90px">
|
||
</div>
|
||
` : ""}
|
||
${t.id === "mandatory_reminder" ? `
|
||
<div class="tm-meta" style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||
${this._t("panel.notif_escalation_reminder_label")}
|
||
<input type="number" min="1" max="1440" class="tm-notif-time-input" value="${this._esc(String(settings.mandatory_escalation_reminder_minutes || 30))}" data-act="notif-set-escalation" data-esc-field="reminder_minutes" style="display:inline;margin:0;width:70px">
|
||
${this._t("panel.notif_escalation_minutes_suffix")}
|
||
</div>
|
||
` : ""}
|
||
${t.id === "presence_arrival" ? `
|
||
<div class="tm-meta" style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||
${this._t("panel.notif_presence_min_away_label")}
|
||
<span style="display:inline-flex;align-items:center;gap:8px;white-space:nowrap">
|
||
<input type="number" min="0" max="1440" class="tm-notif-time-input" value="${this._esc(String(settings.presence_arrival_min_away ?? 30))}" data-act="notif-set-presence-arrival" style="display:inline;margin:0;width:70px">
|
||
${this._t("panel.notif_escalation_minutes_suffix")}
|
||
</span>
|
||
</div>
|
||
` : ""}
|
||
${t.id === "mandatory_parent_alert" ? `
|
||
<div class="tm-meta" style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||
${this._t("panel.notif_escalation_parent_label")}
|
||
<input type="number" min="1" max="1440" class="tm-notif-time-input" value="${this._esc(String(settings.mandatory_escalation_parent_minutes || 120))}" data-act="notif-set-escalation" data-esc-field="parent_minutes" style="display:inline;margin:0;width:70px">
|
||
${this._t("panel.notif_escalation_minutes_suffix")}
|
||
</div>
|
||
` : ""}
|
||
<div style="margin-top:6px">
|
||
<button type="button" class="tm-btn" data-act="notif-send-test" data-type-id="${this._esc(t.id)}" style="padding:2px 10px;font-size:12px">
|
||
<ha-icon class="tm-rtl-flip" icon="mdi:send" style="--mdc-icon-size:14px"></ha-icon> ${this._t("panel.notif_send_test")}
|
||
</button>
|
||
</div>
|
||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||
<input type="text" class="tm-notif-time-input" style="width:150px"
|
||
value="${this._esc(c.nav_url || "")}"
|
||
data-act="notif-set-nav-url-type" data-type-id="${this._esc(t.id)}"
|
||
placeholder="${this._esc(this._t("panel.notif_nav_url_row_placeholder"))}">
|
||
<input type="text" class="tm-notif-time-input" style="width:150px" maxlength="64"
|
||
value="${this._esc(c.group || "")}"
|
||
data-act="notif-set-group-type" data-type-id="${this._esc(t.id)}"
|
||
title="${this._esc(this._t("panel.notif_group_global_label"))}"
|
||
placeholder="${this._esc(this._t("panel.notif_group_row_placeholder"))}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
${recipients.map(r => this._renderNotifMatrixCell(t, c, r)).join("")}
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
_renderNotifMatrixCell(t, c, r) {
|
||
const valid =
|
||
t.audience === "both" ||
|
||
(t.audience === "child" && r.kind === "child") ||
|
||
(t.audience === "parent" && r.kind === "parent");
|
||
if (!valid) return `<td class="tm-notif-matrix-cell" style="color:var(--tm-text-muted,#666)">—</td>`;
|
||
const route = c.routes[r.id] || { enabled: false, time: null };
|
||
return `
|
||
<td class="tm-notif-matrix-cell">
|
||
<input type="checkbox" class="tm-notif-switch" data-act="notif-set-route" data-type-id="${this._esc(t.id)}" data-recipient-id="${this._esc(r.id)}" data-time="${this._esc(route.time || "")}" ${route.enabled ? "checked" : ""} ${c.master_enabled ? "" : "disabled"}>
|
||
${t.per_recipient_time ? `
|
||
<input type="time" class="tm-notif-time-input" data-act="notif-set-route-time" data-type-id="${this._esc(t.id)}" data-recipient-id="${this._esc(r.id)}" value="${this._esc(route.time || (t.id === "birthday" ? "08:00" : "20:00"))}" ${(c.master_enabled && route.enabled) ? "" : "disabled"}>
|
||
` : ""}
|
||
</td>
|
||
`;
|
||
}
|
||
|
||
_renderNotifCustomSection(ns, recipients) {
|
||
return `
|
||
<div class="tm-card" style="margin-bottom:16px">
|
||
<div style="display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center">
|
||
<h3 style="margin:0">${this._t("panel.notif_section_custom")}</h3>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="notif-add-custom">+ ${this._t("panel.notif_add_custom")}</button>
|
||
</div>
|
||
<p class="tm-meta">${this._t("panel.notif_section_custom_desc")}</p>
|
||
${(ns.custom || []).length === 0 ? `<div class="tm-empty"><p>${this._t("panel.notif_custom_empty")}</p></div>` : ""}
|
||
${(ns.custom || []).map(n => this._renderNotifCustomCard(n, recipients)).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderNotifCustomCard(n, recipients) {
|
||
const days = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
|
||
return `
|
||
<div class="tm-notif-custom-card">
|
||
<div class="tm-notif-custom-row">
|
||
<input type="text" class="tm-input" value="${this._esc(n.name)}" data-act="notif-update-custom" data-custom-id="${this._esc(n.id)}" data-field="name" placeholder="${this._t("panel.notif_custom_name_placeholder")}">
|
||
<input type="time" class="tm-input" value="${this._esc(n.time)}" data-act="notif-update-custom" data-custom-id="${this._esc(n.id)}" data-field="time">
|
||
<input type="checkbox" class="tm-notif-switch" data-act="notif-toggle-custom" data-custom-id="${this._esc(n.id)}" ${n.enabled ? "checked" : ""}>
|
||
<button type="button" class="tm-notif-delete" data-act="notif-delete-custom" data-custom-id="${this._esc(n.id)}" aria-label="${this._t("panel.btn_delete")}">×</button>
|
||
</div>
|
||
<input type="text" class="tm-input tm-notif-custom-msg" value="${this._esc(n.message_template)}" data-act="notif-update-custom" data-custom-id="${this._esc(n.id)}" data-field="message_template" placeholder="${this._t("panel.notif_custom_message_placeholder")}">
|
||
<div class="tm-notif-toggle-group">
|
||
${days.map((d, i) => {
|
||
const on = !!(n.day_mask & (1 << i));
|
||
return `
|
||
<label class="tm-notif-toggle tm-notif-toggle-day ${on ? "tm-notif-toggle-on" : ""}">
|
||
<input type="checkbox" data-act="notif-toggle-day" data-custom-id="${this._esc(n.id)}" data-day="${i}" ${on ? "checked" : ""}>
|
||
<span>${this._t("panel.notif_day_" + d.toLowerCase())}</span>
|
||
</label>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
<div class="tm-notif-toggle-group">
|
||
${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 `
|
||
<button type="button" class="tm-notif-toggle tm-notif-toggle-pill ${on ? "tm-notif-toggle-on" : ""}"
|
||
data-act="notif-toggle-recipient" data-custom-id="${this._esc(n.id)}" data-recipient-id="${this._esc(r.id)}"
|
||
aria-pressed="${on ? "true" : "false"}">
|
||
${this._esc(name)}
|
||
</button>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// -- 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(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-gift">${this._t("panel.gift_send")}</button>`
|
||
);
|
||
}
|
||
|
||
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(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-danger" data-act="save-adjust-remove">${this._t("panel.adjust_remove")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-adjust-add">${this._t("panel.adjust_add")}</button>`
|
||
);
|
||
}
|
||
|
||
// `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),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-swap">${this._t("panel.swap_create")}</button>`
|
||
);
|
||
}
|
||
|
||
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 `
|
||
<div class="tm-scrim" data-act="scrim">
|
||
<div class="tm-dialog" style="max-width:680px">
|
||
<div class="tm-dialog-head">
|
||
<h2>${isEdit ? this._t("panel.template_edit_title") : this._t("panel.template_create_title")}</h2>
|
||
<button type="button" class="tm-icon-btn" data-act="close-dialog">✕</button>
|
||
</div>
|
||
<div class="tm-dialog-body">
|
||
<div class="tm-field-row" style="margin-bottom:16px">
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_name_label")}</span><input class="tm-input" type="text" data-field="name" value="${this._esc(d.name)}"></div>
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_icon_label")}</span><input class="tm-input" type="text" data-field="icon" value="${this._esc(d.icon)}"></div>
|
||
</div>
|
||
<label class="tm-field-label" style="display:block;margin-bottom:8px">${this._t("panel.template_chores_in_template")}</label>
|
||
${(d.chores || []).map((c, i) => `
|
||
<div class="tm-tpl-preview-card" style="margin-bottom:8px">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px">
|
||
<span style="font-weight:600;flex:1">${this._esc(c.name || this._t("panel.template_unnamed"))}</span>
|
||
<span class="tm-meta">${this._t("panel.pts_display", {count: c.points || 0})}</span>
|
||
<button type="button" class="tm-tpl-remove" data-act="tpl-dialog-remove-chore" data-idx="${i}">✕</button>
|
||
</div>
|
||
<div style="padding:0 14px 12px;border-top:1px solid var(--tm-border-soft)">
|
||
<div class="tm-field-row" style="margin-top:10px">
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-dialog-field="name" data-tpl-dialog-idx="${i}" value="${this._esc(c.name)}"></div>
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${this._num(c.points)}" min="0"></div>
|
||
</div>
|
||
<div class="tm-field-row">
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
|
||
<select class="tm-select" data-tpl-dialog-field="time_category" data-tpl-dialog-idx="${i}">
|
||
${this._timeCategoryOptions().map(t => `<option value="${this._esc(t.v)}" ${c.time_category === t.v ? "selected" : ""}>${this._esc(t.l)}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_schedule_label")}</span>
|
||
<select class="tm-select" data-tpl-dialog-field="schedule_mode" data-tpl-dialog-idx="${i}">
|
||
${SCHEDULE_MODES.map(s => `<option value="${s.v}" ${c.schedule_mode === s.v ? "selected" : ""}>${this._t(s.lk)}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
<button type="button" class="tm-btn" data-act="tpl-dialog-add-chore" style="margin-top:8px">${this._t("panel.btn_add_chore_template")}</button>
|
||
</div>
|
||
<div class="tm-dialog-foot">
|
||
<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="${isEdit ? "tpl-save-edited" : "tpl-save-created"}">${isEdit ? this._t("panel.btn_save_changes") : this._t("panel.btn_create_template")}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_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(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-child">${this._t("panel.btn_save")}</button>`
|
||
);
|
||
}
|
||
|
||
/** 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 `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(this._t("panel.child_kiosk_pin_label"))}</span>
|
||
<input class="tm-input" data-field="kiosk_pin" type="password" inputmode="numeric" pattern="[0-9]*"
|
||
maxlength="4" autocomplete="new-password" value="${this._esc(d.kiosk_pin || "")}"
|
||
placeholder="${d.has_kiosk_pin ? "••••" : ""}" ${clearing ? "disabled" : ""}>
|
||
<span class="tm-field-hint">${this._esc(this._t("panel.child_kiosk_pin_hint"))}${d.has_kiosk_pin
|
||
? " " + this._esc(this._t("panel.child_kiosk_pin_set")) : ""}</span>
|
||
</div>
|
||
${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) => `
|
||
<section class="tm-drawer-sec">
|
||
<h3 class="tm-drawer-sec-h">${this._t(`panel.chore_sec_${key}`)}</h3>
|
||
${parts.join("")}
|
||
</section>`;
|
||
|
||
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"),
|
||
`<div class="tm-field-row">
|
||
${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 || "")}
|
||
</div>`,
|
||
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 ? `<div class="tm-field-row">
|
||
${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")}
|
||
</div>
|
||
<div class="tm-field-row">
|
||
${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")}
|
||
</div>` : `<div class="tm-field-row">
|
||
${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")}
|
||
</div>`,
|
||
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 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.chore_assign_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.assigned_to || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-assigned" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.chore_assign_hint")}</span>
|
||
</div>
|
||
` : `<div class="tm-field"><span class="tm-field-hint">${this._t("panel.chore_assign_no_children")}</span></div>`) : "",
|
||
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 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.chore_days_label")}</span>
|
||
<div class="tm-day-row">
|
||
${DAYS.map(day => `
|
||
<button type="button" class="tm-day-btn ${(d.due_days || []).includes(day.v) ? "tm-day-on" : ""}" data-act="toggle-day" data-day="${day.v}">${this._t(day.lk)}</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
${this._field(this._t("panel.chore_weekly_target_label"), "weekly_target", d.weekly_target || 0, "number",
|
||
this._t("panel.chore_weekly_target_hint"))}
|
||
` : "",
|
||
showRecurring ? `
|
||
<div class="tm-field-row">
|
||
${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"))}
|
||
</div>
|
||
<div class="tm-field-row">
|
||
${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)}
|
||
</div>
|
||
` : "",
|
||
this._dateField(this._t("panel.chore_expires_label"), "expires_on", d.expires_on, this._t("panel.chore_expires_hint")),
|
||
`<div class="tm-field-row">
|
||
${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")}
|
||
</div>`,
|
||
(this._state.chores || []).filter(x => x.id !== d.id).length > 0 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.chore_depends_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${(this._state.chores || []).filter(x => x.id !== d.id).map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.depends_on || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-depends" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.chore_depends_hint")}</span>
|
||
</div>
|
||
` : "",
|
||
]),
|
||
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 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-hint">${this._t("panel.chore_group_hint", {name: this._esc(memberInGroup.name), policy: memberInGroup.policy})}</span>
|
||
</div>
|
||
` : "",
|
||
`<details class="tm-advanced" data-section="bonus_subtasks"${this._dialog._openAdvanced?.has("bonus_subtasks") ? " open" : ""}>
|
||
<summary>${this._t("panel.chore_advanced_bonus_subtasks")}</summary>
|
||
<div>
|
||
<span class="tm-field-hint" style="margin-bottom:8px;display:block">${this._t("panel.chore_advanced_bonus_subtasks_hint")}</span>
|
||
${(d.bonus_subtasks || []).map((b, idx) => `
|
||
<div class="tm-field-row" style="align-items:flex-end;gap:6px;margin-bottom:6px">
|
||
<div class="tm-field" style="flex:2;margin:0"><input class="tm-input" placeholder="${this._t("panel.chore_subtask_name_placeholder")}" value="${this._esc(b.name)}" data-field="bonus_subtasks[${idx}].name"></div>
|
||
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${this._num(b.points)}" data-field="bonus_subtasks[${idx}].points"></div>
|
||
<button type="button" class="tm-btn tm-btn-icon" data-act="remove-bonus-subtask" data-idx="${idx}" title="${this._t("panel.tooltip_remove")}" style="padding:6px 10px">✕</button>
|
||
</div>
|
||
`).join("")}
|
||
<button type="button" class="tm-btn" data-act="add-bonus-subtask" style="margin-top:4px">${this._t("panel.btn_add_bonus_subtask")}</button>
|
||
</div>
|
||
</details>`,
|
||
this._renderChoreTags(d),
|
||
`<details class="tm-advanced" data-section="visibility"${this._dialog._openAdvanced?.has("visibility") ? " open" : ""}>
|
||
<summary>${this._t("panel.chore_advanced_visibility")}</summary>
|
||
<div>
|
||
${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"))}
|
||
<div class="tm-field-row">
|
||
${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"))}
|
||
</div>
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.chore_calendar_label")}</span>
|
||
${calendarEntities.length === 0 ? `<span class="tm-field-hint">${this._t("panel.chore_calendar_no_entities")}</span>` : `
|
||
<div class="tm-chip-row">
|
||
${calendarEntities.map(cid => `
|
||
<button type="button" class="tm-chip-btn ${(d.publish_calendar_entities || []).includes(cid) ? "tm-chip-on" : ""}" data-act="toggle-calendar" data-id="${this._esc(cid)}">
|
||
${this._esc(cid)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.chore_calendar_hint")}</span>
|
||
`}
|
||
</div>
|
||
${this._switch(this._t("panel.chore_enabled_label"), "enabled", d.enabled !== false)}
|
||
</div>
|
||
</details>`,
|
||
this._dialog.mode === "edit" ? this._renderScheduledChanges(d) : "",
|
||
`<details class="tm-advanced" data-section="weather"${this._dialog._openAdvanced?.has("weather") ? " open" : ""}>
|
||
<summary>${this._t("panel.chore_advanced_weather")}</summary>
|
||
<div>
|
||
<span class="tm-field-hint" style="margin-bottom:8px;display:block">${this._t("panel.chore_weather_intro")}</span>
|
||
${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 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.chore_weather_conditions_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${WEATHER_CONDITIONS.map(w => `
|
||
<button type="button" class="tm-chip-btn ${(d.weather_block_conditions || []).includes(w.v) ? "tm-chip-on" : ""}" data-act="toggle-weather-condition" data-id="${w.v}">
|
||
<ha-icon icon="${w.icon}" style="--mdc-icon-size:16px;margin-inline-end:4px"></ha-icon>${this._t("weather.condition_" + w.v.replace(/-/g, "_"))}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.chore_weather_conditions_hint")}</span>
|
||
</div>
|
||
<div class="tm-field-row">
|
||
${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"))}
|
||
</div>
|
||
${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"))}
|
||
<span class="tm-field-hint">${this._t("panel.chore_weather_failopen_hint")}</span>
|
||
` : ""}
|
||
</div>
|
||
</details>`,
|
||
]),
|
||
].join(""),
|
||
`${this._dialog.mode === "edit" && d.id ? `<button type="button" class="tm-btn tm-drawer-delete" data-act="delete-chore" data-id="${this._esc(d.id)}"><ha-icon icon="mdi:delete-outline"></ha-icon>${this._t("panel.btn_delete")}</button>` : ""}
|
||
<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-chore">${this._t("panel.btn_save")}</button>`,
|
||
{ 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) => `
|
||
<button type="button" class="tm-chip-btn ${selected.includes(id) ? "tm-chip-on" : ""}" data-act="toggle-tag" data-id="${this._esc(id)}" title="${this._esc(id)}">
|
||
<ha-icon icon="mdi:nfc-variant" style="--mdc-icon-size:16px;margin-inline-end:4px"></ha-icon>${this._esc(label || id)}
|
||
</button>`;
|
||
const cantTag = d.require_photo || d.open_ended || d.task_type === "timed";
|
||
const open = this._dialog._openAdvanced?.has("tags");
|
||
return `<details class="tm-advanced" data-section="tags"${open ? " open" : ""}>
|
||
<summary>${this._t("panel.chore_advanced_tags")}${selected.length ? ` <span class="tm-sched-count">${selected.length}</span>` : ""}</summary>
|
||
<div>
|
||
<span class="tm-field-hint" style="margin-bottom:8px;display:block">${this._t("panel.chore_tags_intro")}</span>
|
||
${registry.length || typed.length ? `
|
||
<div class="tm-chip-row">
|
||
${registry.map(tag => chip(tag.id, tag.name)).join("")}
|
||
${typed.map(id => chip(id, id)).join("")}
|
||
</div>
|
||
` : `<span class="tm-field-hint">${this._t("panel.chore_tags_none")}</span>`}
|
||
<div class="tm-field-row" style="grid-template-columns:1fr auto;align-items:end;gap:6px;margin-top:8px">
|
||
<div class="tm-field" style="margin:0">
|
||
<span class="tm-field-label">${this._t("panel.chore_tags_typed_label")}</span>
|
||
<input class="tm-input" type="text" data-role="tag-input" maxlength="100" placeholder="${this._t("panel.chore_tags_typed_placeholder")}">
|
||
</div>
|
||
<button type="button" class="tm-btn" data-act="add-tag">${this._t("panel.btn_add_tag")}</button>
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.chore_tags_hint")}</span>
|
||
${cantTag ? `<span class="tm-field-hint" style="display:block;margin-top:6px;color:var(--tm-warning, #b26a00)">${this._t("panel.chore_tags_blocked_hint")}</span>` : ""}
|
||
</div>
|
||
</details>`;
|
||
}
|
||
|
||
/**
|
||
* 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}: <strong>${value}</strong>`;
|
||
}).join(" · ");
|
||
|
||
return `<details class="tm-advanced" data-section="scheduled"${open ? " open" : ""}>
|
||
<summary>${this._t("panel.chore_advanced_scheduled")}${pending.length ? ` <span class="tm-sched-count">${pending.length}</span>` : ""}</summary>
|
||
<div>
|
||
<span class="tm-field-hint" style="margin-bottom:8px;display:block">${this._t("panel.chore_scheduled_intro")}</span>
|
||
${pending.length ? `
|
||
<div class="tm-sched-list">
|
||
${pending.map(c => `
|
||
<div class="tm-sched-row">
|
||
<div class="tm-sched-when">${this._esc(c.apply_on)}</div>
|
||
<div class="tm-sched-what">${describe(c.changes)}${c.note ? `<div class="tm-sched-note">${this._esc(c.note)}</div>` : ""}</div>
|
||
<button type="button" class="tm-btn tm-btn-icon" data-act="remove-scheduled" data-id="${this._esc(c.id)}"
|
||
title="${this._t("panel.tooltip_remove")}">✕</button>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : `<span class="tm-field-hint">${this._t("panel.chore_scheduled_none")}</span>`}
|
||
|
||
<div class="tm-sched-add">
|
||
<div class="tm-field-row">
|
||
${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 })))}
|
||
</div>
|
||
${this._renderScheduledValueInput(draft, children)}
|
||
${this._field(this._t("panel.sched_note_label"), "_sched_note", draft.note || "", "text",
|
||
this._t("panel.sched_note_hint"))}
|
||
<button type="button" class="tm-btn" data-act="add-scheduled">${this._t("panel.btn_add_scheduled")}</button>
|
||
</div>
|
||
|
||
${applied.length ? `
|
||
<div class="tm-sched-applied">
|
||
<span class="tm-field-label">${this._t("panel.chore_scheduled_history")}</span>
|
||
${applied.map(c => `
|
||
<div class="tm-sched-row tm-sched-done">
|
||
<div class="tm-sched-when">${this._esc(c.apply_on)}</div>
|
||
<div class="tm-sched-what">${describe(c.changes)}</div>
|
||
<span class="tm-sched-tick" title="${this._t("panel.chore_scheduled_applied")}">✓</span>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
</details>`;
|
||
}
|
||
|
||
/** 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 `<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.sched_value_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => `
|
||
<button type="button" class="tm-chip-btn ${picked.includes(c.id) ? "tm-chip-on" : ""}"
|
||
data-act="toggle-scheduled-child" data-id="${this._esc(c.id)}">${this._esc(c.name)}</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
if (spec.kind === "days") {
|
||
const picked = draft.value_list || [];
|
||
return `<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.sched_value_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${DAYS.map(day => `
|
||
<button type="button" class="tm-chip-btn ${picked.includes(day.v) ? "tm-chip-on" : ""}"
|
||
data-act="toggle-scheduled-day" data-id="${day.v}">${this._t(day.lk)}</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
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"),
|
||
`<div class="tm-field-row">
|
||
${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)}
|
||
</div>`,
|
||
children.length > 0 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.reward_assigned_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.assigned_to || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-reward-assigned" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.reward_assigned_hint")}</span>
|
||
</div>
|
||
` : "",
|
||
// 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")),
|
||
`<div class="tm-field-row">
|
||
${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"))}
|
||
</div>`,
|
||
this._switch(this._t("panel.reward_restock_label"), "restock_enabled", d.restock_enabled,
|
||
this._t("panel.reward_restock_hint")),
|
||
`<div class="tm-field-row">
|
||
${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") },
|
||
])}
|
||
</div>`,
|
||
this._renderRewardTimeLock(d),
|
||
this._renderRewardUnlock(d),
|
||
].join(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-reward">${this._t("panel.btn_save")}</button>`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 `<details class="tm-advanced" data-section="teamwork"${open ? " open" : ""}>
|
||
<summary>${this._t("panel.chore_team_section")}</summary>
|
||
<div>
|
||
<span class="tm-field-hint" style="margin-bottom:8px;display:block">${this._t("panel.chore_team_hint")}</span>
|
||
<div class="tm-field-row">
|
||
${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")}
|
||
</div>
|
||
</div>
|
||
</details>`;
|
||
}
|
||
|
||
/**
|
||
* Time lock (#857). Restricts *claiming* to certain weekdays and/or a
|
||
* time-of-day window — the reward stays visible the rest of the time, greyed
|
||
* out with a "when it's available" badge, and saving points into a pool
|
||
* reward is never blocked.
|
||
*/
|
||
_renderRewardTimeLock(d) {
|
||
const open = this._dialog._openAdvanced?.has("timelock") || !!d.time_lock_enabled;
|
||
const days = (d.available_days || []).map(Number);
|
||
return `<details class="tm-advanced" data-section="timelock"${open ? " open" : ""}>
|
||
<summary>${this._t("panel.reward_timelock_section")}</summary>
|
||
<div>
|
||
${this._switch(this._t("panel.reward_timelock_label"), "time_lock_enabled", d.time_lock_enabled,
|
||
this._t("panel.reward_timelock_hint"), true)}
|
||
${d.time_lock_enabled ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.reward_timelock_days_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${DAYS.map((day, i) => `
|
||
<button type="button" class="tm-chip-btn ${days.includes(i) ? "tm-chip-on" : ""}" data-act="toggle-reward-day" data-day="${i}">
|
||
${this._t(day.lk)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.reward_timelock_days_hint")}</span>
|
||
</div>
|
||
<div class="tm-field-row">
|
||
${this._timeField(this._t("panel.reward_timelock_from_label"), "available_from", d.available_from)}
|
||
${this._timeField(this._t("panel.reward_timelock_until_label"), "available_until", d.available_until)}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.reward_timelock_times_hint")}</span>
|
||
` : ""}
|
||
</div>
|
||
</details>`;
|
||
}
|
||
|
||
/**
|
||
* Timed unlock (#678). Only offers entities the parent has allowlisted in
|
||
* Settings — a reward must not be able to reach arbitrary devices.
|
||
*/
|
||
_renderRewardUnlock(d) {
|
||
const allow = (this._state.settings || {}).unlock_allowlist || [];
|
||
const open = this._dialog._openAdvanced?.has("unlock");
|
||
if (!allow.length) {
|
||
return `<details class="tm-advanced" data-section="unlock"${open ? " open" : ""}>
|
||
<summary>${this._t("panel.reward_unlock_section")}</summary>
|
||
<div><span class="tm-field-hint">${this._t("panel.reward_unlock_no_allowlist")}</span></div>
|
||
</details>`;
|
||
}
|
||
const options = [{ v: "", lk: "panel.reward_unlock_none" }]
|
||
.concat(allow.map(e => ({ v: e, l: e })));
|
||
return `<details class="tm-advanced" data-section="unlock"${open ? " open" : ""}>
|
||
<summary>${this._t("panel.reward_unlock_section")}</summary>
|
||
<div>
|
||
<span class="tm-field-hint" style="margin-bottom:8px;display:block">${this._t("panel.reward_unlock_intro")}</span>
|
||
${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")) : ""}
|
||
</div>
|
||
</details>`;
|
||
}
|
||
|
||
_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) => `
|
||
<div class="tm-avatar-row">
|
||
<div class="tm-avatar-row-fields">
|
||
<input class="tm-input" type="text" data-field="catalog[${i}].label" value="${this._esc(a.label || "")}" placeholder="${this._t("panel.avatar_label_ph")}">
|
||
<ha-icon-picker class="tm-avatar-picker" data-field="catalog[${i}].icon" data-current="${this._esc(a.icon || "")}"></ha-icon-picker>
|
||
<select class="tm-select" data-field="catalog[${i}].unlock_type">
|
||
${typeOpts.map(o => `<option value="${o.v}" ${(a.unlock_type || "free") === o.v ? "selected" : ""}>${this._esc(o.l)}</option>`).join("")}
|
||
</select>
|
||
<input class="tm-input" type="number" min="0" data-field="catalog[${i}].unlock_value" value="${Number(a.unlock_value) || 0}" ${(a.unlock_type || "free") === "free" ? "disabled" : ""}>
|
||
</div>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="avatar-del-row" data-idx="${i}">✕</button>
|
||
</div>
|
||
`).join("");
|
||
return this._dialogShell(this._t("panel.avatar_dialog_title"),
|
||
[
|
||
`<p class="tm-field-hint">${this._t("panel.avatar_dialog_hint")}</p>`,
|
||
rows.length ? `<div class="tm-avatar-rows">${rowHtml}</div>` : `<div class="tm-meta">${this._t("panel.avatar_empty")}</div>`,
|
||
`<button type="button" class="tm-btn tm-btn-sm" data-act="avatar-add-row" style="margin-top:10px;">+ ${this._t("panel.avatar_add_row")}</button>`,
|
||
].join(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-avatar-catalog">${this._t("panel.btn_save")}</button>`,
|
||
// 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) => `
|
||
<div class="tm-quest-step-row">
|
||
<span class="tm-quest-step-num">${i + 1}</span>
|
||
<span class="tm-quest-step-name">${this._esc(choreById[sid] ? choreById[sid].name : this._t("panel.quest_missing_chore"))}</span>
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="quest-step-move" data-idx="${i}" data-dir="up" ${i === 0 ? "disabled" : ""}>▲</button>
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="quest-step-move" data-idx="${i}" data-dir="down" ${i === steps.length - 1 ? "disabled" : ""}>▼</button>
|
||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="toggle-quest-step" data-id="${this._esc(sid)}">✕</button>
|
||
</div>
|
||
`).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"),
|
||
`<div class="tm-field-row">
|
||
${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)}
|
||
</div>`,
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.quest_steps_label")}</span>
|
||
<span class="tm-field-hint">${this._t("panel.quest_steps_hint")}</span>
|
||
${steps.length ? `<div class="tm-quest-step-list">${stepRows}</div>` : `<div class="tm-meta">${this._t("panel.quest_no_steps")}</div>`}
|
||
<div class="tm-chip-row" style="margin-top:8px;">
|
||
${chores.map(c => `
|
||
<button type="button" class="tm-chip-btn ${steps.includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-quest-step" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>`,
|
||
children.length > 0 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.quest_assigned_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.assigned_to || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-quest-assigned" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.quest_assigned_hint")}</span>
|
||
</div>
|
||
` : "",
|
||
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(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-quest">${this._t("panel.btn_save")}</button>`
|
||
);
|
||
}
|
||
|
||
_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"),
|
||
`<div class="tm-field-row">
|
||
${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)}
|
||
</div>`,
|
||
children.length > 0 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.penbon_applies_to")}</span>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.assigned_to || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-penbon-assigned" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.penbon_applies_to_hint")}</span>
|
||
</div>
|
||
` : "",
|
||
].join(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-${kind}">${this._t("panel.btn_save")}</button>`
|
||
);
|
||
}
|
||
|
||
_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}),
|
||
`<p>${isPenalty ? this._t("panel.penalty_apply_text", {points: item.points}) : this._t("panel.bonus_apply_text", {points: item.points})}</p>
|
||
${eligible.length === 0 ? `<p class="tm-meta">${this._t("panel.penbon_no_eligible", {kind})}</p>` : `
|
||
<div class="tm-chip-row" style="margin-top: 12px;">
|
||
${eligible.map(c => `
|
||
<button type="button" class="tm-btn" data-act="do-apply-${kind}" data-id="${this._esc(item.id)}" data-child="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
`}`,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_close")}</button>`
|
||
);
|
||
}
|
||
|
||
_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 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-hint">${this._t("panel.group_no_rotation_chores")}</span>
|
||
</div>
|
||
` : `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.group_chores_label")}</span>
|
||
<div class="tm-chip-row">
|
||
${chores.map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.chore_ids || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-group-chore" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`,
|
||
].join(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-group">${this._t("panel.btn_save")}</button>`
|
||
);
|
||
}
|
||
|
||
_renderBulkChoreDialog() {
|
||
const d = this._dialog.data;
|
||
const children = this._state.children || [];
|
||
return this._dialogShell(this._t("panel.bulk_title"),
|
||
[
|
||
`<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bulk_chore_names_label")}</span>
|
||
<textarea class="tm-textarea" data-field="chore_names" rows="6" placeholder="${this._t("panel.bulk_chore_names_placeholder")}">${this._esc(d.chore_names || "")}</textarea>
|
||
<span class="tm-field-hint">${this._t("panel.bulk_chore_names_hint")}</span>
|
||
</div>`,
|
||
`<div class="tm-field-row">
|
||
${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")}
|
||
</div>`,
|
||
children.length > 0 ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bulk_assigned_to")}</span>
|
||
<div class="tm-chip-row">
|
||
${children.map(c => `
|
||
<button type="button" class="tm-chip-btn ${(d.assigned_to || []).includes(c.id) ? "tm-chip-on" : ""}" data-act="toggle-assigned" data-id="${this._esc(c.id)}">
|
||
${this._esc(c.name)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.bulk_leave_empty_hint")}</span>
|
||
</div>
|
||
` : "",
|
||
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" ? `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.bulk_days_label")}</span>
|
||
<div class="tm-day-row">
|
||
${DAYS.map(day => `
|
||
<button type="button" class="tm-day-btn ${(d.due_days || []).includes(day.v) ? "tm-day-on" : ""}" data-act="toggle-bulk-day" data-day="${day.v}">${this._t(day.lk)}</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
` : "",
|
||
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")),
|
||
`<div class="tm-field-row">
|
||
${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")}
|
||
</div>`,
|
||
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(""),
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-bulk-chores">${this._t("panel.btn_create_chores")}</button>`
|
||
);
|
||
}
|
||
|
||
_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,
|
||
`<p class="tm-meta">${hint}</p>
|
||
<div class="tm-reorder-list">
|
||
${(d.order || []).map((id, idx, arr) => {
|
||
const c = choreById[id];
|
||
if (!c) return "";
|
||
return `
|
||
<div class="tm-reorder-item" draggable="true" data-drag-id="${this._esc(id)}">
|
||
<div class="tm-reorder-handle">⠿</div>
|
||
<div class="tm-reorder-name">${this._esc(c.name)}</div>
|
||
<div class="tm-reorder-points tm-numeric">${this._num(c.points)}</div>
|
||
<div class="tm-reorder-moves">
|
||
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="-1" data-drag-id="${this._esc(id)}" ${idx === 0 ? "disabled" : ""} title="${this._t("reorder.move_up")}" aria-label="${this._t("reorder.move_up")}">▲</button>
|
||
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="1" data-drag-id="${this._esc(id)}" ${idx === arr.length - 1 ? "disabled" : ""} title="${this._t("reorder.move_down")}" aria-label="${this._t("reorder.move_down")}">▼</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>`,
|
||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-chore-order">${this._t("panel.btn_save_order")}</button>`
|
||
);
|
||
}
|
||
|
||
// ---- form helpers ----------------------------------------------------
|
||
// `drawer: true` opens it as a full-height panel on the right (#968).
|
||
_dialogShell(title, body, footer, { drawer = false, wide = false } = {}) {
|
||
return `
|
||
<div class="tm-scrim ${drawer ? "tm-scrim-drawer" : ""}" data-act="scrim">
|
||
<div class="${["tm-dialog", drawer && "tm-drawer", wide && "tm-dialog-wide"].filter(Boolean).join(" ")}" role="dialog" aria-modal="true" aria-label="${this._esc(title)}">
|
||
<header class="tm-dialog-head">
|
||
<h2>${this._esc(title)}</h2>
|
||
<button type="button" class="tm-icon-btn" data-act="close-dialog" title="${this._t("panel.tooltip_close")}">×</button>
|
||
</header>
|
||
<div class="tm-dialog-body">${body}</div>
|
||
<footer class="tm-dialog-foot">${footer}</footer>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_field(label, name, value, type = "text", hintHtml = "") {
|
||
return `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(label)}</span>
|
||
<input
|
||
class="tm-input"
|
||
data-field="${name}"
|
||
value="${this._esc(value == null ? "" : value)}"
|
||
${type === "number" ? 'type="number"' : 'type="text"'}
|
||
>
|
||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_dateField(label, name, value, hintHtml = "") {
|
||
return `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(label)}</span>
|
||
<input
|
||
class="tm-input"
|
||
data-field="${name}"
|
||
value="${this._esc(value || "")}"
|
||
type="date"
|
||
>
|
||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_timeField(label, name, value, hintHtml = "") {
|
||
return `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(label)}</span>
|
||
<input
|
||
class="tm-input"
|
||
data-field="${name}"
|
||
value="${this._esc(value || "")}"
|
||
type="time"
|
||
>
|
||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_select(label, name, value, options, hintHtml = "", rerender = false) {
|
||
return `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(label)}</span>
|
||
<select
|
||
class="tm-select"
|
||
data-field="${name}"
|
||
${rerender ? 'data-rerender="true"' : ""}
|
||
>
|
||
${options.map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(value) ? "selected" : ""}>${this._esc(o.lk ? this._t(o.lk) : o.l)}</option>`).join("")}
|
||
</select>
|
||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_switch(label, name, checked, hintHtml = "", rerender = false) {
|
||
return `
|
||
<div class="tm-check-row">
|
||
<ha-switch data-field="${name}" ${checked ? "checked" : ""} ${rerender ? 'data-rerender="true"' : ""}></ha-switch>
|
||
<div>
|
||
<div class="tm-check-title">${this._esc(label)}</div>
|
||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** The chore picture well (#750): thumbnail or empty state, Upload, Remove. */
|
||
_choreImageField(value) {
|
||
const busy = this._choreImageBusy;
|
||
return `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.chore_image_label")}</span>
|
||
<div class="tm-image-well">
|
||
${value
|
||
? `<img class="tm-image-thumb" src="${this._esc(value)}" alt="">`
|
||
: `<div class="tm-image-empty">${this._t("panel.chore_image_empty")}</div>`}
|
||
<div class="tm-image-actions">
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="chore-image-pick" ${busy ? "disabled" : ""}>
|
||
${busy ? this._t("panel.chore_image_uploading") : this._t("panel.chore_image_upload")}
|
||
</button>
|
||
${value ? `<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="chore-image-remove">${this._t("panel.chore_image_remove")}</button>` : ""}
|
||
</div>
|
||
</div>
|
||
<span class="tm-field-hint">${this._t("panel.chore_image_hint")}</span>
|
||
</div>`;
|
||
}
|
||
|
||
/** Uploaded custom sounds from the last get_state (#856). */
|
||
_customSounds() {
|
||
return (this._state && this._state.custom_sounds) || [];
|
||
}
|
||
|
||
/** Built-in + uploaded sounds, as <select> options. */
|
||
_soundOptions() {
|
||
return [
|
||
...COMPLETION_SOUNDS.map(v => ({ v, l: v })),
|
||
...this._customSounds().map(s => ({ v: s.value, l: s.name })),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* The chore editor's completion-sound picker, with a preview button.
|
||
*
|
||
* Picking a sound used to be silent here: the only preview code lived in
|
||
* taskmate-config-sounds.js, which scans the document for sound dropdowns
|
||
* and so could never see this one inside the panel's shadow root (#856).
|
||
*/
|
||
_soundField(value) {
|
||
const current = value || "coin";
|
||
return `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._t("panel.chore_completion_sound_label")}</span>
|
||
<div class="tm-sound-row">
|
||
<select class="tm-select" data-field="completion_sound">
|
||
${this._soundOptions().map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(current) ? "selected" : ""}>${this._esc(o.l)}</option>`).join("")}
|
||
</select>
|
||
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview-field"
|
||
title="${this._esc(this._t("panel.sound_preview"))}"
|
||
aria-label="${this._esc(this._t("panel.sound_preview"))}">▶</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** Play a sound through the shared engine (taskmate-sounds.js). */
|
||
_previewSound(value) {
|
||
const engine = window.__taskmate_sounds;
|
||
if (engine) engine.play(value, this._customSounds());
|
||
}
|
||
|
||
/**
|
||
* Icon field. `ha-icon-picker` is HA's own component — we keep it for the
|
||
* searchable MDI list, but its Material shell (grey fill, underline, 56px
|
||
* box) reads as a foreign control beside the panel's own inputs. The
|
||
* `.tm-icon-well` wrapper supplies the tm-input frame and the picker inside
|
||
* it is made transparent; see the CSS for why the wrapper, not the picker,
|
||
* carries the border.
|
||
*/
|
||
_iconPickerField(label, name, value) {
|
||
return `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(label)}</span>
|
||
<div class="tm-icon-well">
|
||
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
_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 `
|
||
<div class="tm-field">
|
||
<span class="tm-field-label">${this._esc(label)}</span>
|
||
<div class="tm-ep" data-ep-field="${name}" data-ep-domains="${(domains || []).join(",")}">
|
||
${value
|
||
? `<div class="tm-ep-selected">
|
||
<ha-icon icon="${this._esc(icon)}" class="tm-ep-selected-icon"></ha-icon>
|
||
<span class="tm-ep-selected-text">${this._esc(friendly || value)}</span>
|
||
<button type="button" class="tm-entity-clear" data-act="clear-field" data-field="${name}" title="${this._t("panel.tooltip_clear")}">✕</button>
|
||
</div>`
|
||
: `<div class="tm-ep-search-wrap">
|
||
<ha-icon icon="mdi:magnify" class="tm-ep-search-icon"></ha-icon>
|
||
<input type="text" class="tm-ep-input" placeholder="${this._t("panel.entity_search_placeholder")}" value="" autocomplete="off">
|
||
</div>`
|
||
}
|
||
</div>
|
||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_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 =>
|
||
`<button type="button" role="menuitem" class="tm-row-menu-item${it.danger ? " tm-row-menu-danger" : ""}" data-act="${it.act}" data-id="${esc(id)}">
|
||
<span class="tm-row-menu-icon">${it.icon}</span><span>${esc(it.label)}</span>
|
||
</button>`).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 `
|
||
<div class="tm-empty">
|
||
<div class="tm-empty-icon">${icon}</div>
|
||
<h3>${this._esc(title)}</h3>
|
||
<p>${this._esc(copy)}</p>
|
||
<button type="button" class="tm-btn tm-btn-raised" data-act="${action}">${this._esc(label)}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_idBadge(id) {
|
||
if (!this._showIds || !id) return "";
|
||
return `<span class="tm-id-badge"><code>${this._esc(id)}</code><button type="button" class="tm-id-copy" data-act="copy-id" data-id="${this._esc(id)}" title="${this._t("panel.toast_copied")}"><ha-icon icon="mdi:content-copy"></ha-icon></button></span>`;
|
||
}
|
||
|
||
_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, """).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 `<ha-icon icon="${this._esc(name || "mdi:account-circle")}"></ha-icon>`;
|
||
}
|
||
|
||
_styles() {
|
||
return `<style>
|
||
/* Fill the panel area without depending on a percentage-height chain.
|
||
HA nests us as taskmate-panel → ha-panel-custom → partial-panel-resolver
|
||
→ ha-drawer, and the middle two are display:inline / height:auto. A
|
||
height:100% here only resolves while some ancestor happens to carry a
|
||
definite height; where it doesn't, the shell collapses to its content
|
||
height and the nav column visibly stops partway down every short page
|
||
(#754). A flex column of exactly 100dvh is immune to the ancestor
|
||
chain: panel_custom gives the element the whole viewport — HA draws no
|
||
toolbar above it — so 100dvh is the full panel area, not an overflow.
|
||
It must be height, not min-height: the shell scrolls its own body,
|
||
and a floor-only rule would let a long section (Settings) grow the
|
||
panel past the viewport and scroll the nav column off the page. */
|
||
taskmate-panel {
|
||
display: flex; flex-direction: column; height: 100dvh;
|
||
|
||
--tm-bg: var(--primary-background-color, #fafafa);
|
||
--tm-surface-0: var(--card-background-color, #fff);
|
||
--tm-surface-1: var(--card-background-color, #fff);
|
||
--tm-surface-2: var(--secondary-background-color, #f5f5f5);
|
||
--tm-surface-3: var(--secondary-background-color, #e8e8e8);
|
||
--tm-surface-hover: var(--secondary-background-color, #f5f5f5);
|
||
|
||
--tm-border: var(--divider-color, #e0e0e0);
|
||
--tm-border-strong: var(--divider-color, #d4d4d4);
|
||
--tm-border-soft: var(--divider-color, #e8e8e8);
|
||
|
||
--tm-text: var(--primary-text-color, #212121);
|
||
--tm-text-muted: var(--secondary-text-color, #727272);
|
||
--tm-text-faint: var(--secondary-text-color, #727272);
|
||
--tm-text-vfaint: var(--disabled-text-color, #bdbdbd);
|
||
|
||
--tm-accent: var(--primary-color, #03a9f4);
|
||
--tm-accent-hover: color-mix(in srgb, var(--primary-color, #03a9f4), #000 14%);
|
||
--tm-accent-press: var(--primary-color, #03a9f4);
|
||
--tm-accent-soft: color-mix(in srgb, var(--primary-color, #03a9f4), transparent 90%);
|
||
--tm-accent-border: color-mix(in srgb, var(--primary-color, #03a9f4), transparent 70%);
|
||
--tm-accent-text: var(--primary-color, #03a9f4);
|
||
--tm-accent-glow: color-mix(in srgb, var(--primary-color, #03a9f4), transparent 85%);
|
||
|
||
--tm-positive: var(--success-color, #4caf50);
|
||
--tm-positive-soft: color-mix(in srgb, var(--success-color, #4caf50), transparent 90%);
|
||
--tm-positive-border:color-mix(in srgb, var(--success-color, #4caf50), transparent 70%);
|
||
--tm-warning: var(--warning-color, #ff9800);
|
||
--tm-warning-soft: color-mix(in srgb, var(--warning-color, #ff9800), transparent 90%);
|
||
--tm-warning-border:color-mix(in srgb, var(--warning-color, #ff9800), transparent 70%);
|
||
--tm-danger: var(--error-color, #db4437);
|
||
--tm-danger-soft: color-mix(in srgb, var(--error-color, #db4437), transparent 90%);
|
||
--tm-danger-border: color-mix(in srgb, var(--error-color, #db4437), transparent 70%);
|
||
|
||
--tm-gold: var(--warning-color, #ff9800);
|
||
--tm-gold-soft: color-mix(in srgb, var(--warning-color, #ff9800), transparent 90%);
|
||
--tm-pool: var(--accent-color, #ff9800);
|
||
--tm-pool-soft: color-mix(in srgb, var(--accent-color, #ff9800), transparent 90%);
|
||
--tm-sticky: var(--info-color, #039be5);
|
||
--tm-sticky-soft: color-mix(in srgb, var(--info-color, #039be5), transparent 90%);
|
||
|
||
--tm-radius-sm: 8px;
|
||
--tm-radius: var(--ha-card-border-radius, 12px);
|
||
--tm-radius-lg: var(--ha-card-border-radius, 12px);
|
||
|
||
--tm-shadow-xs: none;
|
||
--tm-shadow-sm: none;
|
||
--tm-shadow: var(--ha-card-box-shadow, none);
|
||
--tm-shadow-lg: var(--ha-card-box-shadow, 0 2px 6px rgba(0,0,0,0.15));
|
||
--tm-shadow-focus: 0 0 0 2px var(--tm-accent-glow);
|
||
--tm-easing: ease;
|
||
|
||
--tm-sidebar-w: 240px;
|
||
|
||
background: var(--tm-bg);
|
||
color: var(--tm-text);
|
||
font-family: var(--paper-font-body1_-_font-family, Roboto, "Noto Sans", sans-serif);
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* ===== Shell ===== */
|
||
.tm-shell {
|
||
display: grid;
|
||
grid-template-columns: var(--tm-sidebar-w) 1fr;
|
||
/* Flex to fill the panel rather than height:100% — see the note on
|
||
taskmate-panel above (#754). min-height:0 lets the grid shrink
|
||
instead of being forced to its content height. */
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.tm-main { display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
||
.tm-shell > [data-zone], .tm-main > [data-zone] { display: contents; }
|
||
|
||
/* ===== Sidebar ===== */
|
||
.tm-sidebar {
|
||
background: var(--tm-surface-0);
|
||
border-inline-end: 1px solid var(--tm-border);
|
||
display: flex; flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.tm-brand {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--tm-border-soft);
|
||
}
|
||
.tm-brand-mark {
|
||
width: 28px; height: 28px;
|
||
border-radius: 7px;
|
||
background: var(--tm-accent);
|
||
color: var(--text-primary-color, #fff);
|
||
display: grid; place-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.tm-brand-mark ha-icon { --mdc-icon-size: 16px; }
|
||
.tm-brand-text {
|
||
flex: 1; min-width: 0;
|
||
font-weight: 600; font-size: 14px;
|
||
letter-spacing: -0.01em;
|
||
line-height: 1.15;
|
||
}
|
||
.tm-brand-text small {
|
||
display: block;
|
||
font-weight: 400;
|
||
color: var(--tm-text-faint);
|
||
font-size: 11px;
|
||
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
||
margin-top: 1px;
|
||
}
|
||
.tm-brand-wiki {
|
||
flex-shrink: 0;
|
||
width: 28px; height: 28px;
|
||
border-radius: 7px;
|
||
display: grid; place-items: center;
|
||
color: var(--tm-text-faint);
|
||
text-decoration: none;
|
||
transition: color 0.15s ease, background 0.15s ease;
|
||
}
|
||
.tm-brand-wiki ha-icon { --mdc-icon-size: 18px; }
|
||
.tm-brand-wiki:hover, .tm-brand-wiki:focus-visible {
|
||
color: var(--tm-accent);
|
||
background: var(--tm-border-soft);
|
||
outline: none;
|
||
}
|
||
.tm-nav {
|
||
padding: 10px 8px;
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
.tm-nav-group { margin-bottom: 16px; }
|
||
.tm-nav-group:last-child { margin-bottom: 0; }
|
||
.tm-nav-head {
|
||
font-size: 10.5px;
|
||
font-weight: 600;
|
||
color: var(--tm-text-faint);
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
padding: 4px 10px 6px;
|
||
}
|
||
.tm-nav-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 7px 10px;
|
||
border-radius: var(--tm-radius-sm);
|
||
color: var(--tm-text-muted);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
background: transparent; border: 0;
|
||
width: 100%; text-align: start;
|
||
position: relative;
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-nav-item:hover { background: var(--tm-surface-2); color: var(--tm-text); }
|
||
.tm-nav-active {
|
||
background: var(--tm-surface-2);
|
||
color: var(--tm-text);
|
||
font-weight: 600;
|
||
}
|
||
.tm-nav-active::before {
|
||
content: ""; position: absolute;
|
||
inset-inline-start: -8px; top: 6px; bottom: 6px; width: 2px;
|
||
background: var(--tm-accent);
|
||
border-start-start-radius: 0; border-start-end-radius: 2px; border-end-end-radius: 2px; border-end-start-radius: 0;
|
||
}
|
||
.tm-nav-icon {
|
||
width: 16px; height: 16px;
|
||
display: grid; place-items: center;
|
||
color: var(--tm-text-faint);
|
||
flex-shrink: 0;
|
||
}
|
||
.tm-nav-icon ha-icon { --mdc-icon-size: 16px; }
|
||
.tm-nav-active .tm-nav-icon { color: var(--tm-accent); }
|
||
.tm-nav-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||
.tm-nav-badge {
|
||
font-size: 11px;
|
||
color: var(--tm-text-faint);
|
||
background: var(--tm-surface-3);
|
||
padding: 1px 7px;
|
||
border-radius: 999px;
|
||
font-variant-numeric: tabular-nums;
|
||
line-height: 1.4;
|
||
}
|
||
.tm-nav-active .tm-nav-badge {
|
||
background: var(--tm-accent-soft);
|
||
color: var(--tm-accent-text);
|
||
}
|
||
.tm-nav-badge-urgent {
|
||
background: var(--tm-warning-soft) !important;
|
||
color: var(--tm-warning) !important;
|
||
}
|
||
|
||
/* ===== Topbar ===== */
|
||
.tm-topbar {
|
||
height: 52px;
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 0 24px;
|
||
border-bottom: 1px solid var(--tm-border);
|
||
background: var(--tm-surface-0);
|
||
flex-shrink: 0;
|
||
}
|
||
.tm-menu-btn {
|
||
display: none;
|
||
background: transparent;
|
||
border: 0;
|
||
color: var(--tm-text);
|
||
padding: 6px;
|
||
margin-inline-start: -6px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
align-items: center; justify-content: center;
|
||
}
|
||
.tm-menu-btn:hover { background: var(--tm-surface-2); }
|
||
.tm-menu-btn ha-icon { --mdc-icon-size: 22px; }
|
||
.tm-crumbs {
|
||
display: flex; align-items: center; gap: 8px;
|
||
font-size: 13px;
|
||
color: var(--tm-text-faint);
|
||
flex: 1;
|
||
}
|
||
.tm-crumbs strong {
|
||
color: var(--tm-text);
|
||
font-weight: 600;
|
||
letter-spacing: -0.005em;
|
||
}
|
||
.tm-crumbs-sep { color: var(--tm-text-vfaint); }
|
||
.tm-approval-pill {
|
||
background: var(--tm-warning-soft);
|
||
color: var(--tm-warning);
|
||
border: 1px solid var(--tm-warning-border);
|
||
/* 24px high is too small to hit reliably on a phone; this is the
|
||
panel's only route to the pending-approvals queue. */
|
||
min-height: 32px;
|
||
box-sizing: border-box;
|
||
padding-block: 4px; padding-inline: 10px 12px;
|
||
border-radius: 999px;
|
||
font-size: 12px; font-weight: 500;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-approval-pill:hover { filter: brightness(0.97); }
|
||
.tm-approval-dot {
|
||
width: 6px; height: 6px; border-radius: 50%;
|
||
background: var(--tm-warning);
|
||
animation: tm-pulse 2s ease-in-out infinite;
|
||
}
|
||
@keyframes tm-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
|
||
|
||
/* ===== Mobile section picker (hidden by default, shown on narrow) ===== */
|
||
.tm-mobilenav { display: none; }
|
||
|
||
/* Approval banner */
|
||
.tm-approval-banner {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px 24px;
|
||
background: var(--tm-warning-soft);
|
||
border-bottom: 1px solid var(--tm-warning-border);
|
||
color: var(--tm-warning);
|
||
font-size: 13px;
|
||
}
|
||
.tm-approval-banner ha-icon { --mdc-icon-size: 18px; color: var(--tm-warning); }
|
||
.tm-approval-banner span { flex: 1; }
|
||
.tm-approval-banner strong { color: var(--tm-text); font-weight: 600; }
|
||
|
||
/* Body */
|
||
.tm-body { flex: 1; overflow-y: auto; padding: 24px 32px 56px; background: var(--tm-bg); }
|
||
.tm-body-inner { max-width: 1180px; margin: 0 auto; }
|
||
|
||
/* Toolbar */
|
||
.tm-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
|
||
.tm-toolbar-title {
|
||
margin: 0; font-size: 20px; font-weight: 600;
|
||
letter-spacing: -0.018em;
|
||
}
|
||
.tm-toolbar-count {
|
||
color: var(--tm-text-faint); font-weight: 400;
|
||
margin-inline-start: 6px;
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: 16px;
|
||
}
|
||
|
||
/* Search input */
|
||
.tm-search-wrap {
|
||
position: relative;
|
||
flex: 1; min-width: 240px; max-width: 400px;
|
||
}
|
||
.tm-search-icon {
|
||
position: absolute; inset-inline-start: 10px; top: 50%; transform: translateY(-50%);
|
||
--mdc-icon-size: 14px; color: var(--tm-text-faint);
|
||
pointer-events: none;
|
||
}
|
||
.tm-search {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
padding-block: 6px; padding-inline: 32px 10px;
|
||
color: var(--tm-text);
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
box-shadow: var(--tm-shadow-xs);
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-search:hover { border-color: var(--tm-border-strong); }
|
||
.tm-search:focus {
|
||
outline: 0;
|
||
border-color: var(--tm-accent);
|
||
box-shadow: var(--tm-shadow-focus);
|
||
}
|
||
.tm-search::placeholder { color: var(--tm-text-vfaint); }
|
||
.tm-search-clear {
|
||
position: absolute; inset-inline-end: 6px; top: 50%; transform: translateY(-50%);
|
||
background: var(--tm-surface-2);
|
||
color: var(--tm-text-muted);
|
||
border: 0;
|
||
width: 20px; height: 20px;
|
||
border-radius: 50%; cursor: pointer; font-size: 11px;
|
||
display: grid; place-items: center;
|
||
}
|
||
.tm-search-clear:hover { background: var(--tm-surface-3); color: var(--tm-text); }
|
||
|
||
/* Buttons */
|
||
.tm-btn {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
gap: 6px; padding: 8px 16px;
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
background: var(--tm-surface-0);
|
||
color: var(--tm-accent);
|
||
font-size: 13px; font-weight: 500;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: all 0.12s var(--tm-easing);
|
||
white-space: nowrap;
|
||
text-decoration: none;
|
||
line-height: 1.3;
|
||
}
|
||
.tm-btn:hover {
|
||
background: var(--tm-accent-soft);
|
||
border-color: var(--tm-accent);
|
||
}
|
||
.tm-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: default;
|
||
pointer-events: none;
|
||
}
|
||
@keyframes tm-spin { to { transform: rotate(360deg); } }
|
||
.tm-btn-spinner {
|
||
width: 12px; height: 12px;
|
||
border: 2px solid currentColor;
|
||
border-top-color: transparent;
|
||
border-radius: 50%;
|
||
animation: tm-spin 0.7s linear infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
/* Still reads as busy — the ring just doesn't rotate. */
|
||
.tm-btn-spinner { animation: none; opacity: 0.7; }
|
||
}
|
||
.tm-btn-raised {
|
||
background: var(--tm-accent);
|
||
color: var(--text-primary-color, #fff);
|
||
border-color: var(--tm-accent);
|
||
box-shadow: var(--tm-shadow-xs);
|
||
}
|
||
.tm-btn-raised:hover {
|
||
background: var(--tm-accent-hover);
|
||
border-color: var(--tm-accent-hover);
|
||
color: var(--text-primary-color, #fff);
|
||
box-shadow: var(--tm-shadow-sm);
|
||
}
|
||
.tm-btn-sm {
|
||
padding: 5px 12px; font-size: 12.5px;
|
||
}
|
||
.tm-btn-danger {
|
||
color: var(--tm-danger);
|
||
border-color: var(--tm-danger-soft);
|
||
}
|
||
.tm-btn-danger:hover {
|
||
background: var(--tm-danger-soft);
|
||
border-color: var(--tm-danger);
|
||
}
|
||
.tm-icon-btn {
|
||
width: 28px; height: 28px;
|
||
border: 0; background: transparent;
|
||
border-radius: var(--tm-radius-sm);
|
||
display: grid; place-items: center;
|
||
color: var(--tm-text-faint);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-icon-btn ha-icon { --mdc-icon-size: 16px; }
|
||
.tm-icon-btn:hover { background: var(--tm-surface-2); color: var(--tm-text); }
|
||
|
||
/* Cards */
|
||
.tm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); gap: 14px; }
|
||
.tm-card {
|
||
position: relative; overflow: hidden;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-lg);
|
||
padding: 18px;
|
||
box-shadow: var(--tm-shadow-xs);
|
||
transition: all 0.15s var(--tm-easing);
|
||
margin-bottom: 14px;
|
||
}
|
||
.tm-card:last-child { margin-bottom: 0; }
|
||
.tm-card:hover { border-color: var(--tm-border-strong); box-shadow: var(--tm-shadow-sm); }
|
||
.tm-card-error { border-inline-start: 3px solid var(--tm-danger); color: var(--tm-danger); }
|
||
.tm-loading { color: var(--tm-text-muted); }
|
||
|
||
.tm-section-title {
|
||
margin: 0 0 12px;
|
||
font-size: 14px; font-weight: 600;
|
||
letter-spacing: -0.005em;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
|
||
/* Child / reward / group cards share head/avatar */
|
||
.tm-child-card { display: flex; flex-direction: column; gap: 14px; margin-bottom: 0; }
|
||
.tm-child-head { display: flex; align-items: center; gap: 12px; }
|
||
.tm-avatar {
|
||
width: 44px; height: 44px;
|
||
border-radius: 10px;
|
||
background: var(--tm-accent-soft);
|
||
border: 1px solid var(--tm-border);
|
||
display: grid; place-items: center;
|
||
flex-shrink: 0;
|
||
color: var(--tm-accent);
|
||
}
|
||
.tm-avatar ha-icon { --mdc-icon-size: 24px; }
|
||
.tm-bounty-cell { display: flex; align-items: center; gap: 12px; }
|
||
.tm-bounty-ic { width: 34px; height: 34px; border-radius: 50%; color: var(--tm-gold, #d4ac0d); }
|
||
.tm-bounty-ic ha-icon { --mdc-icon-size: 18px; }
|
||
.tm-bounty-note { margin: 0 0 12px; }
|
||
/* Chore auctions (#982) */
|
||
.tm-auc-ic { width: 34px; height: 34px; border-radius: 10px; color: #fff; background: linear-gradient(135deg, #7e57c2, #5e35b1); }
|
||
.tm-auc-ic ha-icon { --mdc-icon-size: 18px; }
|
||
.tm-auc-gold { color: var(--tm-gold, #d4ac0d); }
|
||
.tm-auc-head { flex: 1; min-width: 0; }
|
||
.tm-auc-chips .tm-chip-btn { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }
|
||
.tm-auc-chips .tm-chip-btn > ha-icon { --mdc-icon-size: 15px; }
|
||
.tm-auc-chips .tm-av { width: 20px; height: 20px; --mdc-icon-size: 14px; }
|
||
.tm-auc-stats { grid-template-columns: repeat(3, 1fr); margin-bottom: 14px; }
|
||
.tm-auc-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
|
||
.tm-auc-reveal { margin-inline-start: auto; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
|
||
.tm-auc-chip {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding-block: 2px; padding-inline: 2px 8px; margin-block: 2px; margin-inline: 0 4px;
|
||
border-radius: 999px; background: var(--tm-surface-2); font-size: 12px; white-space: nowrap;
|
||
}
|
||
.tm-auc-chip b { color: var(--tm-gold, #d4ac0d); }
|
||
.tm-auc-stack { display: inline-flex; }
|
||
.tm-auc-stack .tm-av + .tm-av { margin-inline-start: -6px; }
|
||
.tm-auc-cdown { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; font-variant-numeric: tabular-nums; color: #7e57c2; }
|
||
.tm-auc-cdown ha-icon { --mdc-icon-size: 15px; }
|
||
.tm-auc-win { display: flex; align-items: center; gap: 8px; }
|
||
.tm-auc-warn { color: var(--tm-warning, #b9770e); }
|
||
.tm-auc-steprow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.tm-auc-stepper { display: inline-flex; align-items: center; border: 1px solid var(--tm-border); border-radius: 8px; overflow: hidden; }
|
||
.tm-auc-stepper button { width: 34px; height: 34px; border: 0; cursor: pointer; font-size: 18px; background: var(--tm-surface-2); color: var(--tm-text); }
|
||
.tm-auc-stepper span { min-width: 64px; text-align: center; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
.tm-bounty-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; }
|
||
.tm-avatar-reward { color: var(--tm-gold); }
|
||
.tm-child-name { min-width: 0; flex: 1; }
|
||
.tm-child-name h3 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
|
||
.tm-meta { color: var(--tm-text-faint); font-size: 12px; margin-top: 2px; word-break: break-word; }
|
||
/* Entity ids are long and unbreakable. Left to wrap they split mid-token
|
||
and tear the pill background across two lines, so truncate instead —
|
||
the full value is on the title attribute. */
|
||
.tm-meta code { font-family: ui-monospace, "SF Mono", Menlo, monospace; background: var(--tm-surface-2); padding: 1px 6px; border-radius: 4px; font-size: 11px; color: var(--tm-text-muted); display: inline-block; max-width: 100%; vertical-align: bottom; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.tm-text-muted { color: var(--tm-text-muted); }
|
||
|
||
.tm-stats-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 1px;
|
||
background: var(--tm-border-soft);
|
||
border: 1px solid var(--tm-border-soft);
|
||
border-radius: var(--tm-radius);
|
||
overflow: hidden;
|
||
}
|
||
.tm-stat {
|
||
background: var(--tm-surface-1);
|
||
padding: 10px 12px;
|
||
}
|
||
.tm-stat-value {
|
||
font-size: 17px; font-weight: 600;
|
||
letter-spacing: -0.01em;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--tm-text);
|
||
line-height: 1.15;
|
||
}
|
||
.tm-stat-label {
|
||
font-size: 11px;
|
||
color: var(--tm-text-faint);
|
||
margin-top: 2px; font-weight: 400;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
.tm-stat-highlight .tm-stat-value { color: var(--tm-gold); }
|
||
|
||
/* Manual point adjustment strip on child cards (#746). Each -set is a
|
||
nowrap group so the row folds as "minus / plus + ⋯" on a narrow card
|
||
instead of orphaning the ⋯ button on a line of its own. */
|
||
/* Chore picture well (#750). */
|
||
.tm-image-well {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px; border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm); background: var(--tm-surface-2);
|
||
}
|
||
.tm-image-thumb {
|
||
width: 56px; height: 56px; min-width: 0; min-height: 0;
|
||
object-fit: cover; border-radius: var(--tm-radius-sm); display: block;
|
||
border: 1px solid var(--tm-border);
|
||
}
|
||
.tm-image-empty {
|
||
width: 56px; height: 56px; display: grid; place-items: center;
|
||
border: 1px dashed var(--tm-border); border-radius: var(--tm-radius-sm);
|
||
color: var(--tm-text-faint); font-size: 11px; text-align: center;
|
||
}
|
||
.tm-image-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
|
||
/* Custom completion sounds (#856) */
|
||
.tm-sound-row { display: flex; gap: 6px; align-items: center; }
|
||
.tm-sound-row .tm-select { flex: 1; min-width: 0; }
|
||
.tm-sound-list {
|
||
display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;
|
||
}
|
||
.tm-sound-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 10px; border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm); background: var(--tm-surface-2);
|
||
}
|
||
.tm-sound-name {
|
||
flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.tm-sound-item-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.tm-sound-upload { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
|
||
.tm-points-adjust {
|
||
display: flex; gap: 4px; flex-wrap: wrap;
|
||
align-items: center;
|
||
padding-top: 10px;
|
||
}
|
||
.tm-points-adjust-set { display: flex; gap: 4px; align-items: center; }
|
||
.tm-points-adjust-gap { flex: 1 1 8px; min-width: 4px; }
|
||
.tm-points-adjust .tm-btn { min-width: 40px; justify-content: center; padding-inline: 8px; }
|
||
|
||
.tm-card-foot {
|
||
display: flex; gap: 6px;
|
||
margin-top: auto;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--tm-border-soft);
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.tm-add-tile {
|
||
background: transparent;
|
||
border: 1.5px dashed var(--tm-border-strong);
|
||
border-radius: var(--tm-radius-lg);
|
||
padding: 36px 18px;
|
||
color: var(--tm-text-faint);
|
||
text-align: center;
|
||
font-size: 13px; font-weight: 500;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
display: grid; place-items: center; gap: 8px;
|
||
transition: all 0.12s var(--tm-easing);
|
||
min-height: 200px;
|
||
}
|
||
.tm-add-tile:hover {
|
||
border-color: var(--tm-accent);
|
||
color: var(--tm-accent-text);
|
||
background: var(--tm-accent-soft);
|
||
}
|
||
.tm-add-plus {
|
||
width: 32px; height: 32px;
|
||
border-radius: 50%;
|
||
background: var(--tm-surface-2);
|
||
display: grid; place-items: center;
|
||
font-size: 18px;
|
||
transition: all 0.15s var(--tm-easing);
|
||
}
|
||
.tm-add-tile:hover .tm-add-plus { background: var(--tm-accent); color: var(--text-primary-color, #fff); }
|
||
|
||
/* Tables */
|
||
.tm-table-wrap {
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-lg);
|
||
/* Scroll horizontally on narrow screens so wide tables (e.g. Chores)
|
||
stay usable and the action buttons remain reachable, instead of being
|
||
clipped by the rounded container. */
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
-webkit-overflow-scrolling: touch;
|
||
box-shadow: var(--tm-shadow-xs);
|
||
}
|
||
.tm-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.tm-table th, .tm-table td {
|
||
text-align: start;
|
||
padding: 11px 16px;
|
||
border-bottom: 1px solid var(--tm-border-soft);
|
||
vertical-align: middle;
|
||
/* Keep columns at their natural width so the table overflows (and the
|
||
wrap scrolls) on small devices rather than squashing cells. */
|
||
white-space: nowrap;
|
||
}
|
||
.tm-table th {
|
||
color: var(--tm-text-faint);
|
||
font-weight: 500; font-size: 11.5px;
|
||
text-transform: uppercase; letter-spacing: 0.02em;
|
||
background: var(--tm-surface-2);
|
||
border-bottom: 1px solid var(--tm-border);
|
||
}
|
||
.tm-table tr:last-child td { border-bottom: 0; }
|
||
.tm-row { transition: background 0.1s var(--tm-easing); }
|
||
.tm-row:hover { background: var(--tm-surface-hover); }
|
||
.tm-row-disabled { opacity: 0.5; }
|
||
.tm-row-icon { display: inline-flex; vertical-align: middle; margin-inline-end: 10px; opacity: 0.7; --mdc-icon-size: 18px; color: var(--tm-text-muted); }
|
||
.tm-row-actions { text-align: end; white-space: nowrap; }
|
||
.tm-row-actions > div { display: inline-flex; gap: 4px; align-items: center; justify-content: flex-end; }
|
||
|
||
/* Keep the task-name column visible when a wide table scrolls
|
||
horizontally (issue #533). Pinned only outside bulk-select mode,
|
||
where the checkbox column sits to the name's left. */
|
||
.tm-table th.tm-col-sticky, .tm-table td.tm-col-sticky {
|
||
position: sticky; inset-inline-start: 0; z-index: 1;
|
||
}
|
||
.tm-table td.tm-col-sticky { background: var(--tm-surface-0); }
|
||
.tm-table th.tm-col-sticky { z-index: 3; }
|
||
.tm-row:hover td.tm-col-sticky { background: var(--tm-surface-hover); }
|
||
|
||
/* Row actions (✏ / ⋮) ride inside the pinned name cell so they stay
|
||
reachable without scrolling the wide table right (issue #568).
|
||
Pinned with the name outside bulk mode; scroll with it in bulk mode. */
|
||
.tm-name-cell { display: flex; align-items: center; gap: 12px; }
|
||
.tm-name-cell .tm-name-main { min-width: 0; }
|
||
.tm-name-cell .tm-name-actions {
|
||
margin-inline-start: auto; display: inline-flex; gap: 4px;
|
||
align-items: center; flex-shrink: 0;
|
||
}
|
||
/* Variant for rows whose actions are full buttons (Wishlists, Bounties;
|
||
#951): the name wraps and the actions drop under it, so the pinned
|
||
cell shrinks on a narrow panel instead of pushing them off the edge. */
|
||
.tm-table td.tm-cell-wrap { white-space: normal; }
|
||
.tm-name-cell-wrap { flex-wrap: wrap; row-gap: 8px; }
|
||
.tm-name-cell-wrap .tm-name-main { flex: 1 1 auto; min-width: 150px; }
|
||
/* Icon-only actions (Pledges, Wish history; #962) stay beside the name,
|
||
which wraps instead, so a lone delete icon doesn't take its own line. */
|
||
.tm-name-cell-icon .tm-name-main { flex: 1 1 auto; min-width: 150px; }
|
||
|
||
/* Floating kebab (⋮) action menu for chore rows. Rendered fixed at the
|
||
document root so the table wrap's overflow never clips it. */
|
||
.tm-row-menu {
|
||
position: fixed; z-index: 300;
|
||
min-width: 188px; padding: 4px;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius);
|
||
box-shadow: var(--tm-shadow-lg);
|
||
animation: tm-row-menu-in 0.1s var(--tm-easing);
|
||
}
|
||
@keyframes tm-row-menu-in {
|
||
from { opacity: 0; transform: translateY(-4px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.tm-row-menu-item {
|
||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||
padding: 8px 10px; border: 0; background: transparent;
|
||
border-radius: var(--tm-radius-sm);
|
||
color: var(--tm-text); font-family: inherit; font-size: 13px;
|
||
text-align: start; cursor: pointer; white-space: nowrap;
|
||
transition: background 0.08s var(--tm-easing);
|
||
}
|
||
.tm-row-menu-item:hover { background: var(--tm-surface-hover); }
|
||
.tm-row-menu-icon { width: 18px; text-align: center; flex-shrink: 0; font-size: 13px; }
|
||
.tm-row-menu-danger { color: var(--tm-danger); }
|
||
.tm-row-menu-danger:hover { background: var(--tm-danger-soft); }
|
||
.tm-numeric { font-feature-settings: "tnum"; }
|
||
.tm-yes { color: var(--tm-positive); font-weight: 500; }
|
||
.tm-no { color: var(--tm-text-faint); }
|
||
.tm-neg { color: var(--tm-danger); }
|
||
.tm-pos { color: var(--tm-positive); }
|
||
.tm-current-assignee { color: var(--tm-accent); font-weight: 600; }
|
||
.tm-cost { color: var(--tm-gold); }
|
||
.tm-table-hint { margin-top: 8px; font-size: 11px; }
|
||
|
||
.tm-inline-input {
|
||
background: var(--tm-surface-1);
|
||
border: 1px solid var(--tm-accent);
|
||
border-radius: 6px;
|
||
padding: 4px 8px;
|
||
color: var(--tm-text);
|
||
font-size: 13px; font-weight: 500;
|
||
font-family: inherit;
|
||
max-width: 240px;
|
||
outline: 0;
|
||
box-shadow: 0 0 0 3px var(--tm-accent-soft);
|
||
}
|
||
|
||
/* Pills */
|
||
.tm-pill {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 2px 8px; border-radius: 999px;
|
||
font-size: 11.5px; font-weight: 500;
|
||
line-height: 1.5;
|
||
background: var(--tm-surface-2); color: var(--tm-text-muted);
|
||
border: 1px solid var(--tm-border);
|
||
margin-inline-start: 4px; vertical-align: middle;
|
||
}
|
||
.tm-pill-accent { background: var(--tm-accent-soft); color: var(--tm-accent-text); border-color: var(--tm-accent-border); }
|
||
.tm-pill-success { background: var(--tm-positive-soft); color: var(--tm-positive); border-color: var(--tm-positive-border); }
|
||
.tm-pill-warn { background: var(--tm-warning-soft); color: var(--tm-warning); border-color: var(--tm-warning-border); }
|
||
.tm-pill-danger { background: var(--tm-danger-soft); color: var(--tm-danger); border-color: var(--tm-danger-border); }
|
||
.tm-pill-pool { background: var(--tm-pool-soft); color: var(--tm-pool); border-color: color-mix(in srgb, var(--tm-pool), transparent 75%); }
|
||
.tm-pill-sticky { background: var(--tm-sticky-soft); color: var(--tm-sticky); border-color: color-mix(in srgb, var(--tm-sticky), transparent 75%); }
|
||
.tm-pill-spread { background: var(--tm-positive-soft); color: var(--tm-positive); border-color: var(--tm-positive-border); }
|
||
.tm-pill-timelock { background: var(--tm-accent-soft); color: var(--tm-accent-text); border-color: var(--tm-accent-border); }
|
||
.tm-pill-timelock ha-icon { --mdc-icon-size: 13px; vertical-align: -2px; }
|
||
.tm-pill-jackpot { background: var(--tm-gold-soft); color: var(--tm-gold); border-color: color-mix(in srgb, var(--tm-gold), transparent 75%); }
|
||
.tm-pill-muted { background: var(--tm-surface-2); color: var(--tm-text-muted); border-color: var(--tm-border); }
|
||
.tm-quest-inactive { opacity: 0.6; }
|
||
.tm-quest-steps { margin: 8px 0 4px; padding-inline-start: 20px; color: var(--tm-text); font-size: 13px; }
|
||
.tm-quest-steps li { margin: 2px 0; }
|
||
.tm-quest-step-list { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||
.tm-quest-step-row { display: flex; align-items: center; gap: 8px; }
|
||
.tm-quest-step-num { width: 22px; height: 22px; flex: 0 0 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: var(--tm-accent-soft); color: var(--tm-accent-text); font-size: 12px; font-weight: 600; }
|
||
.tm-quest-step-name { flex: 1; font-size: 13px; }
|
||
.tm-avatar-rows { display: flex; flex-direction: column; gap: 10px; }
|
||
.tm-avatar-row { display: flex; align-items: center; gap: 8px; }
|
||
.tm-avatar-row-fields { flex: 1; display: grid; grid-template-columns: 1fr 1.5fr 1.3fr 0.6fr; gap: 6px; align-items: center; }
|
||
.tm-avatar-picker { display: block; min-width: 0; }
|
||
@media (max-width: 600px) { .tm-avatar-row-fields { grid-template-columns: 1fr 1fr; } }
|
||
.tm-pill-alternating, .tm-pill-random, .tm-pill-balanced { background: var(--tm-accent-soft); color: var(--tm-accent-text); border-color: var(--tm-accent-border); }
|
||
.tm-pill-first_come { background: var(--tm-gold-soft); color: var(--tm-gold); border-color: color-mix(in srgb, var(--tm-gold), transparent 75%); }
|
||
.tm-pill-unassigned { background: var(--tm-muted-bg, #f0f0f0); color: var(--tm-text-muted); border-color: var(--tm-border); }
|
||
.tm-pill-dot::before {
|
||
content: ""; width: 5px; height: 5px; border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
|
||
/* ID badge (Show IDs toggle) */
|
||
.tm-id-badge {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
margin-top: 2px;
|
||
}
|
||
.tm-id-badge code {
|
||
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
||
font-size: 10.5px; color: var(--tm-text-faint);
|
||
background: var(--tm-surface-2); padding: 1px 6px;
|
||
border-radius: 4px; user-select: all;
|
||
}
|
||
.tm-id-copy {
|
||
background: none; border: none; cursor: pointer;
|
||
padding: 2px; border-radius: 4px;
|
||
color: var(--tm-text-faint); display: inline-flex; align-items: center;
|
||
transition: color 0.15s;
|
||
}
|
||
.tm-id-copy:hover { color: var(--tm-accent); }
|
||
.tm-id-copy ha-icon { --mdc-icon-size: 14px; }
|
||
|
||
/* Reward extras */
|
||
.tm-reward-card { display: flex; flex-direction: column; gap: 12px; margin-bottom: 0; }
|
||
.tm-progress {
|
||
height: 8px;
|
||
background: var(--tm-surface-2);
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
margin-top: 4px;
|
||
border: 1px solid var(--tm-border-soft);
|
||
}
|
||
.tm-progress > span {
|
||
display: block; height: 100%;
|
||
background: var(--tm-accent);
|
||
border-radius: 999px;
|
||
}
|
||
.tm-progress-text {
|
||
font-size: 12px; color: var(--tm-text-faint);
|
||
display: flex; justify-content: space-between;
|
||
font-variant-numeric: tabular-nums;
|
||
margin-top: 6px;
|
||
}
|
||
.tm-progress-text strong { color: var(--tm-text); font-weight: 600; }
|
||
|
||
/* Empty state */
|
||
.tm-empty {
|
||
text-align: center;
|
||
padding: 56px 24px;
|
||
background: var(--tm-surface-0);
|
||
border: 1px dashed var(--tm-border-strong);
|
||
border-radius: var(--tm-radius-lg);
|
||
}
|
||
.tm-empty-icon {
|
||
width: 48px; height: 48px;
|
||
border-radius: 12px;
|
||
background: var(--tm-surface-2);
|
||
display: inline-grid; place-items: center;
|
||
font-size: 22px;
|
||
margin-bottom: 12px;
|
||
border: 1px solid var(--tm-border);
|
||
}
|
||
.tm-empty h3 { margin: 0 0 4px; font-size: 14px; font-weight: 600; letter-spacing: -0.005em; color: var(--tm-text); }
|
||
.tm-empty p { margin: 0 0 16px; color: var(--tm-text-muted); font-size: 13px; max-width: 360px; margin-inline-start: auto; margin-inline-end: auto; }
|
||
|
||
/* Group list */
|
||
.tm-group-list { margin: 8px 0 0; padding-inline-start: 20px; color: var(--tm-text-muted); font-size: 13px; }
|
||
.tm-group-list li { margin-bottom: 2px; }
|
||
|
||
/* Activity tab */
|
||
.tm-approval-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.tm-approval-item {
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 12px;
|
||
background: var(--tm-surface-2);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
}
|
||
.tm-approval-icon {
|
||
width: 36px; height: 36px;
|
||
border-radius: 50%;
|
||
background: var(--tm-warning-soft);
|
||
color: var(--tm-warning);
|
||
display: grid; place-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.tm-approval-icon ha-icon { --mdc-icon-size: 20px; }
|
||
.tm-approval-body { flex: 1; min-width: 0; }
|
||
.tm-approval-photo img { width: 44px; height: 44px; object-fit: cover; border-radius: 8px; display: block; }
|
||
.tm-approval-line { font-size: 13px; }
|
||
.tm-approval-actions { display: flex; gap: 6px; flex-shrink: 0; align-items: center; margin-inline-start: auto; }
|
||
/* Let the actions drop below the text on a narrow screen instead of
|
||
crushing it into a one-word column (the star picker made it worse). */
|
||
.tm-approval-item { flex-wrap: wrap; }
|
||
.tm-approval-body { flex: 1 1 180px; }
|
||
/* Quality rating star picker (#927) */
|
||
.tm-stars { display: inline-flex; gap: 2px; margin-inline-end: 4px; }
|
||
.tm-star {
|
||
background: none; border: 0; padding: 2px 3px; cursor: pointer;
|
||
font-size: 20px; line-height: 1; color: var(--tm-text-muted, #888);
|
||
border-radius: 6px;
|
||
}
|
||
.tm-star:hover { color: #f5b301; }
|
||
.tm-star.tm-star-on { color: #f5b301; }
|
||
.tm-star:focus-visible { outline: 2px solid var(--tm-accent); outline-offset: 1px; }
|
||
.tm-q-stars { color: #f5b301; letter-spacing: 1px; margin-inline-end: 6px; }
|
||
.tm-q-off { color: var(--tm-text-muted, #888); }
|
||
.tm-quality td, .tm-quality th { font-size: 13px; }
|
||
|
||
.tm-timeline { display: flex; flex-direction: column; }
|
||
.tm-timeline-row {
|
||
display: grid; grid-template-columns: 80px 32px 1fr auto;
|
||
gap: 12px; align-items: center;
|
||
padding: 10px 0;
|
||
border-top: 1px solid var(--tm-border-soft);
|
||
font-size: 13px;
|
||
}
|
||
.tm-timeline-row:first-child { border-top: 0; }
|
||
.tm-timeline-time { color: var(--tm-text-faint); font-size: 12px; font-feature-settings: "tnum"; }
|
||
.tm-timeline-icon {
|
||
width: 28px; height: 28px;
|
||
border-radius: 8px;
|
||
display: grid; place-items: center;
|
||
background: var(--tm-surface-2);
|
||
}
|
||
.tm-timeline-icon ha-icon { --mdc-icon-size: 16px; }
|
||
.tm-timeline-completion ha-icon { color: var(--tm-positive); }
|
||
.tm-timeline-claim ha-icon { color: var(--tm-gold); }
|
||
.tm-timeline-manual ha-icon { color: var(--tm-accent); }
|
||
/* Surprise inspections (#981) */
|
||
.tm-timeline-inspection ha-icon { color: #e67e22; }
|
||
.tm-insp-note { font-style: italic; margin-top: 2px; overflow-wrap: anywhere; }
|
||
.tm-insp-tag { margin-inline-start: 0; margin-top: 3px; max-width: 100%; white-space: normal; }
|
||
.tm-insp-tag ha-icon { --mdc-icon-size: 13px; }
|
||
.tm-insp-flag { vertical-align: middle; margin-inline-start: 4px; color: #e67e22; }
|
||
.tm-insp-flag ha-icon { --mdc-icon-size: 18px; }
|
||
.tm-insp-icon { background: color-mix(in srgb, #e67e22, transparent 86%); color: #e67e22; }
|
||
.tm-insp-item { border-color: color-mix(in srgb, #e67e22, transparent 60%); }
|
||
.tm-insp-ctx {
|
||
display: flex; gap: 12px; align-items: center; margin-bottom: 14px;
|
||
padding: 10px 12px; border-radius: var(--tm-radius-sm);
|
||
background: var(--tm-surface-2); border: 1px solid var(--tm-border);
|
||
}
|
||
.tm-insp-ctx-ic {
|
||
width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center;
|
||
background: color-mix(in srgb, #e67e22, transparent 86%); color: #e67e22;
|
||
}
|
||
.tm-insp-stepper { display: inline-flex; align-items: center; gap: 6px; }
|
||
.tm-insp-stepper .tm-input { width: 90px; text-align: center; }
|
||
.tm-insp-stepper .tm-btn { min-width: 36px; justify-content: center; font-size: 16px; }
|
||
.tm-insp-go { background: #e67e22 !important; border-color: #e67e22 !important; color: #fff !important; }
|
||
.tm-insp-go ha-icon { --mdc-icon-size: 18px; }
|
||
.tm-insp-opts { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||
.tm-insp-opt {
|
||
display: flex; gap: 10px; align-items: flex-start; text-align: start; width: 100%;
|
||
padding: 10px 12px; border-radius: var(--tm-radius-sm); cursor: pointer;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border); color: inherit; font: inherit;
|
||
}
|
||
.tm-insp-opt small { display: block; color: var(--tm-text-muted); margin-top: 2px; font-size: 12.5px; }
|
||
.tm-insp-opt-on { border-color: var(--tm-accent); background: var(--tm-accent-soft); }
|
||
.tm-insp-opt:disabled { opacity: 0.55; cursor: not-allowed; }
|
||
.tm-insp-radio {
|
||
width: 16px; height: 16px; border-radius: 50%; flex: none; margin-top: 2px;
|
||
border: 2px solid var(--tm-text-muted); box-sizing: border-box;
|
||
}
|
||
.tm-insp-opt-on .tm-insp-radio { border-color: var(--tm-accent); box-shadow: inset 0 0 0 3px var(--tm-surface-0); background: var(--tm-accent); }
|
||
.tm-insp-hint { display: flex; gap: 6px; align-items: center; }
|
||
.tm-insp-hint ha-icon { --mdc-icon-size: 15px; }
|
||
.tm-insp-settings { border-color: color-mix(in srgb, #e67e22, transparent 55%); }
|
||
.tm-insp-settings h3 ha-icon { --mdc-icon-size: 18px; color: #e67e22; vertical-align: -3px; }
|
||
.tm-insp-sub { padding-inline-start: 18px; }
|
||
.tm-insp-settings .tm-setting-label ha-icon { --mdc-icon-size: 16px; vertical-align: -3px; }
|
||
.tm-timeline-points { font-weight: 600; }
|
||
|
||
/* Settings */
|
||
.tm-settings { display: flex; flex-direction: column; gap: 16px; }
|
||
.tm-section {
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-lg);
|
||
box-shadow: var(--tm-shadow-xs);
|
||
}
|
||
.tm-section-head {
|
||
padding: 16px 20px 12px;
|
||
}
|
||
.tm-section-head h3 { margin: 0 0 3px; font-size: 14px; font-weight: 600; letter-spacing: -0.005em; }
|
||
.tm-section-head p { margin: 0; color: var(--tm-text-faint); font-size: 12.5px; }
|
||
.tm-section-body { padding: 0; }
|
||
/* Section content that isn't a .tm-setting-row pads itself (#972). */
|
||
.tm-section-pad { padding: 14px 20px 16px; border-top: 1px solid var(--tm-border-soft); }
|
||
.tm-section-body.tm-section-pad, .tm-section-body > .tm-section-pad:first-child { border-top: 0; padding-top: 4px; }
|
||
.tm-section-pad > p.tm-meta { margin: 0 0 12px; }
|
||
.tm-section-pad > p.tm-meta:last-child { margin-bottom: 0; }
|
||
.tm-subhead { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
|
||
.tm-setting-row {
|
||
display: grid; grid-template-columns: 280px 1fr;
|
||
gap: 20px; align-items: center;
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--tm-border-soft);
|
||
}
|
||
.tm-setting-row:first-child { border-top: 0; }
|
||
.tm-setting-label { color: var(--tm-text); font-size: 13px; font-weight: 500; }
|
||
.tm-setting-label small { display: block; color: var(--tm-text-faint); font-weight: 400; font-size: 12px; margin-top: 2px; }
|
||
.tm-difficulty-mults { display: flex; gap: 12px; flex-wrap: wrap; }
|
||
.tm-difficulty-mults label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--tm-text-faint); }
|
||
.tm-difficulty-mults .tm-input { width: 90px; }
|
||
.tm-setting-row ha-textfield,
|
||
.tm-setting-row ha-select,
|
||
.tm-setting-row input.tm-input,
|
||
.tm-setting-row select.tm-select {
|
||
max-width: 360px;
|
||
}
|
||
.tm-time-pair {
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.tm-time-pair .tm-input { max-width: 130px; }
|
||
.tm-time-pair span { color: var(--tm-text-faint); }
|
||
.tm-period-row {
|
||
grid-template-columns: 56px minmax(140px, 1fr) auto 36px;
|
||
gap: 12px;
|
||
}
|
||
.tm-period-row ha-icon-picker {
|
||
width: 56px; max-width: 56px; height: 56px;
|
||
border-radius: var(--tm-radius-sm, 8px); overflow: hidden;
|
||
/* Older HA renders the picker as a filled mwc-textfield: drop the fill
|
||
and its dark active-indicator line so no stray underline shows. */
|
||
--mdc-text-field-fill-color: transparent;
|
||
--mdc-text-field-idle-line-color: transparent;
|
||
--mdc-text-field-hover-line-color: transparent;
|
||
--mdc-text-field-focused-line-color: transparent;
|
||
--mdc-text-field-disabled-line-color: transparent;
|
||
}
|
||
.tm-period-row > .tm-input { max-width: none; }
|
||
.tm-vacation-row {
|
||
grid-template-columns: minmax(140px, 1fr) auto 36px;
|
||
gap: 12px;
|
||
}
|
||
.tm-vacation-row > .tm-input { max-width: none; }
|
||
.tm-bulk-bar {
|
||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||
padding: 10px 16px; margin: 0 0 10px;
|
||
background: var(--secondary-background-color, #f1f1f1);
|
||
border-radius: 10px;
|
||
}
|
||
.tm-bulk-count { font-weight: 600; margin-inline-end: auto; }
|
||
.tm-btn-on { background: var(--primary-color, #5b8def); color: #fff; }
|
||
.tm-audit-row {
|
||
display: grid;
|
||
grid-template-columns: 170px 120px minmax(0, 1.3fr) minmax(0, 1fr);
|
||
gap: 12px;
|
||
padding: 8px 20px;
|
||
border-top: 1px solid var(--tm-border-soft);
|
||
font-size: 13px;
|
||
}
|
||
.tm-audit-row:first-child { border-top: 0; }
|
||
.tm-audit-head { font-weight: 600; color: var(--tm-text-faint); }
|
||
.tm-audit-when, .tm-audit-user { color: var(--tm-text-faint); }
|
||
.tm-audit-action { font-family: var(--code-font-family, monospace); }
|
||
.tm-audit-action, .tm-audit-target { min-width: 0; overflow-wrap: anywhere; }
|
||
@media (max-width: 700px) {
|
||
.tm-audit-row { grid-template-columns: 1fr 1fr; }
|
||
.tm-audit-head { display: none; }
|
||
}
|
||
.tm-period-del {
|
||
color: var(--tm-text-faint);
|
||
}
|
||
.tm-period-del:not([disabled]):hover { color: var(--error-color, #d9434f); }
|
||
.tm-period-del[disabled] { opacity: .5; cursor: not-allowed; }
|
||
.tm-period-inuse {
|
||
grid-column: 1 / -1;
|
||
font-size: 12px; color: var(--tm-text-faint);
|
||
margin-top: -6px;
|
||
}
|
||
.tm-period-anytime {
|
||
grid-template-columns: 56px 1fr;
|
||
color: var(--tm-text-faint);
|
||
}
|
||
.tm-period-anytime ha-icon { margin: 0 auto; }
|
||
.tm-period-actions {
|
||
display: flex; gap: 10px;
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--tm-border-soft);
|
||
}
|
||
@media (max-width: 700px) {
|
||
.tm-period-row { grid-template-columns: 48px 1fr 36px; }
|
||
.tm-period-row .tm-time-pair { grid-column: 1 / -1; }
|
||
}
|
||
.tm-settings-foot {
|
||
display: flex; justify-content: flex-end;
|
||
padding-top: 8px;
|
||
}
|
||
|
||
/* Switch (HA native ha-switch) */
|
||
ha-switch {
|
||
--mdc-theme-secondary: var(--primary-color);
|
||
}
|
||
|
||
/* Dialog */
|
||
.tm-scrim {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
display: flex; align-items: flex-start; justify-content: center;
|
||
padding: 60px 20px; z-index: 100;
|
||
overflow-y: auto;
|
||
}
|
||
.tm-scrim.tm-enter { animation: tm-scrim-in 0.18s var(--tm-easing); }
|
||
@keyframes tm-scrim-in { from { opacity: 0; } to { opacity: 1; } }
|
||
.tm-dialog {
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-lg);
|
||
width: 100%; max-width: 560px;
|
||
box-shadow: var(--tm-shadow-lg);
|
||
display: flex; flex-direction: column;
|
||
max-height: calc(100vh - 120px);
|
||
overflow: hidden;
|
||
}
|
||
.tm-scrim.tm-enter > .tm-dialog { animation: tm-dialog-in 0.2s var(--tm-easing); }
|
||
.tm-dialog.tm-dialog-wide { max-width: 860px; }
|
||
@keyframes tm-dialog-in { from { opacity: 0; transform: translateY(8px) scale(0.985); } to { opacity: 1; transform: none; } }
|
||
|
||
/* Side-panel editor (#968): full height on the right, page visible behind. */
|
||
.tm-scrim.tm-scrim-drawer {
|
||
padding: 0; overflow: hidden;
|
||
justify-content: flex-end; align-items: stretch;
|
||
background: rgba(0, 0, 0, 0.32);
|
||
}
|
||
.tm-dialog.tm-drawer {
|
||
width: min(520px, 100vw); max-width: none; box-sizing: border-box;
|
||
height: 100%; max-height: none;
|
||
border-radius: 0; border-width: 0; border-inline-start-width: 1px;
|
||
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.18);
|
||
}
|
||
.tm-scrim.tm-enter > .tm-dialog.tm-drawer { animation: tm-drawer-in 0.22s var(--tm-easing); }
|
||
@keyframes tm-drawer-in { from { transform: translateX(100%); } to { transform: none; } }
|
||
/* RTL (#979): the drawer sits on the left, so it casts and slides the other way. */
|
||
[dir="rtl"] .tm-dialog.tm-drawer { box-shadow: 16px 0 40px rgba(0, 0, 0, 0.18); }
|
||
[dir="rtl"] .tm-scrim.tm-enter > .tm-dialog.tm-drawer { animation-name: tm-drawer-in-rtl; }
|
||
@keyframes tm-drawer-in-rtl { from { transform: translateX(-100%); } to { transform: none; } }
|
||
.tm-drawer .tm-dialog-body { flex: 1; padding-top: 4px; }
|
||
.tm-drawer-sec { padding-bottom: 6px; margin-bottom: 14px; border-bottom: 1px solid var(--tm-border-soft); }
|
||
.tm-drawer-sec:last-child { border-bottom: 0; margin-bottom: 0; }
|
||
.tm-drawer-sec-h {
|
||
margin: 12px 0 12px; font-size: 11.5px; font-weight: 600;
|
||
letter-spacing: 0.06em; text-transform: uppercase; color: var(--tm-text-muted);
|
||
}
|
||
.tm-drawer-delete { margin-inline-end: auto; color: var(--tm-danger); display: inline-flex; align-items: center; gap: 4px; }
|
||
.tm-drawer-delete ha-icon { --mdc-icon-size: 18px; }
|
||
@media (prefers-reduced-motion: reduce) { .tm-scrim.tm-enter > .tm-dialog.tm-drawer { animation: none; } }
|
||
|
||
.tm-dialog-head {
|
||
padding: 18px 22px;
|
||
border-bottom: 1px solid var(--tm-border-soft);
|
||
display: flex; align-items: center;
|
||
}
|
||
.tm-dialog-head h2 {
|
||
margin: 0; font-size: 16px; font-weight: 600;
|
||
letter-spacing: -0.01em;
|
||
flex: 1;
|
||
}
|
||
.tm-dialog-body { padding: 16px 20px; overflow-y: auto; }
|
||
.tm-dialog-foot {
|
||
padding: 12px 20px;
|
||
border-top: 1px solid var(--tm-border-soft);
|
||
background: var(--tm-surface-2);
|
||
display: flex; justify-content: flex-end; gap: 8px;
|
||
}
|
||
|
||
.tm-field { display: block; margin-bottom: 14px; }
|
||
.tm-field-label {
|
||
display: block; color: var(--tm-text);
|
||
font-size: 12.5px; margin-bottom: 5px;
|
||
font-weight: 500;
|
||
}
|
||
.tm-field ha-textfield, .tm-field ha-select {
|
||
display: block; width: 100%;
|
||
}
|
||
.tm-input, .tm-select {
|
||
width: 100%; box-sizing: border-box;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
padding: 10px 12px;
|
||
color: var(--tm-text);
|
||
font-size: 13px; font-family: inherit;
|
||
box-shadow: var(--tm-shadow-xs);
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-input:hover, .tm-select:hover { border-color: var(--tm-border-strong); }
|
||
.tm-input:focus, .tm-select:focus { outline: 0; border-color: var(--tm-accent); box-shadow: var(--tm-shadow-focus); }
|
||
.tm-input::placeholder { color: var(--tm-text-vfaint); }
|
||
.tm-select {
|
||
cursor: pointer;
|
||
appearance: none; -webkit-appearance: none;
|
||
padding-inline-end: 32px;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: right 10px center; /* rtl-ok: [dir="rtl"] override below */
|
||
}
|
||
[dir="rtl"] .tm-select { background-position: left 10px center; } /* rtl-ok: RTL side of the rule above */
|
||
.tm-select option { background: var(--tm-surface-0); color: var(--tm-text); }
|
||
.tm-textarea {
|
||
width: 100%;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
padding: 7px 10px;
|
||
color: var(--tm-text);
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
box-sizing: border-box;
|
||
box-shadow: var(--tm-shadow-xs);
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-textarea:focus {
|
||
outline: 0;
|
||
border-color: var(--tm-accent);
|
||
box-shadow: var(--tm-shadow-focus);
|
||
}
|
||
.tm-ep { position: relative; }
|
||
.tm-ep-search-wrap {
|
||
display: flex; align-items: center;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm); padding: 0 10px;
|
||
box-shadow: var(--tm-shadow-xs); transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-ep-search-wrap:focus-within {
|
||
border-color: var(--tm-accent);
|
||
box-shadow: var(--tm-shadow-focus);
|
||
}
|
||
.tm-ep-search-icon {
|
||
--mdc-icon-size: 18px;
|
||
color: var(--tm-text-faint); flex-shrink: 0; margin-inline-end: 6px;
|
||
}
|
||
.tm-ep-input {
|
||
flex: 1; border: 0; background: transparent; outline: 0;
|
||
color: var(--tm-text); font-size: 13px; font-family: inherit;
|
||
padding: 8px 0; width: 100%;
|
||
}
|
||
.tm-ep-input::placeholder { color: var(--tm-text-faint); }
|
||
.tm-ep-selected {
|
||
display: flex; align-items: center; gap: 8px;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm); padding: 6px 10px;
|
||
cursor: pointer; transition: all 0.1s var(--tm-easing);
|
||
box-shadow: var(--tm-shadow-xs);
|
||
}
|
||
.tm-ep-selected:hover { border-color: var(--tm-accent); }
|
||
.tm-ep-selected-icon { --mdc-icon-size: 20px; color: var(--tm-text-muted); flex-shrink: 0; }
|
||
.tm-ep-selected-text {
|
||
flex: 1; font-size: 13px; color: var(--tm-text);
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.tm-entity-clear {
|
||
width: 20px; height: 20px;
|
||
border: 0; background: var(--tm-surface-2);
|
||
border-radius: 50%; cursor: pointer;
|
||
font-size: 11px; color: var(--tm-text-faint);
|
||
display: grid; place-items: center;
|
||
font-family: inherit; flex-shrink: 0;
|
||
}
|
||
.tm-entity-clear:hover { background: var(--tm-danger-soft); color: var(--tm-danger); }
|
||
.tm-ep-dropdown {
|
||
position: absolute; top: 100%; inset-inline-start: 0; inset-inline-end: 0;
|
||
max-height: 260px; overflow-y: auto;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius); z-index: 200;
|
||
margin-top: 4px; padding: 4px 0;
|
||
box-shadow: var(--tm-shadow-lg);
|
||
}
|
||
.tm-ep-option {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 12px; cursor: pointer;
|
||
transition: background 0.08s;
|
||
}
|
||
.tm-ep-option:hover { background: var(--tm-surface-hover); }
|
||
.tm-ep-option-icon { --mdc-icon-size: 20px; color: var(--tm-text-muted); flex-shrink: 0; }
|
||
.tm-ep-option-text { flex: 1; min-width: 0; }
|
||
.tm-ep-id { font-size: 13px; color: var(--tm-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tm-ep-name { font-size: 11px; color: var(--tm-text-muted); margin-top: 1px; font-family: ui-monospace, monospace; }
|
||
.tm-field-hint {
|
||
display: block; color: var(--tm-text-faint);
|
||
font-size: 11.5px; margin-top: 4px; line-height: 1.5;
|
||
}
|
||
.tm-field-hint code {
|
||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||
background: var(--tm-surface-2); padding: 1px 5px;
|
||
border-radius: 3px; font-size: 11px;
|
||
}
|
||
.tm-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.tm-textarea { resize: vertical; min-height: 100px; line-height: 1.5; }
|
||
|
||
.tm-field ha-icon-picker,
|
||
.tm-section-body ha-icon-picker {
|
||
display: block; width: 100%;
|
||
}
|
||
|
||
/* Icon field well: make ha-icon-picker sit in the dialogs like a
|
||
tm-input. The picker's inner shell can't be reached from here (shadow
|
||
DOM), so the wrapper carries the frame and the picker is stripped back:
|
||
- the fill and the 1px "active indicator" are var() usages inside
|
||
ha-picker-field, so overriding the vars here does reach them;
|
||
- ha-combo-box-item hard-sets its own 56px row height and square
|
||
bottom corners, which no variable overrides — so the well is
|
||
tm-input height with overflow:hidden, and the picker is pulled up
|
||
by half the difference to keep its content optically centred.
|
||
The --mdc-* vars cover older HA, which still renders an mwc textfield. */
|
||
.tm-icon-well {
|
||
box-sizing: border-box;
|
||
height: 37px;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
box-shadow: var(--tm-shadow-xs);
|
||
overflow: hidden;
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-icon-well:hover { border-color: var(--tm-border-strong); }
|
||
.tm-icon-well:focus-within { border-color: var(--tm-accent); box-shadow: var(--tm-shadow-focus); }
|
||
.tm-icon-well ha-icon-picker {
|
||
display: block; width: 100%;
|
||
margin-top: calc((37px - 56px) / 2);
|
||
--ha-color-form-background: transparent;
|
||
--ha-color-border-neutral-loud: transparent;
|
||
--md-list-item-leading-space: 11px;
|
||
--md-list-item-trailing-space: 4px;
|
||
--md-list-item-label-text-size: 13px;
|
||
--mdc-text-field-fill-color: transparent;
|
||
--mdc-text-field-idle-line-color: transparent;
|
||
--mdc-text-field-hover-line-color: transparent;
|
||
--mdc-text-field-focused-line-color: transparent;
|
||
}
|
||
|
||
/* Chip multi-select */
|
||
/* Insight reports (#679) */
|
||
.tm-verdict {
|
||
border-radius: 10px; padding: 10px 14px; margin: 10px 0 14px;
|
||
font-weight: 700; font-size: 13.5px;
|
||
}
|
||
.tm-verdict-ok { background: rgba(46, 125, 50, 0.12); color: #2e7d32; }
|
||
.tm-verdict-warn { background: rgba(239, 108, 0, 0.14); color: #ef6c00; }
|
||
.tm-fairness { display: flex; flex-direction: column; gap: 14px; }
|
||
.tm-fair-row { display: grid; grid-template-columns: 120px 1fr auto; gap: 10px 12px; align-items: center; }
|
||
.tm-fair-name { font-weight: 700; font-size: 14px; }
|
||
.tm-fair-bar {
|
||
position: relative; height: 14px; border-radius: 99px;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
overflow: visible;
|
||
}
|
||
.tm-fair-bar > i {
|
||
display: block; height: 100%; border-radius: 99px;
|
||
background: var(--tm-accent); min-width: 2px;
|
||
}
|
||
.tm-fair-bar > i.tm-fair-over { background: #ef6c00; }
|
||
.tm-fair-bar > i.tm-fair-under { background: #7e57c2; }
|
||
.tm-fair-bar > i.tm-fair-balanced { background: #2e7d32; }
|
||
/* Marks where an even split would sit, so "fair" is visible not implied. */
|
||
.tm-fair-target {
|
||
position: absolute; top: -3px; width: 2px; height: 20px;
|
||
background: var(--tm-text-muted); opacity: 0.65;
|
||
}
|
||
.tm-fair-figs { grid-column: 2; font-size: 12.5px; color: var(--tm-text-muted); }
|
||
.tm-fair-status {
|
||
font-size: 11.5px; font-weight: 800; text-transform: uppercase;
|
||
letter-spacing: 0.03em; white-space: nowrap;
|
||
}
|
||
.tm-fair-status.tm-fair-over { color: #ef6c00; }
|
||
.tm-fair-status.tm-fair-under { color: #7e57c2; }
|
||
.tm-fair-status.tm-fair-balanced { color: #2e7d32; }
|
||
.tm-fair-status.tm-fair-idle { color: var(--tm-text-muted); }
|
||
@media (max-width: 640px) {
|
||
.tm-fair-row { grid-template-columns: 1fr auto; }
|
||
.tm-fair-bar { grid-column: 1 / -1; }
|
||
}
|
||
|
||
/* Health & diagnostics (#682) */
|
||
.tm-health-issues { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; }
|
||
.tm-health-issue {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 10px 12px; border-radius: 10px;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
}
|
||
.tm-health-sev {
|
||
font-size: 11px; font-weight: 800; text-transform: uppercase;
|
||
padding: 2px 8px; border-radius: 999px; white-space: nowrap;
|
||
}
|
||
.tm-health-error .tm-health-sev { background: rgba(198,40,40,.14); color: #c62828; }
|
||
.tm-health-warning .tm-health-sev { background: rgba(239,108,0,.16); color: #ef6c00; }
|
||
.tm-health-info .tm-health-sev { background: var(--tm-surface-1, #eee); color: var(--tm-text-muted); }
|
||
.tm-health-msg { flex: 1; font-size: 13.5px; line-height: 1.45; }
|
||
.tm-btn-small { padding: 4px 10px; font-size: 12px; }
|
||
.tm-health-counts {
|
||
display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.tm-health-stat {
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: 10px; padding: 10px; text-align: center;
|
||
}
|
||
.tm-health-num { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||
.tm-health-label { font-size: 11.5px; color: var(--tm-text-muted); margin-top: 2px; }
|
||
|
||
/* Projection report (#681) */
|
||
.tm-proj-totals { display: flex; flex-direction: column; gap: 12px; margin: 12px 0 16px; }
|
||
.tm-proj-kid { display: grid; grid-template-columns: 120px 1fr; gap: 6px 12px; align-items: center; }
|
||
.tm-proj-kid .tm-fair-figs { grid-column: 2; }
|
||
.tm-projection td, .tm-projection th { font-size: 13px; text-align: center; }
|
||
.tm-projection td:first-child, .tm-projection th:first-child { text-align: start; }
|
||
.tm-proj-date { color: var(--tm-text-muted); font-size: 11.5px; }
|
||
.tm-proj-count { color: var(--tm-text-muted); font-size: 11.5px; }
|
||
|
||
/* Friction report (#680) */
|
||
.tm-friction td, .tm-friction th { font-size: 13px; }
|
||
.tm-fric-verdict {
|
||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||
font-size: 11.5px; font-weight: 800; text-transform: uppercase;
|
||
}
|
||
.tm-fric-never { background: rgba(198, 40, 40, 0.14); color: #c62828; }
|
||
.tm-fric-stalling { background: rgba(239, 108, 0, 0.16); color: #ef6c00; }
|
||
.tm-fric-struggling { background: rgba(251, 192, 45, 0.20); color: #a67c00; }
|
||
.tm-fric-fine { background: rgba(46, 125, 50, 0.14); color: #2e7d32; }
|
||
.tm-fric-unknown { background: var(--tm-surface-0); color: var(--tm-text-muted); }
|
||
.tm-fric-misses { color: #ef6c00; font-weight: 800; font-size: 12px; }
|
||
|
||
/* Timed unlock allowlist (#678) */
|
||
.tm-setting-stack { flex-direction: column; align-items: stretch; }
|
||
.tm-allowlist { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||
.tm-allow-chip {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: 999px; padding-block: 4px; padding-inline: 12px 6px;
|
||
font-size: 12.5px; font-family: var(--tm-mono, monospace);
|
||
}
|
||
.tm-allow-chip button {
|
||
background: none; border: 0; cursor: pointer; padding: 0 2px;
|
||
color: var(--tm-text-muted); font-size: 13px; line-height: 1;
|
||
}
|
||
|
||
/* Scheduled config changes (#675) */
|
||
.tm-sched-count {
|
||
display: inline-block; min-width: 18px; padding: 0 5px;
|
||
background: var(--tm-accent); color: #fff;
|
||
border-radius: 999px; font-size: 11px; font-weight: 700;
|
||
text-align: center; vertical-align: middle;
|
||
}
|
||
.tm-sched-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||
.tm-sched-row {
|
||
display: flex; align-items: flex-start; gap: 10px;
|
||
padding: 8px 10px; border-radius: 8px;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
}
|
||
.tm-sched-when {
|
||
font-variant-numeric: tabular-nums; font-weight: 700;
|
||
font-size: 12.5px; white-space: nowrap; padding-top: 1px;
|
||
}
|
||
.tm-sched-what { flex: 1; font-size: 12.5px; line-height: 1.5; }
|
||
.tm-sched-note { color: var(--tm-text-muted); font-size: 11.5px; margin-top: 2px; }
|
||
.tm-sched-add {
|
||
border-top: 1px solid var(--tm-border);
|
||
padding-top: 10px; margin-top: 4px;
|
||
}
|
||
.tm-sched-applied { margin-top: 14px; }
|
||
.tm-sched-done { opacity: 0.65; }
|
||
.tm-sched-tick { color: var(--tm-success, #2e7d32); font-weight: 800; }
|
||
|
||
.tm-chip-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
/* Recaps (#929) */
|
||
.tm-recap-cell { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||
.tm-recap-next { display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: var(--tm-text-muted); }
|
||
.tm-recap-next b { color: var(--tm-text); font-weight: 500; }
|
||
.tm-recap-off { display: inline-flex; align-items: center; gap: 6px; color: var(--tm-warning, #f5b041); }
|
||
.tm-recap-off ha-icon { --mdc-icon-size: 14px; }
|
||
.tm-recap-kids { padding: 14px 20px; border-top: 1px solid var(--tm-border-soft); display: flex; flex-direction: column; gap: 10px; }
|
||
.tm-recap-kids .tm-table td { vertical-align: top; }
|
||
.tm-recap-mode { display: inline-flex; align-self: flex-start; border: 1px solid var(--tm-border); border-radius: 8px; overflow: hidden; }
|
||
.tm-recap-mode button { background: transparent; border: 0; color: var(--tm-text-muted); padding: 5px 11px; font: inherit; font-size: 12px; cursor: pointer; }
|
||
.tm-recap-mode button.on { background: var(--tm-accent-soft); color: var(--tm-accent-text); font-weight: 600; }
|
||
.tm-chip-btn[disabled] { opacity: 0.55; cursor: default; }
|
||
.tm-recap-switches { display: flex; gap: 18px; flex-wrap: wrap; align-items: center; }
|
||
.tm-recap-switches label { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--tm-text-muted); }
|
||
.tm-recap-preview { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||
.tm-chip-btn {
|
||
background: var(--tm-surface-0);
|
||
color: var(--tm-text-muted);
|
||
border: 1px solid var(--tm-border);
|
||
padding: 4px 10px; border-radius: 999px;
|
||
font-size: 12.5px; font-weight: 500;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-chip-btn:hover { color: var(--tm-text); border-color: var(--tm-border-strong); }
|
||
.tm-chip-on {
|
||
background: var(--tm-accent-soft);
|
||
color: var(--tm-accent-text);
|
||
border-color: var(--tm-accent-border);
|
||
}
|
||
|
||
.tm-day-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
|
||
.tm-day-btn {
|
||
background: var(--tm-surface-0);
|
||
color: var(--tm-text-muted);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
padding: 8px 0;
|
||
font-size: 11.5px; font-weight: 600;
|
||
font-family: inherit; cursor: pointer;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
transition: all 0.1s var(--tm-easing);
|
||
}
|
||
.tm-day-btn:hover { color: var(--tm-text); border-color: var(--tm-border-strong); }
|
||
.tm-day-on {
|
||
background: var(--tm-accent); color: var(--text-primary-color, #fff);
|
||
border-color: var(--tm-accent);
|
||
}
|
||
|
||
.tm-check-row {
|
||
display: flex; align-items: flex-start; gap: 14px;
|
||
padding: 12px 0;
|
||
border-top: 1px solid var(--tm-border-soft);
|
||
}
|
||
.tm-check-row:first-child { border-top: 0; padding-top: 0; }
|
||
.tm-check-row > div { flex: 1; }
|
||
.tm-check-title { font-size: 13px; color: var(--tm-text); font-weight: 500; }
|
||
|
||
details.tm-advanced {
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
margin-top: 16px;
|
||
overflow: hidden;
|
||
}
|
||
details.tm-advanced summary {
|
||
cursor: pointer; padding: 12px 14px;
|
||
color: var(--tm-text-muted); font-size: 12px; font-weight: 500;
|
||
user-select: none; list-style: none;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
details.tm-advanced summary::-webkit-details-marker { display: none; }
|
||
details.tm-advanced summary::before {
|
||
content: "▸";
|
||
transition: transform 0.15s var(--tm-easing);
|
||
color: var(--tm-text-faint);
|
||
font-size: 10px;
|
||
}
|
||
details.tm-advanced[open] summary::before { transform: rotate(90deg); }
|
||
[dir="rtl"] details.tm-advanced:not([open]) summary::before { transform: scaleX(-1); }
|
||
details.tm-advanced > div {
|
||
padding: 14px;
|
||
border-top: 1px solid var(--tm-border-soft);
|
||
}
|
||
|
||
/* Reorder dialog */
|
||
.tm-reorder-list { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; }
|
||
.tm-reorder-item {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px 12px;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
cursor: grab;
|
||
transition: all 0.12s var(--tm-easing);
|
||
}
|
||
.tm-reorder-item:hover { border-color: var(--tm-accent); background: var(--tm-surface-2); }
|
||
.tm-reorder-item.tm-dragging { opacity: 0.4; }
|
||
.tm-reorder-handle { color: var(--tm-text-faint); cursor: grab; font-size: 18px; user-select: none; }
|
||
.tm-reorder-name { flex: 1; font-size: 13px; font-weight: 500; }
|
||
.tm-reorder-points { color: var(--tm-accent); font-weight: 600; font-size: 13px; }
|
||
.tm-reorder-moves { display: flex; flex-direction: column; gap: 2px; }
|
||
.tm-reorder-move {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 28px; height: 20px; padding: 0;
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm);
|
||
background: var(--tm-surface-1);
|
||
color: var(--tm-text);
|
||
font-size: 10px; line-height: 1;
|
||
cursor: pointer;
|
||
transition: all 0.12s var(--tm-easing);
|
||
}
|
||
.tm-reorder-move:hover:not(:disabled) { border-color: var(--tm-accent); color: var(--tm-accent); }
|
||
.tm-reorder-move:disabled { opacity: 0.3; cursor: default; }
|
||
|
||
/* Toast */
|
||
.tm-toast {
|
||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); /* rtl-ok: centred with translate(-50%), symmetric */
|
||
padding: 10px 16px; border-radius: var(--tm-radius);
|
||
font-size: 13px; font-weight: 500;
|
||
box-shadow: var(--tm-shadow);
|
||
z-index: 200;
|
||
animation: tm-toast-in 0.2s var(--tm-easing);
|
||
}
|
||
@keyframes tm-toast-in { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translateX(-50%); } }
|
||
.tm-toast-ok { background: var(--tm-positive); color: var(--text-primary-color, #fff); }
|
||
.tm-toast-err { background: var(--tm-danger); color: var(--text-primary-color, #fff); }
|
||
|
||
/* Templates */
|
||
.tm-section-divider {
|
||
display: flex; align-items: center; gap: 12px;
|
||
margin: 20px 0 14px; font-size: 11.5px; font-weight: 600;
|
||
color: var(--tm-text-faint); text-transform: uppercase; letter-spacing: 0.04em;
|
||
}
|
||
.tm-section-divider::after { content: ''; flex: 1; height: 1px; background: var(--tm-border); }
|
||
.tm-tpl-icon {
|
||
width: 40px; height: 40px; border-radius: 10px;
|
||
background: var(--tm-accent-soft); border: 1px solid var(--tm-border);
|
||
display: grid; place-items: center; flex-shrink: 0;
|
||
color: var(--tm-accent); --mdc-icon-size: 20px;
|
||
}
|
||
.tm-tpl-picker-card { cursor: pointer; display: flex; flex-direction: column; gap: 10px; }
|
||
.tm-tpl-picker-card:hover { border-color: var(--tm-accent); background: var(--tm-accent-soft); }
|
||
.tm-tpl-picker-head { display: flex; align-items: center; gap: 12px; }
|
||
.tm-tpl-picker-name { font-size: 15px; font-weight: 600; }
|
||
.tm-tpl-chore-pills { display: flex; flex-wrap: wrap; gap: 5px; }
|
||
.tm-tpl-chore-pill {
|
||
font-size: 11px; padding: 2px 8px;
|
||
background: var(--tm-surface-2); border-radius: 999px;
|
||
color: var(--tm-text-muted); border: 1px solid var(--tm-border-soft);
|
||
}
|
||
.tm-manage-tpl {
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 14px 18px; background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border); border-radius: var(--tm-radius-lg);
|
||
margin-bottom: 10px; transition: all 0.15s;
|
||
}
|
||
.tm-manage-tpl:hover { border-color: var(--tm-border-strong); box-shadow: var(--tm-shadow-sm); }
|
||
.tm-manage-tpl-locked { opacity: 0.65; }
|
||
.tm-manage-tpl-locked:hover { border-color: var(--tm-border); box-shadow: none; }
|
||
.tm-manage-tpl-info { flex: 1; }
|
||
.tm-manage-tpl-name { font-size: 14px; font-weight: 600; }
|
||
.tm-tpl-preview-card {
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-lg); overflow: hidden; margin-bottom: 10px;
|
||
}
|
||
.tm-tpl-preview-card:hover { border-color: var(--tm-border-strong); }
|
||
.tm-tpl-preview-header {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 12px 16px; cursor: pointer;
|
||
}
|
||
.tm-tpl-preview-header:hover { background: var(--tm-surface-hover); }
|
||
.tm-tpl-expand { font-size: 11px; color: var(--tm-text-faint); transition: transform 0.2s; }
|
||
.tm-tpl-expand.open { transform: rotate(90deg); }
|
||
[dir="rtl"] .tm-tpl-expand { display: inline-block; }
|
||
[dir="rtl"] .tm-tpl-expand:not(.open) { transform: scaleX(-1); }
|
||
/* RTL (#979): icons that point along the reading direction turn round. */
|
||
[dir="rtl"] .tm-rtl-flip { transform: scaleX(-1); }
|
||
.tm-tpl-preview-name { font-size: 14px; font-weight: 600; flex: 1; }
|
||
.tm-tpl-preview-summary { font-size: 12px; color: var(--tm-text-muted); display: flex; gap: 12px; }
|
||
.tm-tpl-remove {
|
||
width: 26px; height: 26px; border: none; background: transparent;
|
||
border-radius: var(--tm-radius-sm); color: var(--tm-text-faint);
|
||
cursor: pointer; display: grid; place-items: center; font-size: 14px;
|
||
}
|
||
.tm-tpl-remove:hover { background: var(--tm-danger-soft); color: var(--tm-danger); }
|
||
.tm-tpl-preview-body { padding: 14px 16px; border-top: 1px solid var(--tm-border-soft); }
|
||
.tm-tpl-confirm-bar {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||
padding: 16px 18px; margin-top: 16px;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-lg);
|
||
}
|
||
.tm-tpl-confirm-bar div { font-size: 13px; color: var(--tm-text-muted); }
|
||
.tm-tpl-confirm-bar strong { color: var(--tm-text); }
|
||
.tm-tpl-checklist { max-height: 320px; overflow-y: auto; }
|
||
.tm-tpl-check-row {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 0; border-bottom: 1px solid var(--tm-border-soft);
|
||
font-size: 13px; cursor: pointer;
|
||
}
|
||
.tm-tpl-check-row:last-child { border-bottom: none; }
|
||
.tm-tpl-check-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--tm-accent); }
|
||
.tm-day-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.tm-day-pill {
|
||
padding: 5px 12px; border: 1px solid var(--tm-border); border-radius: 999px;
|
||
font-size: 12px; font-weight: 500; cursor: pointer;
|
||
background: var(--tm-surface-0); color: var(--tm-text-muted); transition: all 0.1s;
|
||
user-select: none;
|
||
}
|
||
.tm-day-pill.active { background: var(--tm-accent); color: var(--text-primary-color, #fff); border-color: var(--tm-accent); }
|
||
|
||
/* ===== Badges ===== */
|
||
.tm-badge-subtabs {
|
||
display: flex; gap: 2px; border-bottom: 1px solid var(--tm-border);
|
||
margin-bottom: 16px;
|
||
}
|
||
.tm-badge-subtab {
|
||
padding: 9px 14px; font-size: 13px; font-weight: 500;
|
||
color: var(--tm-text-muted); background: transparent; border: 0;
|
||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||
cursor: pointer; font-family: inherit; transition: color 0.1s;
|
||
}
|
||
.tm-badge-subtab:hover { color: var(--tm-text); }
|
||
.tm-badge-subtab-active { color: var(--tm-accent); border-bottom-color: var(--tm-accent); }
|
||
|
||
.tm-badge-row td { vertical-align: middle; }
|
||
|
||
/* tier colour token */
|
||
.tm-badge-tier-bronze { --badge-t: #cd7f32; }
|
||
.tm-badge-tier-silver { --badge-t: #c0c0c0; }
|
||
.tm-badge-tier-gold { --badge-t: #f1c40f; }
|
||
.tm-badge-tier-platinum { --badge-t: #67e8f9; }
|
||
|
||
.tm-badge-icon-sm {
|
||
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
|
||
background: var(--badge-t, #cd7f32);
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
color: #1a1a1a; --mdc-icon-size: 18px; vertical-align: middle;
|
||
}
|
||
.tm-badge-tier-label {
|
||
font-size: 10.5px; font-weight: 700; text-transform: uppercase;
|
||
letter-spacing: 1px; color: var(--badge-t, #cd7f32);
|
||
}
|
||
.tm-badge-criteria {
|
||
font-family: ui-monospace, monospace; font-size: 11.5px;
|
||
color: var(--tm-text-muted); white-space: nowrap;
|
||
}
|
||
.tm-badge-toggle {
|
||
width: 36px; height: 20px; border-radius: 999px;
|
||
background: var(--tm-border-strong); border: 0; cursor: pointer;
|
||
position: relative; transition: background 0.15s; flex-shrink: 0;
|
||
}
|
||
.tm-badge-toggle::after {
|
||
content: ""; position: absolute; top: 2px; inset-inline-start: 2px;
|
||
width: 16px; height: 16px; border-radius: 50%;
|
||
background: #fff; transition: transform 0.15s;
|
||
}
|
||
.tm-badge-toggle-on { background: var(--tm-accent); }
|
||
.tm-badge-toggle-on::after { transform: translateX(16px); }
|
||
[dir="rtl"] .tm-badge-toggle-on::after { transform: translateX(-16px); }
|
||
|
||
.tm-badge-src {
|
||
font-size: 10px; font-weight: 700; padding: 2px 8px;
|
||
border-radius: 999px; background: var(--tm-surface-2); color: var(--tm-text-muted);
|
||
}
|
||
.tm-badge-src-manual { background: #3a2a1a; color: #f1c40f; }
|
||
.tm-badge-src-silent { background: var(--tm-surface-2); color: var(--tm-text-faint); }
|
||
.tm-badge-src-auto { background: var(--tm-accent-soft); color: var(--tm-accent-text); }
|
||
|
||
.tm-badge-criteria-block {
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm); padding: 12px; margin-top: 6px;
|
||
}
|
||
.tm-badge-criterion-row {
|
||
display: grid; grid-template-columns: 1.4fr 30px 0.8fr 28px;
|
||
gap: 6px; align-items: center; margin-bottom: 8px;
|
||
}
|
||
.tm-badge-op { font-size: 16px; text-align: center; color: var(--tm-text-muted); }
|
||
|
||
/* ===== Notifications: custom card buttons ===== */
|
||
.tm-notif-custom-card {
|
||
margin-top: 10px;
|
||
padding: 14px;
|
||
background: var(--tm-surface-1);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: 10px;
|
||
}
|
||
.tm-notif-custom-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 110px 42px 32px;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.tm-notif-custom-msg { width: 100%; margin-bottom: 12px; }
|
||
.tm-notif-toggle-group {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 8px;
|
||
}
|
||
.tm-notif-toggle-group:last-child { margin-bottom: 0; }
|
||
.tm-notif-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
padding: 7px 14px;
|
||
font-size: 12.5px;
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
line-height: 1.2;
|
||
background: var(--tm-surface-0);
|
||
color: var(--tm-text);
|
||
border: 1px solid var(--tm-border);
|
||
transition: background 0.12s var(--tm-easing), color 0.12s var(--tm-easing), border-color 0.12s var(--tm-easing);
|
||
user-select: none;
|
||
}
|
||
.tm-notif-toggle:hover {
|
||
background: var(--tm-accent-soft);
|
||
border-color: var(--tm-accent);
|
||
color: var(--tm-accent-text);
|
||
}
|
||
.tm-notif-toggle input[type="checkbox"] { display: none; }
|
||
.tm-notif-toggle-day { border-radius: var(--tm-radius-sm); min-width: 46px; }
|
||
.tm-notif-toggle-pill { border-radius: 999px; }
|
||
.tm-notif-toggle-on {
|
||
background: var(--tm-accent);
|
||
color: var(--text-primary-color, #fff);
|
||
border-color: var(--tm-accent);
|
||
}
|
||
.tm-notif-toggle-on:hover {
|
||
background: var(--tm-accent-hover);
|
||
color: var(--text-primary-color, #fff);
|
||
border-color: var(--tm-accent-hover);
|
||
}
|
||
|
||
/* Notification toggle switches (CSS-only on native checkbox) */
|
||
.tm-notif-switch {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 38px; height: 22px;
|
||
background: var(--tm-border-strong);
|
||
border: none; border-radius: 999px;
|
||
cursor: pointer; position: relative;
|
||
transition: background 0.15s;
|
||
flex-shrink: 0; vertical-align: middle;
|
||
margin: 0;
|
||
}
|
||
.tm-notif-switch::after {
|
||
content: ""; position: absolute;
|
||
top: 3px; inset-inline-start: 3px;
|
||
width: 16px; height: 16px; border-radius: 50%;
|
||
background: #fff; transition: transform 0.15s;
|
||
}
|
||
.tm-notif-switch:checked { background: var(--tm-accent); }
|
||
.tm-notif-switch:checked::after { transform: translateX(16px); }
|
||
[dir="rtl"] .tm-notif-switch:checked::after { transform: translateX(-16px); }
|
||
.tm-notif-switch:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
|
||
/* Notification matrix cells */
|
||
.tm-notif-matrix-cell { text-align: center; vertical-align: middle; }
|
||
.tm-notif-time-input {
|
||
display: block; margin: 6px auto 0; width: 86px;
|
||
font-size: 12px; padding: 5px 6px; text-align: center;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm); color: var(--tm-text);
|
||
font-family: inherit; transition: border-color 0.1s;
|
||
}
|
||
.tm-notif-time-input:focus {
|
||
outline: 0; border-color: var(--tm-accent);
|
||
box-shadow: var(--tm-shadow-focus);
|
||
}
|
||
.tm-notif-time-input:disabled { opacity: 0.35; }
|
||
|
||
/* Notification recipient selects */
|
||
.tm-notif-select {
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-sm); padding: 7px 10px;
|
||
color: var(--tm-text); font-size: 12.5px; font-family: inherit;
|
||
cursor: pointer; min-width: 0; max-width: 240px; transition: border-color 0.1s;
|
||
}
|
||
.tm-notif-select:hover { border-color: var(--tm-border-strong); }
|
||
.tm-notif-select:focus { outline: 0; border-color: var(--tm-accent); box-shadow: var(--tm-shadow-focus); }
|
||
|
||
/* Notification delete button */
|
||
.tm-notif-delete {
|
||
width: 30px; height: 30px;
|
||
border: 1px solid transparent; background: transparent;
|
||
border-radius: var(--tm-radius-sm);
|
||
display: grid; place-items: center;
|
||
color: var(--tm-text-faint); cursor: pointer;
|
||
font-size: 16px; font-family: inherit;
|
||
transition: all 0.1s;
|
||
}
|
||
.tm-notif-delete:hover {
|
||
background: var(--tm-danger-soft); color: var(--tm-danger);
|
||
border-color: var(--tm-danger-soft);
|
||
}
|
||
|
||
/* Power user info box */
|
||
.tm-notif-info-box {
|
||
margin-top: 16px; padding: 14px 16px;
|
||
background: var(--tm-surface-1); border: 1px solid var(--tm-border);
|
||
border-inline-start: 3px solid var(--tm-accent, #4fc3f7);
|
||
border-radius: var(--tm-radius-sm);
|
||
font-size: 12.5px; color: var(--tm-text-muted); line-height: 1.55;
|
||
}
|
||
.tm-notif-info-box strong {
|
||
color: var(--tm-text); display: block;
|
||
margin-bottom: 4px; font-size: 13px;
|
||
}
|
||
|
||
/* ===== Mobile / narrow ===== */
|
||
@media (max-width: 900px) {
|
||
.tm-shell { grid-template-columns: 1fr; }
|
||
.tm-sidebar { display: none; }
|
||
.tm-menu-btn { display: inline-flex; }
|
||
.tm-topbar { padding: 0 12px; }
|
||
/* Redundant with the section picker below — hide on mobile. */
|
||
.tm-crumbs { display: none; }
|
||
.tm-approval-pill { margin-inline-start: auto; }
|
||
|
||
/* Section picker replaces the old horizontal tab strip. */
|
||
.tm-main { position: relative; }
|
||
.tm-mobilenav {
|
||
display: block;
|
||
position: relative;
|
||
z-index: 5;
|
||
flex-shrink: 0;
|
||
background: var(--tm-surface-0);
|
||
border-bottom: 1px solid var(--tm-border);
|
||
padding: 8px 12px;
|
||
}
|
||
.tm-mnav-trigger {
|
||
width: 100%;
|
||
display: flex; align-items: center; gap: 10px;
|
||
background: var(--tm-surface-2);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-md, 12px);
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
color: var(--tm-text);
|
||
text-align: start;
|
||
transition: background 0.12s var(--tm-easing), border-color 0.12s var(--tm-easing);
|
||
}
|
||
.tm-mnav-trigger:active { background: var(--tm-surface-3); }
|
||
.tm-mobilenav-open .tm-mnav-trigger { border-color: var(--tm-accent); }
|
||
.tm-mnav-ico {
|
||
width: 30px; height: 30px; flex-shrink: 0;
|
||
border-radius: var(--tm-radius-sm, 8px);
|
||
background: var(--tm-accent-soft);
|
||
color: var(--tm-accent-text);
|
||
display: grid; place-items: center;
|
||
--mdc-icon-size: 20px;
|
||
}
|
||
.tm-mnav-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||
.tm-mnav-crumb { font-size: 11px; color: var(--tm-text-faint); line-height: 1.2; }
|
||
.tm-mnav-title {
|
||
font-size: 15px; font-weight: 600; line-height: 1.25;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.tm-mnav-chev {
|
||
color: var(--tm-text-muted); flex-shrink: 0;
|
||
--mdc-icon-size: 22px;
|
||
transition: transform 0.18s var(--tm-easing);
|
||
}
|
||
.tm-mobilenav-open .tm-mnav-chev { transform: rotate(180deg); }
|
||
.tm-mnav-count {
|
||
flex-shrink: 0;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
color: var(--tm-text-muted);
|
||
font-size: 11px; font-weight: 600;
|
||
padding: 1px 7px; border-radius: 999px;
|
||
}
|
||
.tm-mnav-count-urgent {
|
||
background: var(--tm-warning-soft);
|
||
border-color: transparent;
|
||
color: var(--tm-warning);
|
||
}
|
||
.tm-mnav-scrim {
|
||
position: absolute;
|
||
top: 100%; inset-inline-start: 0; inset-inline-end: 0;
|
||
height: 100vh;
|
||
background: rgba(10, 15, 22, 0.32);
|
||
z-index: 30;
|
||
animation: tm-mnav-fade 0.18s var(--tm-easing);
|
||
}
|
||
.tm-mnav-sheet {
|
||
position: absolute;
|
||
top: calc(100% + 6px); inset-inline-start: 8px; inset-inline-end: 8px;
|
||
z-index: 31;
|
||
background: var(--tm-surface-0);
|
||
border: 1px solid var(--tm-border);
|
||
border-radius: var(--tm-radius-md, 14px);
|
||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.24);
|
||
max-height: calc(100vh - 200px);
|
||
overflow-y: auto;
|
||
padding: 4px 0 8px;
|
||
animation: tm-mnav-drop 0.18s var(--tm-easing);
|
||
}
|
||
.tm-mnav-grp-head {
|
||
font-size: 11px; font-weight: 700; letter-spacing: 0.06em;
|
||
text-transform: uppercase; color: var(--tm-text-faint);
|
||
padding: 12px 16px 4px;
|
||
}
|
||
.tm-mnav-item {
|
||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||
background: none; border: 0; font-family: inherit; cursor: pointer;
|
||
padding: 11px 16px; color: var(--tm-text); text-align: start;
|
||
}
|
||
.tm-mnav-item:active { background: var(--tm-surface-2); }
|
||
.tm-mnav-item-ico {
|
||
flex-shrink: 0; color: var(--tm-text-muted);
|
||
--mdc-icon-size: 20px;
|
||
}
|
||
.tm-mnav-item-label { flex: 1; font-size: 15px; }
|
||
.tm-mnav-item-active { background: var(--tm-accent-soft); color: var(--tm-accent-text); }
|
||
.tm-mnav-item-active .tm-mnav-item-ico { color: var(--tm-accent-text); }
|
||
@keyframes tm-mnav-fade { from { opacity: 0; } to { opacity: 1; } }
|
||
@keyframes tm-mnav-drop {
|
||
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.tm-body { padding: 16px; }
|
||
.tm-toolbar { flex-direction: column; align-items: stretch; }
|
||
.tm-search-wrap { max-width: none; }
|
||
.tm-dialog { max-width: none; border-radius: 0; max-height: 100vh; height: 100vh; }
|
||
.tm-scrim { padding: 0; }
|
||
.tm-dialog-body .tm-field-row { grid-template-columns: 1fr; }
|
||
.tm-setting-row { grid-template-columns: 1fr; gap: 6px; padding: 12px 16px; }
|
||
.tm-section-head, .tm-setting-row { padding-inline-start: 16px; padding-inline-end: 16px; }
|
||
.tm-timeline-row { grid-template-columns: 1fr auto; }
|
||
.tm-timeline-time, .tm-timeline-icon { display: none; }
|
||
}
|
||
/* Wishlists tab (#932) */
|
||
.tm-wl-intro { margin: -4px 0 10px; }
|
||
.tm-wl-thumb {
|
||
flex: none; border-radius: 10px; object-fit: cover; display: inline-grid; place-items: center;
|
||
}
|
||
.tm-wl-thumb-ph {
|
||
background: linear-gradient(135deg, #e91e63, #9b59b6); color: #fff; --mdc-icon-size: 20px;
|
||
}
|
||
.tm-wl-appr { align-items: center; }
|
||
.tm-wl-target { display: inline-flex; align-items: center; gap: 6px; }
|
||
.tm-wl-target .tm-input { width: 96px; padding: 6px 8px; }
|
||
.tm-wl-link { text-decoration: none; --mdc-icon-size: 13px; margin-inline-start: 4px; }
|
||
.tm-wl-cell { display: flex; align-items: center; gap: 10px; }
|
||
.tm-wl-progress { min-width: 180px; }
|
||
.tm-wl-acts { display: flex; align-items: center; justify-content: flex-end; gap: 6px; white-space: nowrap; }
|
||
.tm-wl-acts .tm-btn { --mdc-icon-size: 16px; }
|
||
.tm-wl-gold { color: var(--tm-gold); font-weight: 700; }
|
||
.tm-wl-quick { margin: -6px 0 14px; }
|
||
.tm-wl-split {
|
||
height: 12px; border-radius: 6px; overflow: hidden; display: flex; margin-bottom: 4px;
|
||
background: var(--tm-surface-2); border: 1px solid var(--tm-border);
|
||
}
|
||
.tm-wl-split-lg { height: 16px; margin-top: 4px; }
|
||
.tm-wl-split i { display: block; height: 100%; }
|
||
.tm-wl-me { background: #e91e63; }
|
||
.tm-wl-fam { background: repeating-linear-gradient(135deg, #f1c40f 0 6px, #f39c12 6px 12px); }
|
||
.tm-wl-new { background: var(--tm-text); opacity: .35; }
|
||
@media (max-width: 700px) {
|
||
.tm-wl-appr { flex-wrap: wrap; }
|
||
.tm-wl-progress { min-width: 120px; }
|
||
}
|
||
|
||
/* ===== Admin panel layout (#966) ===== */
|
||
.tm-sr {
|
||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||
}
|
||
button.tm-nav-head {
|
||
display: flex; align-items: center; gap: 4px;
|
||
width: 100%; background: none; border: 0; cursor: pointer;
|
||
font-family: inherit; text-align: start;
|
||
border-radius: 6px;
|
||
}
|
||
button.tm-nav-head:hover { color: var(--tm-text); }
|
||
.tm-nav-chev { --mdc-icon-size: 14px; transition: transform 0.15s var(--tm-easing); }
|
||
.tm-nav-head[aria-expanded="false"] .tm-nav-chev { transform: rotate(-90deg); }
|
||
.tm-nav-group:first-child { margin-bottom: 10px; }
|
||
.tm-rail-btn {
|
||
flex-shrink: 0; width: 28px; height: 28px; border-radius: 7px;
|
||
display: grid; place-items: center; cursor: pointer;
|
||
background: none; border: 0; color: var(--tm-text-faint);
|
||
}
|
||
.tm-rail-btn:hover { background: var(--tm-surface-2); color: var(--tm-text); }
|
||
.tm-rail-btn ha-icon { --mdc-icon-size: 18px; }
|
||
|
||
/* Icon-only sidebar */
|
||
.tm-shell.tm-shell-rail { grid-template-columns: 64px 1fr; }
|
||
.tm-shell-rail .tm-brand { flex-direction: column; padding: 12px 0; gap: 8px; }
|
||
.tm-shell-rail .tm-brand-text, .tm-shell-rail .tm-brand-wiki,
|
||
.tm-shell-rail .tm-nav-label, .tm-shell-rail .tm-nav-chev,
|
||
.tm-shell-rail .tm-nav-head span { display: none; }
|
||
.tm-shell-rail .tm-nav-head {
|
||
height: 1px; padding: 0; margin: 10px 8px; background: var(--tm-border);
|
||
pointer-events: none;
|
||
}
|
||
.tm-shell-rail .tm-nav-item { justify-content: center; padding: 9px 0; }
|
||
.tm-shell-rail .tm-nav-icon, .tm-shell-rail .tm-nav-icon ha-icon { --mdc-icon-size: 20px; width: 20px; height: 20px; }
|
||
.tm-shell-rail .tm-nav-badge { display: none; }
|
||
.tm-shell-rail .tm-nav-badge-urgent {
|
||
display: block; position: absolute; top: 2px; inset-inline-end: 6px;
|
||
padding: 0 5px; font-size: 10px; line-height: 16px;
|
||
}
|
||
|
||
/* Topbar controls */
|
||
.tm-topbar .tm-crumbs { flex: 0 1 auto; min-width: 0; white-space: nowrap; }
|
||
.tm-search-btn {
|
||
flex: 1 1 auto; max-width: 420px; margin: 0 auto;
|
||
display: flex; align-items: center; gap: 8px;
|
||
height: 36px; padding: 0 10px;
|
||
border-radius: 10px; border: 1px solid var(--tm-border);
|
||
background: var(--tm-bg); color: var(--tm-text-faint);
|
||
font-family: inherit; font-size: 13px; cursor: text; text-align: start;
|
||
}
|
||
.tm-search-btn:hover { border-color: var(--tm-accent-border); }
|
||
.tm-search-btn ha-icon { --mdc-icon-size: 18px; flex-shrink: 0; }
|
||
.tm-search-btn-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tm-topbar kbd, .tm-pal kbd {
|
||
font-family: inherit; font-size: 11px; line-height: 1.6;
|
||
padding: 0 6px; border-radius: 5px;
|
||
border: 1px solid var(--tm-border); background: var(--tm-surface-2); color: var(--tm-text-muted);
|
||
}
|
||
.tm-scope {
|
||
display: flex; align-items: center; gap: 2px; flex-shrink: 0;
|
||
padding: 3px; border-radius: 999px;
|
||
background: var(--tm-bg); border: 1px solid var(--tm-border);
|
||
}
|
||
.tm-scope-btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
min-height: 30px; padding-block: 0; padding-inline: 3px 10px; border-radius: 999px;
|
||
background: none; border: 0; cursor: pointer;
|
||
font-family: inherit; font-size: 12.5px; color: var(--tm-text-muted);
|
||
}
|
||
.tm-scope-all { padding: 0 12px; }
|
||
.tm-scope-btn:hover { color: var(--tm-text); background: var(--tm-surface-2); }
|
||
.tm-scope-on { background: var(--tm-accent-soft); color: var(--tm-accent-text); font-weight: 600; }
|
||
.tm-av {
|
||
width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
|
||
display: grid; place-items: center;
|
||
background: color-mix(in srgb, var(--kc), transparent 80%);
|
||
color: var(--kc); --mdc-icon-size: 16px;
|
||
}
|
||
.tm-av-sm { width: 22px; height: 22px; --mdc-icon-size: 14px; }
|
||
.tm-scope-on .tm-av { box-shadow: 0 0 0 2px var(--kc); }
|
||
@media (max-width: 1280px) {
|
||
.tm-scope-name { display: none; }
|
||
.tm-scope-btn { padding: 0 3px; }
|
||
.tm-scope-all { padding: 0 10px; }
|
||
}
|
||
.tm-new-wrap { position: relative; flex-shrink: 0; }
|
||
.tm-new-btn { display: inline-flex; align-items: center; gap: 4px; }
|
||
.tm-new-btn ha-icon { --mdc-icon-size: 18px; }
|
||
.tm-new-menu {
|
||
position: absolute; inset-inline-end: 0; top: calc(100% + 6px); z-index: 40;
|
||
min-width: 210px; padding: 6px;
|
||
background: var(--tm-surface-0); border: 1px solid var(--tm-border);
|
||
border-radius: 12px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22);
|
||
}
|
||
.tm-new-item {
|
||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||
padding: 9px 10px; border: 0; border-radius: 8px; background: none;
|
||
font-family: inherit; font-size: 13.5px; color: var(--tm-text);
|
||
cursor: pointer; text-align: start;
|
||
}
|
||
.tm-new-item:hover, .tm-new-item:focus-visible { background: var(--tm-surface-2); }
|
||
.tm-new-item ha-icon { --mdc-icon-size: 18px; color: var(--tm-text-muted); }
|
||
.tm-scope-banner {
|
||
display: flex; align-items: center; gap: 10px; flex-shrink: 0;
|
||
padding: 8px 24px;
|
||
background: var(--tm-accent-soft); border-bottom: 1px solid var(--tm-accent-border);
|
||
font-size: 13px; color: var(--tm-text);
|
||
}
|
||
.tm-scope-banner ha-icon { --mdc-icon-size: 18px; color: var(--tm-accent-text); }
|
||
.tm-scope-banner span { flex: 1; }
|
||
|
||
/* Today page */
|
||
.tm-today-head { margin-bottom: 18px; }
|
||
.tm-today-head .tm-toolbar-title { margin: 0; }
|
||
.tm-today-needs { color: var(--tm-warning); font-weight: 600; }
|
||
.tm-section-title > ha-icon { --mdc-icon-size: 18px; color: var(--tm-text-muted); }
|
||
.tm-today-kids {
|
||
display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||
gap: 14px; margin-bottom: 18px;
|
||
}
|
||
.tm-kid {
|
||
display: grid; grid-template-columns: 1fr auto; gap: 8px 6px;
|
||
padding: 14px; margin: 0;
|
||
border-top: 4px solid var(--kc);
|
||
}
|
||
.tm-kid-main {
|
||
display: flex; align-items: center; gap: 14px; min-width: 0;
|
||
background: none; border: 0; padding: 0; cursor: pointer;
|
||
font-family: inherit; color: inherit; text-align: start;
|
||
}
|
||
.tm-ring {
|
||
--p: 0; width: 54px; height: 54px; border-radius: 50%; flex-shrink: 0;
|
||
background: conic-gradient(var(--kc) calc(var(--p) * 1%), var(--tm-surface-3) 0);
|
||
display: grid; place-items: center;
|
||
}
|
||
.tm-ring > span {
|
||
width: 42px; height: 42px; border-radius: 50%;
|
||
background: var(--tm-surface-0);
|
||
display: grid; place-items: center;
|
||
font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
|
||
}
|
||
.tm-kid-text { display: flex; flex-direction: column; min-width: 0; }
|
||
.tm-kid-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tm-kid-pts { font-size: 13px; color: var(--tm-text-muted); }
|
||
.tm-kid-pts strong { font-size: 18px; color: var(--tm-gold); margin-inline-end: 2px; }
|
||
.tm-kid-adj { align-self: start; }
|
||
.tm-kid-pills { grid-column: 1 / -1; display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.tm-kid-pills ha-icon { --mdc-icon-size: 13px; margin-inline-end: 2px; }
|
||
.tm-today-cols {
|
||
display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||
gap: 18px; align-items: start;
|
||
}
|
||
.tm-today-col { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||
.tm-today-col > .tm-card { margin: 0; }
|
||
.tm-today-clear { display: flex; align-items: center; gap: 8px; }
|
||
/* A long queue scrolls inside its card so the chore board stays in reach. */
|
||
.tm-today-col .tm-approval-list { max-height: 440px; overflow-y: auto; overscroll-behavior: contain; }
|
||
.tm-today-clear ha-icon { color: var(--tm-positive); --mdc-icon-size: 18px; }
|
||
.tm-today-quick { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.tm-today-quick .tm-btn { justify-content: flex-start; gap: 8px; }
|
||
.tm-today-quick ha-icon { --mdc-icon-size: 18px; }
|
||
.tm-seg {
|
||
margin-inline-start: auto; display: inline-flex; padding: 2px; gap: 2px;
|
||
border-radius: 8px; background: var(--tm-surface-2);
|
||
}
|
||
.tm-seg button {
|
||
border: 0; background: none; cursor: pointer; font-family: inherit;
|
||
font-size: 12.5px; font-weight: 500; color: var(--tm-text-muted);
|
||
padding: 4px 10px; border-radius: 6px;
|
||
}
|
||
.tm-seg .tm-seg-on { background: var(--tm-surface-0); color: var(--tm-text); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); }
|
||
|
||
/* Chore board */
|
||
.tm-board-wrap { overflow-x: auto; margin: 0 -4px; padding: 0 4px; }
|
||
.tm-board {
|
||
display: grid; grid-template-columns: 120px repeat(var(--cols), minmax(140px, 1fr));
|
||
font-size: 13px;
|
||
}
|
||
.tm-board > div { padding: 8px 10px; border-bottom: 1px solid var(--tm-border-soft); min-width: 0; }
|
||
.tm-board-h { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 12.5px; }
|
||
.tm-board-h span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tm-board-slot { display: flex; align-items: center; gap: 6px; color: var(--tm-text-muted); font-size: 12.5px; }
|
||
.tm-board-slot ha-icon { --mdc-icon-size: 16px; flex-shrink: 0; }
|
||
.tm-board-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
|
||
.tm-board-none { color: var(--tm-text-vfaint); }
|
||
.tm-bchip {
|
||
display: inline-flex; align-items: center; gap: 7px; max-width: 100%;
|
||
background: none; border: 0; padding: 3px 4px; margin-inline-start: -4px;
|
||
border-radius: 6px; cursor: pointer;
|
||
font-family: inherit; font-size: 12.5px; color: var(--tm-text); text-align: start;
|
||
}
|
||
button.tm-bchip:hover { background: var(--tm-surface-2); }
|
||
.tm-bchip > span:not(.tm-sr) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tm-bchip small { color: var(--tm-text-faint); }
|
||
.tm-bdot {
|
||
width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0;
|
||
border: 2px solid var(--tm-text-faint); box-sizing: border-box;
|
||
}
|
||
.tm-bchip-done .tm-bdot { background: var(--tm-positive); border-color: var(--tm-positive); }
|
||
.tm-bchip-done > span:not(.tm-sr) { color: var(--tm-text-faint); text-decoration: line-through; }
|
||
.tm-bchip-pending .tm-bdot { border-color: var(--tm-warning); background: var(--tm-warning-soft); }
|
||
.tm-bchip-missed .tm-bdot { border-color: var(--tm-danger); }
|
||
.tm-bchip-missed > span:not(.tm-sr) { color: var(--tm-danger); }
|
||
.tm-board-legend {
|
||
display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px;
|
||
font-size: 12px; color: var(--tm-text-muted);
|
||
}
|
||
.tm-board-legend .tm-bchip { cursor: default; font-size: 12px; color: var(--tm-text-muted); text-decoration: none; }
|
||
.tm-board-legend .tm-bchip-done { text-decoration: none; }
|
||
|
||
/* Week view */
|
||
.tm-week {
|
||
display: grid; grid-template-columns: 110px repeat(var(--days), minmax(38px, 1fr));
|
||
gap: 6px; align-items: center; font-size: 12px;
|
||
}
|
||
.tm-week-h { text-align: center; color: var(--tm-text-muted); font-weight: 500; }
|
||
.tm-week-kid { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 13px; }
|
||
.tm-week-kid span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tm-week-cell {
|
||
height: 32px; border-radius: 7px;
|
||
display: grid; place-items: center;
|
||
font-weight: 600; font-variant-numeric: tabular-nums;
|
||
background: color-mix(in srgb, var(--kc) calc(15% + var(--p) * 0.7%), var(--tm-surface-2));
|
||
color: var(--tm-text);
|
||
}
|
||
.tm-week-none { background: var(--tm-surface-2); color: var(--tm-text-vfaint); font-weight: 400; }
|
||
.tm-week-hint { margin: 10px 0 0; }
|
||
|
||
/* Command search */
|
||
.tm-pal-scrim {
|
||
position: fixed; inset: 0; z-index: 60;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
display: flex; align-items: flex-start; justify-content: center;
|
||
padding: 10vh 12px 12px;
|
||
}
|
||
.tm-pal {
|
||
width: 600px; max-width: 100%; max-height: 72vh;
|
||
display: flex; flex-direction: column;
|
||
background: var(--tm-surface-0); color: var(--tm-text);
|
||
border: 1px solid var(--tm-border); border-radius: 14px;
|
||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4); overflow: hidden;
|
||
}
|
||
.tm-pal-head {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 12px 16px; border-bottom: 1px solid var(--tm-border);
|
||
}
|
||
.tm-pal-head ha-icon { --mdc-icon-size: 20px; color: var(--tm-text-muted); }
|
||
.tm-pal-input {
|
||
flex: 1; min-width: 0; border: 0; outline: 0; background: none;
|
||
font-family: inherit; font-size: 16px; color: var(--tm-text);
|
||
}
|
||
.tm-pal-list { overflow-y: auto; padding: 4px 0 8px; }
|
||
.tm-pal-group {
|
||
padding: 10px 16px 4px; font-size: 11px; font-weight: 600;
|
||
letter-spacing: 0.06em; text-transform: uppercase; color: var(--tm-text-faint);
|
||
}
|
||
.tm-pal-item {
|
||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||
padding: 8px 16px; border: 0; background: none; cursor: pointer;
|
||
font-family: inherit; font-size: 14px; color: var(--tm-text); text-align: start;
|
||
}
|
||
.tm-pal-item ha-icon { --mdc-icon-size: 18px; color: var(--tm-text-muted); flex-shrink: 0; }
|
||
.tm-pal-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tm-pal-item:hover, .tm-pal-hi { background: var(--tm-accent-soft); }
|
||
.tm-pal-hi ha-icon { color: var(--tm-accent-text); }
|
||
.tm-pal-empty { padding: 20px 16px; color: var(--tm-text-muted); }
|
||
.tm-pal-foot {
|
||
padding: 8px 16px; border-top: 1px solid var(--tm-border);
|
||
font-size: 12px; color: var(--tm-text-faint);
|
||
}
|
||
|
||
/* Phone bottom bar (shown in the narrow block below) */
|
||
.tm-bnav { display: none; }
|
||
|
||
@media (max-width: 1100px) {
|
||
.tm-today-cols { grid-template-columns: 1fr; }
|
||
}
|
||
@media (max-width: 900px) {
|
||
.tm-shell.tm-shell-rail { grid-template-columns: 1fr; }
|
||
.tm-search-btn { flex: 0 0 auto; width: 38px; height: 38px; margin-block: 0; margin-inline: auto 0; padding: 0; justify-content: center; border: 0; background: none; color: var(--tm-text-muted); }
|
||
.tm-search-btn-text, .tm-search-btn kbd, .tm-new-text { display: none; }
|
||
.tm-approval-pill { display: none; }
|
||
.tm-new-btn { width: 38px; height: 38px; padding: 0; justify-content: center; border-radius: 10px; }
|
||
.tm-new-btn ha-icon { --mdc-icon-size: 22px; }
|
||
.tm-today-col .tm-approval-list { max-height: 60vh; }
|
||
/* Clear the bottom bar. */
|
||
.tm-toast { bottom: calc(72px + env(safe-area-inset-bottom, 0px)); }
|
||
.tm-scope-banner { padding: 8px 12px; }
|
||
.tm-today-kids {
|
||
grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: minmax(240px, 80%);
|
||
overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 16px;
|
||
margin: 0 -16px 16px; padding: 0 16px 4px;
|
||
}
|
||
.tm-kid { scroll-snap-align: start; }
|
||
.tm-board { grid-template-columns: 92px repeat(var(--cols), minmax(120px, 1fr)); }
|
||
.tm-bnav {
|
||
display: grid; grid-template-columns: repeat(5, 1fr); flex-shrink: 0;
|
||
background: var(--tm-surface-0); border-top: 1px solid var(--tm-border);
|
||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||
}
|
||
.tm-bnav-item {
|
||
position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
gap: 2px; min-height: 56px; border: 0; background: none; cursor: pointer;
|
||
font-family: inherit; font-size: 11px; color: var(--tm-text-muted);
|
||
}
|
||
.tm-bnav-item ha-icon { --mdc-icon-size: 22px; }
|
||
.tm-bnav-on { color: var(--tm-accent-text); }
|
||
.tm-bnav-dot {
|
||
position: absolute; top: 6px; inset-inline-start: calc(50% + 6px);
|
||
min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px;
|
||
background: var(--tm-warning); color: #fff;
|
||
font-size: 10px; font-weight: 700; line-height: 16px; text-align: center;
|
||
}
|
||
}
|
||
@media (max-width: 480px) {
|
||
.tm-scope { max-width: 46vw; overflow-x: auto; }
|
||
.tm-today-quick { grid-template-columns: 1fr; }
|
||
}
|
||
</style>`;
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|