/** Maintenance Supporter Sidebar Panel. */ import { LitElement, html, nothing, type TemplateResult } from "lit"; import { isSafeHttpUrl } from "./helpers/url"; import { queryTokens, scoreFields } from "./helpers/search-match"; import { objectRef, parseRef, renderRefChip, taskRef, type HasRef } from "./helpers/reference"; import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge"; import { isStaleBundle } from "./helpers/bundle-version"; import { customElement, property, state } from "lit/decorators.js"; import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, currencySymbolOf, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatCost, syncCurrencyDecimals} from "./styles"; import { OVERVIEW_TABS, type OverviewTab } from "./helpers/overview-tabs"; import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys"; import { openHtmlInNewTab, openSignedDocument, signApiPath } from "./helpers/document-url"; import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache"; import { hydrateObjects } from "./helpers/hydrate-objects"; import { daysProgress } from "./helpers/interval"; import { buildObjectReportHtml, type ReportLabels } from "./helpers/report"; import { warrantyStatus } from "./helpers/warranty"; import { OBJECT_COLUMNS, DEFAULT_OBJECTS_TABLE_COLUMNS, sanitizeColumns } from "./helpers/object-columns"; import { downloadTextFile } from "./helpers/download"; import { renderNotesMarkdown } from "./helpers/notes-markdown"; import { buildTaskWorksheetHtml, type WorksheetExcerpt, type WorksheetLabels } from "./helpers/worksheet"; import { describePartLink } from "./helpers/shared-parts"; import { effectivePhase } from "./helpers/phases"; import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args"; import { describeWsError } from "./ws-errors"; import { panelStyles } from "./panel-styles"; // Global search (#171): what the panel needs to know about a hit — enough // to render one row and to act on it (open the page / the document). interface SearchHit { kind: "object" | "task" | "part" | "document" | "content" | "history"; entryId: string; taskId?: string; docId?: string; docKind?: string | null; url?: string | null; page?: number | null; /** #170: the hit's reference ("8.3", "8.3-2") when numbered. */ ref?: string | null; label: string; sub: string; snippet?: string; score: number; icon: string; } interface RemoteDocHit { id: string; entry_id: string; object_name: string; kind: string; title?: string | null; filename?: string | null; url?: string | null; tags?: string[]; match: "meta" | "content"; page?: number | null; snippet?: string; score: number; } interface RemoteHistoryHit { entry_id: string; task_id: string; task_name: string; object_name: string; timestamp?: string | null; type?: string | null; snippet: string; score: number; ref?: string | null; } const SEARCH_MIN_CHARS = 2; const SEARCH_DEBOUNCE_MS = 250; const SEARCH_GROUP_CAP = { objects: 6, tasks: 10, parts: 6, documents: 8, history: 6 } as const; const MDI_MAGNIFY = "M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"; import type { HomeAssistant, MaintenanceObjectResponse, MaintenanceTask, MaintenanceGroup, StatisticsResponse, BudgetStatus, AdvancedFeatures, TaskRow, HistoryEntry, TriggerConfig, StatisticsPoint, SavedView, SavedViewFilters, ManualDocRef, } from "./types"; import { StatisticsService } from "./statistics-service"; import { UserService } from "./user-service"; // The heavy dialogs + the settings view are NOT imported here: they are // esbuild code-split chunks loaded by _ensureLazyUi() right after mount // (roadmap perf wave 2, item 5). Only their types are imported — esbuild // tree-shakes type-only imports, so they cost the entry bundle nothing. import type { MaintenanceObjectDialog } from "./components/object-dialog"; import "./components/documents-section"; import "./components/parts-section"; import "./components/object-history-section"; import "./components/task-documents"; import type { MaintenanceTaskDialog } from "./components/task-dialog"; import type { MaintenanceCompleteDialog } from "./components/complete-dialog"; import type { MaintenanceQrDialog } from "./components/qr-dialog"; import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-dialog"; import "./components/battery-fleet-section"; import type { MaintenanceSuggestedSetupsDialog } from "./components/suggested-setups-dialog"; // v2.0.0: panel uses the extracted Calendar Card instead of its own // _renderCalendar() method — single source of truth for the calendar view. import "./maintenance-calendar-card"; // v2.2.0: in-place edit dialog for past history entries import "./components/history-edit-dialog"; import type { MaintenanceHistoryEditDialog, HistoryEntryDraft, } from "./components/history-edit-dialog"; import "./components/confirm-dialog"; import type { MaintenanceConfirmDialog } from "./components/confirm-dialog"; import "./components/storage-section-card"; import "./components/seasonal-overrides-dialog"; import type { SeasonalOverridesDialog } from "./components/seasonal-overrides-dialog"; import "./components/group-dialog"; import type { MaintenanceGroupDialog } from "./components/group-dialog"; import "./components/saved-views-dialog"; import type { MaintenanceSavedViewsDialog } from "./components/saved-views-dialog"; import { type SparklineContext } from "./renderers/sparkline"; import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket"; import { renderTriggerProgress, renderMiniSparkline, computeTrend } from "./renderers/progress"; import { type HistoryContext } from "./renderers/history"; import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail"; import { renderPersonChip, type PersonDisplay } from "./helpers/person"; // The task-detail sub-view as a web component (light-DOM, panel styles apply). // Side-effect import: type-only imports get tree-shaken and the element // would never register. import "./components/task-detail-view"; import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window"; import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type StickyState } from "./helpers/sticky-pane"; import { invalidateSettingsCache } from "./helpers/settings-cache"; import { buildHistoryEntryDraft } from "./helpers/history-draft"; import { readingSlotDelta } from "./helpers/reading-slots"; import { runWs } from "./helpers/ws-run"; type View = "overview" | "object" | "task" | "all_objects" | "all_parts"; /** One row of the instance-wide parts overview (#130) — the WS response of * `parts/overview`: the stored part fields plus owner, live stock and the * consuming tasks (own + pooled #111 links). */ interface PartsOverviewRow { part_id: string; entry_id: string; object_name: string | null; name: string; unit?: string | null; cost?: number | null; storage_location?: string | null; vendor?: string | null; reorder_threshold?: number | null; stock: number | null; low: boolean; consumers: Array<{ entry_id: string; object_name: string | null; task_id: string; task_name: string | null; quantity: number; pooled: boolean; }>; } type SortMode = "due_date" | "object" | "type" | "task_name" | "area" | "assigned_user" | "group"; type ObjectSortMode = "alphabetical" | "due_soonest" | "task_count"; type GroupByMode = "none" | "area" | "group" | "user" | "object"; // OverviewTab + OVERVIEW_TABS live in helpers/overview-tabs.ts — shared with the panel card's tab preset (#174). // Value lists for everything that arrives as a free string — localStorage, // a saved view, a deep-link query — and must be validated before it lands in // state (an unknown mode would be persisted and silently break sorting). const SORT_MODES: readonly SortMode[] = ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"]; const GROUP_BY_MODES: readonly GroupByMode[] = ["none", "area", "group", "user", "object"]; // (OVERVIEW_TABS: imported from helpers/overview-tabs.ts.) /** The dashboard list's status filter options (the this._onPaletteInput((e.target as HTMLInputElement).value)} />
${q.length < SEARCH_MIN_CHARS && !parseRef(q) ? html`
${t("search_empty_hint", L)}
` : results.length === 0 ? html`
${waiting ? t("search_searching", L) : t("palette_no_results", L)}
` : results.map((r, i) => { const header = r.kind !== lastKind ? html`
${groupTitle[r.kind]}
` : nothing; lastKind = r.kind; return html` ${header}
{ this._paletteActive = i; }} @click=${() => this._selectPaletteResult(r)}>
${r.label} ${r.ref ? html`#${r.ref}` : nothing} ${r.page ? html`${t("search_page", L).replace("{page}", String(r.page))}` : nothing} ${r.sub}
${r.snippet ? html`
${r.snippet}
` : nothing}
`; })} ${results.length > 0 && waiting ? html`
${t("search_searching", L)}
` : nothing}
${t("palette_hint", L)}
`; } // --- Adopt problem sensors --- private _openAdoptProblemSensors(): void { void this._ui("maintenance-adopt-problem-sensors-dialog") .then((d) => d?.open()); } private async _onProblemSensorsAdopted(e: CustomEvent): Promise { const tasks = e.detail?.tasks_created ?? 0; const created = (e.detail?.created ?? []) as Array<{ entry_id: string; task_id: string; name: string }>; await this._loadData(); const msg = t("adopt_problem_done", this._lang).replace("{tasks}", String(tasks)); if (created.length > 0) { // Adopted tasks are fully configurable from day one (responsible user, // priority, documents) — surface that with a direct path to the first. this._showActionToast(msg, t("adopt_problem_configure", this._lang), () => { const ref = created[0]; const obj = this._objects.find((o) => o.entry_id === ref.entry_id); const tk = obj?.tasks.find((task) => task.id === ref.task_id); if (obj && tk) { void this._ui("maintenance-task-dialog") .then((d) => d?.openEdit(ref.entry_id, tk)); } }); } else { this._showToast(msg); } } // --- Suggested setups (integration signatures, v2.28) --- private async _setupBatteryFleet(): Promise { try { const res = await this.hass.connection.sendMessagePromise<{ entry_id: string; task_id?: string }>({ type: "maintenance_supporter/battery_fleet/setup", language: this.hass.language || "en", }); this._batteryFleetSetupAvailable = false; await this._loadData(); // Jump to the fleet task so the user lands on its battery detail section. const obj = this._objects.find((o) => o.entry_id === res.entry_id); const tk = obj?.tasks.find((t2) => t2.id === res.task_id) || obj?.tasks[0]; if (obj && tk) { this._showTask(obj.entry_id, tk.id); } this._showToast(t("battery_fleet_setup_done", this._lang)); } catch (e) { this._showToast(describeWsError(e, this._lang)); } } private _openSuggestedSetups(): void { void this._ui("maintenance-suggested-setups-dialog") .then((d) => d?.open()); } private _onSetupsAdopted(e: CustomEvent): void { const tasks = e.detail?.tasks_created ?? 0; this._showToast(t("setups_done", this._lang).replace("{tasks}", String(tasks))); this._loadData(); } // --- Template gallery --- private async _openTemplateGallery(): Promise { this._templateGalleryOpen = true; if (this._templates.length > 0) return; try { const res = await this.hass.connection.sendMessagePromise<{ categories: Record; templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean }>; }>({ type: "maintenance_supporter/templates", language: this._lang }); this._templateCategories = res.categories || {}; // v2.21: admin-hidden templates stay out of the gallery. this._templates = (res.templates || []).filter((tpl) => !tpl.disabled); } catch { this._showToast(t("action_error", this._lang)); } } private async _createFromTemplate(templateId: string): Promise { this._templateBusy = true; try { const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({ type: "maintenance_supporter/object/from_template", language: this._lang, template_id: templateId, }); this._templateGalleryOpen = false; await this._loadData(); this._showToast(t("template_created", this._lang)); if (res?.entry_id) this._showObject(res.entry_id); } catch { this._showToast(t("action_error", this._lang)); } finally { this._templateBusy = false; } } private _categoryName(catId: string): string { const cat = this._templateCategories[catId] as Record | undefined; if (!cat) return catId; return cat[`name_${this._lang}`] || cat["name_en"] || catId; } private _renderTemplateGallery() { if (!this._templateGalleryOpen) return nothing; const L = this._lang; // Group templates by category, preserving category declaration order. const byCat = new Map(); for (const tpl of this._templates) { if (!byCat.has(tpl.category)) byCat.set(tpl.category, []); byCat.get(tpl.category)!.push(tpl); } return html`
{ this._templateGalleryOpen = false; }}>
`; } // --- Bulk selection (dashboard task list) --- private _bulkKey(row: TaskRow): string { return `${row.entry_id}:${row.task_id}`; } private _toggleBulkMode(): void { this._bulkMode = !this._bulkMode; if (!this._bulkMode) this._bulkSelected = new Set(); } private _toggleBulkRow(row: TaskRow): void { const key = this._bulkKey(row); const next = new Set(this._bulkSelected); if (next.has(key)) next.delete(key); else next.add(key); this._bulkSelected = next; } private _bulkSelectAll(rows: TaskRow[]): void { const keys = rows.map((r) => this._bulkKey(r)); const allSelected = keys.every((k) => this._bulkSelected.has(k)); this._bulkSelected = allSelected ? new Set() : new Set(keys); } /** Run one WS message per selected row (the endpoints are per-task); reports * how many succeeded and refreshes once at the end. */ private async _runBulk( rows: TaskRow[], build: (row: TaskRow) => Record, doneMsg: (n: number) => string, undo?: () => void, ): Promise { const selected = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r))); if (selected.length === 0) return; this._actionLoading = true; let ok = 0; for (const row of selected) { try { await this.hass.connection.sendMessagePromise(build(row)); ok++; } catch { /* keep going; report the successful count */ } } this._actionLoading = false; this._bulkSelected = new Set(); this._bulkMode = false; await this._loadData(); if (undo && ok > 0) this._showUndoToast(doneMsg(ok), undo); else this._showToast(doneMsg(ok)); } private _bulkComplete(rows: TaskRow[]): void { void this._runBulk( rows, (row) => ({ type: "maintenance_supporter/task/complete", entry_id: row.entry_id, task_id: row.task_id }), (n) => t("bulk_completed", this._lang).replace("{n}", String(n)), ); } private _bulkArchive(rows: TaskRow[]): void { // Capture the selection so the undo can unarchive exactly those. const keys = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r))) .map((r) => ({ entry_id: r.entry_id, task_id: r.task_id })); void this._runBulk( rows, (row) => ({ type: "maintenance_supporter/task/archive", entry_id: row.entry_id, task_id: row.task_id }), (n) => t("bulk_archived", this._lang).replace("{n}", String(n)), async () => { for (const k of keys) { try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/unarchive", entry_id: k.entry_id, task_id: k.task_id, }); } catch { /* best effort */ } } await this._loadData(); }, ); } // --- Actions --- /** Run a mutating WS action: loading state, data reload, and — on failure — * the SERVER's error message as the toast. The ~16 hand-written action * methods had drifted (most swallowed the server message behind a generic * "Action failed", three never set _actionLoading, one skipped the reload). * Returns the result payload, or null when the call failed (toast shown). */ private async _runAction>( msg: Record, opts?: { successToast?: string }, ): Promise { this._actionLoading = true; try { const res = await this.hass.connection.sendMessagePromise(msg); await this._loadData(); if (opts?.successToast) this._showToast(opts.successToast); return (res ?? {}) as T; } catch (e) { this._showToast(describeWsError(e, this._lang)); return null; } finally { this._actionLoading = false; } } private async _deleteObject(entryId: string): Promise { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); const ok = await dlg?.confirm({ title: t("delete", this._lang), message: t("confirm_delete_object", this._lang), confirmText: t("delete", this._lang), danger: true, }); if (!ok) return; const res = await this._runAction({ type: "maintenance_supporter/object/delete", entry_id: entryId, }); if (res) this._showOverview(); } /** Open a printable maintenance report for the object in a new tab (the user * prints / saves as PDF from there). Self-contained HTML, no dependency. */ private _printObjectReport(entryId: string): void { const resp = this._getObject(entryId); if (!resp) return; const L = this._lang; const labels: ReportLabels = { title: t("report_title", L), generated: t("report_generated", L), manufacturer: t("manufacturer", L), model: t("model", L), serial: t("serial_number_label", L), installed: t("installed", L), warranty: t("warranty", L), area: t("area", L), notes: t("report_notes", L), tasksHeading: t("tasks", L), colTask: t("task_name", L), colType: t("report_col_type", L), colStatus: t("report_col_status", L), colSchedule: t("report_col_schedule", L), colLastDone: t("last_performed", L), colNextDue: t("next_due", L), colCost: t("cost", L), colTimes: t("report_times_done", L), totalCost: t("report_total_cost", L), scheduleLabel: (task) => formatRecurrence(task, L), none: "—", statusLabel: (s: string) => t(s, L), typeLabel: (ty: string) => t(ty, L), }; const html = buildObjectReportHtml( resp.object, resp.tasks, labels, (iso) => (iso ? formatDate(iso, L) : ""), (amount) => formatCost(amount, this._currencySymbol, L), new Date().toISOString(), ); openHtmlInNewTab(html); } private async _duplicateObject(entryId: string): Promise { const res = await this._runAction<{ entry_id?: string }>( { type: "maintenance_supporter/object/duplicate", entry_id: entryId }, { successToast: t("object_duplicated", this._lang) }, ); if (res?.entry_id) this._showObject(res.entry_id); } private async _deleteTask(entryId: string, taskId: string): Promise { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); const ok = await dlg?.confirm({ title: t("delete", this._lang), message: t("confirm_delete_task", this._lang), confirmText: t("delete", this._lang), danger: true, }); if (!ok) return; const res = await this._runAction({ type: "maintenance_supporter/task/delete", entry_id: entryId, task_id: taskId, }); if (res) this._showObject(entryId); } // v2.10.0: archive / unarchive a single task (reversible — no confirm). private async _duplicateTask(entryId: string, taskId: string): Promise { this._moreMenuOpen = false; const res = await this._runAction<{ task_id?: string }>( { type: "maintenance_supporter/task/duplicate", entry_id: entryId, task_id: taskId }, { successToast: t("task_duplicated", this._lang) }, ); // Jump straight to the copy so the user can rename/adjust it. if (res?.task_id) this._showTask(entryId, res.task_id); } // Forum #23: a task created on the wrong object — move it instead of // re-creating it. Config, history, readings and trigger state travel with // the task; it gets a new reference number and entities under the target. private async _moveTask(entryId: string, taskId: string): Promise { this._moreMenuOpen = false; const others = this._objects .filter((o) => o.entry_id !== entryId && !o.object.archived_at) .sort((a, b) => (a.object.name || "").localeCompare(b.object.name || "")); if (!others.length) return; const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); const result = await dlg?.prompt({ title: t("move_task_title", this._lang), message: t("move_task_message", this._lang), confirmText: t("move_task_title", this._lang), inputLabel: t("move_task_target", this._lang), inputValue: others[0].entry_id, options: others.map((o) => ({ value: o.entry_id, label: o.object.name || o.entry_id })), }); if (!result?.confirmed || !result.value) return; const res = await this._runAction<{ task_id?: string; entry_id?: string }>( { type: "maintenance_supporter/task/move", entry_id: entryId, task_id: taskId, target_entry_id: result.value }, { successToast: t("task_moved", this._lang) }, ); if (res?.entry_id && res.task_id) this._showTask(res.entry_id, res.task_id); } private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise { const res = await this._runAction({ type: archived ? "maintenance_supporter/task/unarchive" : "maintenance_supporter/task/archive", entry_id: entryId, task_id: taskId, }); // Just archived → offer a one-tap undo (unarchive) instead of a confirm. if (res && !archived) { this._showUndoToast(t("task_archived", this._lang), () => this._toggleArchiveTask(entryId, taskId, true)); } } // v2.10.0: archive / unarchive an object. Archiving cascades to its tasks but // is fully reversible, so instead of a blocking confirm we run it immediately // and offer an Undo toast (v2.14.0). private async _toggleArchiveObject(entryId: string, archived: boolean): Promise { const res = await this._runAction({ type: archived ? "maintenance_supporter/object/unarchive" : "maintenance_supporter/object/archive", entry_id: entryId, }); if (res && !archived) { this._showUndoToast(t("object_archived", this._lang), () => this._toggleArchiveObject(entryId, true)); } } // v2.20 (N3): seasonal pause / resume. Pausing asks for an optional // auto-resume date; resuming re-anchors recurring tasks to a fresh cycle. private async _togglePauseObject(entryId: string, paused: boolean): Promise { if (!paused) { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); const result = await dlg?.prompt({ title: t("pause_object", this._lang), message: t("pause_until_prompt", this._lang), confirmText: t("pause_object", this._lang), inputLabel: t("pause_until_label", this._lang), inputType: "date", }); if (!result?.confirmed) return; const msg: Record = { type: "maintenance_supporter/object/pause", entry_id: entryId, }; if (result.value) msg.until = result.value; if (await this._runAction(msg)) { this._showUndoToast(t("object_paused", this._lang), () => this._togglePauseObject(entryId, true)); } return; } await this._runAction( { type: "maintenance_supporter/object/resume", entry_id: entryId }, { successToast: t("object_resumed", this._lang) }, ); } // v2.20 (N1): replace a worn-out object with a successor — the old one is // archived in place (history/costs stay browsable), the new one starts as a // pre-filled fresh unit with tasks and documents carried over. private async _replaceObject(entryId: string, currentName: string): Promise { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); const result = await dlg?.prompt({ title: t("replace_object", this._lang), message: t("replace_object_prompt", this._lang), confirmText: t("replace_object", this._lang), inputLabel: t("replace_name_label", this._lang), inputType: "text", inputValue: currentName, }); if (!result?.confirmed) return; const res = await this._runAction<{ entry_id?: string }>( { type: "maintenance_supporter/object/replace", entry_id: entryId, name: result.value || currentName, }, { successToast: t("object_replaced", this._lang) }, ); if (res?.entry_id) this._showObject(res.entry_id); } private async _skipTask(entryId: string, taskId: string, reason?: string): Promise { const msg: Record = { type: "maintenance_supporter/task/skip", entry_id: entryId, task_id: taskId, }; if (reason) msg.reason = reason; await this._runAction(msg); } private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise { const msg: Record = { type: "maintenance_supporter/task/reset", entry_id: entryId, task_id: taskId, }; if (resetDate) msg.date = resetDate; await this._runAction(msg); } private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise { await this._runAction({ type: "maintenance_supporter/task/apply_suggestion", entry_id: entryId, task_id: taskId, interval: interval, }); } private _openSeasonalOverrides(task: MaintenanceTask): void { const dlg = this.shadowRoot!.querySelector("maintenance-seasonal-overrides-dialog"); if (!dlg || !this._selectedEntryId) return; const overrides = task.adaptive_config?.seasonal_overrides as Record | null | undefined; dlg.open(this._selectedEntryId, task.id, overrides); } private async _reanalyzeInterval(entryId: string, taskId: string): Promise { const res = await this._runAction<{ recommended_interval: number | null; confidence: string; data_points: number; recommendation_reason: string | null; }>({ type: "maintenance_supporter/task/analyze_interval", entry_id: entryId, task_id: taskId, }); if (!res) return; if (res.recommended_interval) { this._showToast( `${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` + `(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`, ); } else { this._showToast(t("reanalyze_insufficient_data", this._lang)); } } private async _promptSkipTask(entryId: string, taskId: string): Promise { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); if (!dlg) return; const result = await dlg.prompt({ title: t("skip", this._lang), message: t("skip_reason_prompt", this._lang), confirmText: t("skip", this._lang), inputLabel: t("reason_optional", this._lang), inputType: "text", }); if (!result.confirmed) return; this._skipTask(entryId, taskId, result.value || undefined); } private async _promptResetTask(entryId: string, taskId: string): Promise { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); if (!dlg) return; const result = await dlg.prompt({ title: t("reset", this._lang), message: t("reset_date_prompt", this._lang), confirmText: t("reset", this._lang), inputLabel: t("reset_date_optional", this._lang), inputType: "date", }); if (!result.confirmed) return; this._resetTask(entryId, taskId, result.value || undefined); } private async _postponeTask(entryId: string, taskId: string, until: string): Promise { await this._runAction( { type: "maintenance_supporter/task/postpone", entry_id: entryId, task_id: taskId, until }, { successToast: t("postponed", this._lang) }, ); } private async _promptPostponeTask(entryId: string, taskId: string): Promise { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); if (!dlg) return; const result = await dlg.prompt({ title: t("postpone", this._lang), message: t("postpone_date_prompt", this._lang), confirmText: t("postpone", this._lang), inputLabel: t("postpone_date_label", this._lang), inputType: "date", }); if (!result.confirmed || !result.value) return; this._postponeTask(entryId, taskId, result.value); } private async _snoozeTask(entryId: string, taskId: string): Promise { // Now reloads like every sibling action — this was the one mutation that // skipped the refresh, leaving the snoozed due date stale until the next // poll. await this._runAction( { type: "maintenance_supporter/task/snooze", entry_id: entryId, task_id: taskId }, { successToast: t("snoozed", this._lang) }, ); } private _dismissSuggestion(entryId?: string, taskId?: string): void { if (entryId && taskId) { this._dismissedSuggestions.add(`${entryId}_${taskId}`); } this.requestUpdate(); } // v1.3.0: silent complete-via-QR. Tries the quick endpoint first; if the // task has no quick_complete_defaults configured, falls back to the // normal complete dialog so the user is never stuck after a scan. private async _handleQuickComplete( entryId: string, taskId: string, task: MaintenanceTask, ): Promise { try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/quick_complete", entry_id: entryId, task_id: taskId, }); this._showToast(t("quick_complete_success", this._lang)); } catch (e: unknown) { const code = (e as { code?: string })?.code || ""; // Both refusals mean "the dialog has to collect something": no // quick-complete defaults configured, or the task demands completion // details a silent complete cannot supply. The dialog opens as the // scan's fallback — via_tag_scan keeps the proof of presence. if (code === "no_defaults" || code === "completion_details_required") { this._openCompleteDialog( entryId, taskId, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled, { viaTagScan: true }, ); } else { // Relay the server's reason (too_early carries the earliest date) // instead of a generic "action failed". this._showToast(describeWsError(e, this._lang, t("action_error", this._lang))); } return; } // Silent success: the list must reflect the completion right away — the // subscription delta may lag or be coalesced. try { await this._loadData(); } catch { /* subscription will sync */ } } // v2.21: printable one-pager for a task — details, checklist tick boxes, // QR pair, and (when a linked PDF manual has a page hint for this task) a // link to the server-cut manual excerpt. Opens in a new tab for printing. private async _printTaskWorksheet(entryId: string, taskId: string): Promise { const obj = this._getObject(entryId); const task = obj?.tasks.find((tk) => tk.id === taskId); if (!obj || !task) return; this._actionLoading = true; try { const qrBase: Record = { type: "maintenance_supporter/qr/generate", entry_id: entryId, task_id: taskId, url_mode: "server", }; const [qrView, qrComplete] = await Promise.all([ this.hass.connection.sendMessagePromise<{ svg_data_uri?: string }>({ ...qrBase, action: "view" }).catch(() => null), this.hass.connection.sendMessagePromise<{ svg_data_uri?: string }>({ ...qrBase, action: "complete" }).catch(() => null), ]); // Manual excerpt: first linked PDF with a page hint for this task. let excerpt: WorksheetExcerpt | null = null; try { const docs = await this.hass.connection.sendMessagePromise<{ documents: Array<{ id: string; kind: string; mime?: string; title?: string; filename?: string; task_ids?: string[]; task_pages?: Record }>; }>({ type: "maintenance_supporter/documents/list", entry_id: entryId }); const manual = (docs.documents || []).find( (d) => d.kind === "file" && d.mime === "application/pdf" && (d.task_ids || []).includes(taskId) && d.task_pages?.[taskId], ); if (manual) { const start = manual.task_pages![taskId]; const count = 4; const signed = { path: await signApiPath( this.hass, `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`, 3600, ), }; excerpt = { title: manual.title || manual.filename || "Manual", startPage: start, endPage: start + count - 1, // Absolute URL: the sheet lives on a blob: page, where a // relative /api/... href cannot resolve (caught live). url: new URL(signed.path, window.location.origin).toString(), vendorBase: new URL("/maintenance_supporter_vendor", window.location.origin).toString(), }; } } catch { /* worksheet works without the excerpt */ } const L = this._lang; const labels: WorksheetLabels = { title: t("worksheet", L), object: t("object", L), type: t("maintenance_type", L), interval: t("interval", L), nextDue: t("next_due", L), lastDone: t("last_performed", L), priority: t("priority", L), checklist: t("checklist", L), notes: t("notes_label", L), scanView: t("worksheet_scan_view", L), scanComplete: t("worksheet_scan_complete", L), manualExcerpt: t("worksheet_manual_excerpt", L), pages: t("worksheet_pages", L), printedOn: t("worksheet_printed", L), never: t("worksheet_never", L), typeLabel: (ty: string) => t(ty, L), statusLabel: (st: string) => t(st, L), parts: t("consumes_parts_label", L), }; // Required parts as checkable lines: qty × name (owner) (stock unit) — // location. A pool owned by another object (#111) names that object, and // a link that resolves to nothing prints "Unknown part" rather than the // blank line the old own-parts-only lookup produced. const partsLines = (task.consumes_parts || []).map((link) => describePartLink(link, obj.entry_id, this._objects, L), ); const html = buildTaskWorksheetHtml( task, obj.object.name, labels, (iso) => formatDate(iso, L), (tk) => formatRecurrence(tk, L), qrView?.svg_data_uri || null, qrComplete?.svg_data_uri || null, excerpt, new Date().toISOString(), partsLines, taskRef(obj.object, task), ); openHtmlInNewTab(html); } finally { this._actionLoading = false; } } /** Open a manual-tagged document from the objects table / object header: * web-links directly, stored files through a signed path (the same * Companion-safe recipe as the documents section). */ private _openManualDoc(doc: ManualDocRef): void { if (doc.kind !== "file") { if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener"); return; } void openSignedDocument(this.hass, doc.id).catch(() => { /* tab already closed by the helper; the panel toast adds no value here */ }); } /** #73: persist one checklist tick. Sends the FULL current state (the * server replaces, not merges — idempotent) and reloads so the progress * header and any other open surface agree. */ private async _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise { const obj = this._getObject(entryId); const task = obj?.tasks.find((x) => x.id === taskId); if (!task) return; const state: Record = {}; // #139: a phased task ticks the checklist of the phase currently due. const steps = effectivePhase(task)?.checklist ?? (task.checklist || []); for (const step of steps) { const current = task.checklist_progress?.[step] ?? false; state[step] = step === item ? done : current; } await this._runAction({ type: "maintenance_supporter/task/checklist_progress", entry_id: entryId, task_id: taskId, checklist_state: state, }); } private _openCompleteDialog( entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean, opts?: { viaTagScan?: boolean }, ): void { void this._ui("maintenance-complete-dialog") .then((dlg) => dlg && this._fillAndOpenCompleteDialog(dlg, entryId, taskId, taskName, checklist, adaptiveEnabled, opts)); } /** Every dialog field is derived in helpers/complete-dialog-args — the * ONE derivation the panel, the Lovelace card and the quick-actions * dialog share (phase override #139, reading type+unit #83, restock * default + unit cost #104, shared pools #111, checklist ticks #73, * tag-scan gate). Resolved HERE so none of the call sites need to * thread anything but the name through. */ private _fillAndOpenCompleteDialog( dlg: MaintenanceCompleteDialog, entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean, opts?: { viaTagScan?: boolean }, ): void { fillAndOpenCompleteDialog( dlg, buildCompleteDialogArgs({ entryId, taskId, taskName, task: this._getTask(entryId, taskId), objects: this._objects, lang: this._lang, checklist, checklistsEnabled: this._features.checklists, adaptiveEnabled, currencySymbol: this._currencySymbol, viaTagScan: opts?.viaTagScan, }), this._lang, ); } private _openQrForObject(entryId: string, objectName: string): void { void this._ui("maintenance-qr-dialog") .then((dlg) => dlg?.openForObject(entryId, objectName)); } private _openQrForTask(entryId: string, taskId: string, objectName: string, taskName: string): void { void this._ui("maintenance-qr-dialog") .then((dlg) => dlg?.openForTask(entryId, taskId, objectName, taskName)); } private _onDialogEvent = async (): Promise => { try { await this._loadData(); } catch { /* subscription will sync */ } }; // --- Render --- render() { return html`
${this._staleBundle ? html`
${t("update_banner", this._lang)} location.reload()}> ${t("update_reload", this._lang)}
` : nothing} ${this._rowActionNotice && !!this.hass?.user?.is_admin ? html`
${t("row_actions_banner", this._lang)} this._dismissRowActionNotice(false)}> ${t("row_actions_keep", this._lang)} this._dismissRowActionNotice(true)}> ${t("row_actions_back", this._lang)}
` : nothing} ${this.narrow || this._view !== "overview" ? this._renderHeader() : nothing}
${this._view === "overview" ? this._renderOverview() : this._view === "all_objects" ? this._renderAllObjects() : this._view === "all_parts" ? this._renderAllParts() : this._view === "object" ? this._renderObjectDetail() : this._renderTaskDetail()}
this._onProblemSensorsAdopted(e)} > this._onSetupsAdopted(e)} > ) => this._onSavedViewsChanged(e)} > ${this._toastMessage ? html`
${this._toastMessage} ${this._toastUndo ? html`` : nothing}
` : nothing} ${this._renderPalette()} ${this._renderTemplateGallery()} `; } /** The magnifier that opens the global search (#171) — in the header * wherever the header is on screen, in the overview's tab bar on wide * screens (no header there), so it is one tap away on every view. */ private _renderSearchButton(cls: string) { const L = this._lang; return html` this._openPalette()} >`; } private _renderHeader() { const crumbs: { label: string; action?: () => void }[] = [ { label: t("maintenance", this._lang), action: () => this._showOverview() }, ]; if (this._view === "object" && this._selectedEntryId) { const obj = this._getObject(this._selectedEntryId); crumbs.push({ label: obj?.object.name || "Object" }); } if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) { const obj = this._getObject(this._selectedEntryId); crumbs.push({ label: obj?.object.name || "Object", action: () => this._showObject(this._selectedEntryId!), }); const task = this._getTask(this._selectedEntryId, this._selectedTaskId); crumbs.push({ label: task?.name || "Task" }); } return html`
${this.narrow && !this.embedded ? html`` : nothing} ${this._view !== "overview" ? html` { if (this._view === "task") this._showObject(this._selectedEntryId!); else this._showOverview(); }} >` : nothing} ${this._renderSearchButton("header-search")}
`; } private _renderOverview() { const L = this._lang; const isAdmin = !!this.hass?.user?.is_admin; const s = this._stats; // Only true HA admins get the Settings tab: global config, feature toggles, // vacation and import live there and stay admin-only server-side. Operators // (incl. allowlisted ones with full content CRUD) and plain users can't open // it. The Calendar tab IS visible to everyone (read-only view). if (!isAdmin && this._overviewTab === "settings") { this._overviewTab = "dashboard"; } return html` ${s ? html`
this._showAllObjects()} title=${t("show_all_objects", L)}> ${s.total_objects} ${t("objects", L)}
this._filterByStatus("")} title=${t("show_all_tasks", L)}> ${s.total_tasks} ${t("tasks", L)}
this._filterByStatus("overdue")} title=${t("filter_to_overdue", L)}> ${s.overdue} ${t("overdue", L)}
this._filterByStatus("due_soon")} title=${t("filter_to_due_soon", L)}> ${s.due_soon} ${t("due_soon", L)}
this._filterByStatus("triggered")} title=${t("filter_to_triggered", L)}> ${s.triggered} ${t("triggered", L)}
${this._features.budget ? this._renderBudgetTiles() : nothing}
` : nothing}
this._setOverviewTab("today")}> ${t("tab_today", L)}
this._setOverviewTab("dashboard")}> ${t("dashboard", L)}
this._setOverviewTab("calendar")}> ${t("tab_calendar", L)}
${isAdmin ? html`
this._setOverviewTab("settings")}> ${t("settings", L)}
` : nothing} ${this.narrow ? nothing : this._renderSearchButton("tab-search")}
${this._overviewTab === "today" ? this._renderToday() : this._overviewTab === "dashboard" ? this._renderDashboard() : this._overviewTab === "calendar" ? html`
` : html``} `; } /** v2.0.0: intercept ll-custom open-task events from the embedded calendar * card so we navigate within the panel (using _showTask) instead of * letting the document-level dialog-mount handler open a duplicate * dialog on top. The handler stops propagation so the document listener * (registered by the dashboard-strategy bundle) doesn't also fire. */ private _onCalendarLlCustom = (e: Event): void => { const detail = (e as CustomEvent<{ type?: string; entry_id?: string; task_id?: string }>).detail; if ( detail?.type === "maintenance-supporter:open-task" && detail.entry_id && detail.task_id ) { e.stopPropagation(); this._showTask(detail.entry_id, detail.task_id); } }; // v2.0.0: _renderCalendar() removed — replaced by an embedded // . The card holds all the same // logic (window chips, source icons, prediction pills, projected events) // and is the single source of truth for calendar rendering across the // panel and stand-alone Lovelace use. Kept for reference: the old // implementation rendered ~120 lines of Lit template literals here, // duplicating what the card now does. /** Status badge with a shape icon so status isn't conveyed by colour alone * (colour-blind accessibility) — icon + text + colour together. The label * is its own span so narrow/tight rows can drop it (#150: the pill ate a * third of a phone row) while `title`/`aria-label` keep the text reachable. */ private _statusBadge(archived: boolean, isDone: boolean, status: string) { const L = this._lang; const cls = archived ? "archived" : (isDone ? "done" : status); const iconKey = archived ? "archived" : (isDone ? "completed" : status); const label = archived ? t("archived", L) : (isDone ? t("completed", L) : t(status, L)); return html`${label}`; } private _setOverviewTab(tab: OverviewTab): void { this._overviewTab = tab; try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ } this._scrollContentToTop(); } /** Mobile-first focus list: what needs attention now, grouped by urgency. */ private _renderToday() { const L = this._lang; const rows = this._taskRows; // already excludes archived + disabled const key = (r: TaskRow) => `${r.entry_id}:${r.task_id}`; const overdue = rows.filter((r) => r.status === "overdue" || r.trigger_active); const claimed = new Set(overdue.map(key)); const dueToday = rows.filter((r) => !claimed.has(key(r)) && r.days_until_due === 0); dueToday.forEach((r) => claimed.add(key(r))); const thisWeek = rows.filter((r) => !claimed.has(key(r)) && r.days_until_due != null && r.days_until_due > 0 && r.days_until_due <= 7); if (overdue.length + dueToday.length + thisWeek.length === 0) { return html`

${t("today_all_caught_up", L)}

`; } return html`
${this._renderTodaySection("today_overdue", overdue, "overdue")} ${this._renderTodaySection("today_due_today", dueToday, "due_soon")} ${this._renderTodaySection("today_this_week", thisWeek, "")}
`; } private _renderTodaySection(titleKey: string, rows: TaskRow[], cls: string) { if (rows.length === 0) return nothing; const L = this._lang; // #169: name the responsible person like the dashboard rows do — as the // member's avatar (initials in their colour) plus the name; narrow // layouts keep only the avatar. With the person filter active every // row belongs to the same person, so the chip would only repeat the // filter — leave it out then. const personOf = (row: TaskRow): PersonDisplay | null => this._filterUser || !row.responsible_user_id ? null : this._userService?.getPerson(row.responsible_user_id) ?? null; return html`
${t(titleKey, L)}${rows.length}
${rows.map((row) => html`
this._showTask(row.entry_id, row.task_id)}>
${this._listRef(row.entry_id, row.task_id)}${row.task_name}
${row.object_name} · ${formatDueDays(row.days_until_due, L)} ${renderPersonChip(personOf(row), "today-person")}
${this._renderRowActions(L, () => this._openCompleteDialogForRow(row), undefined, false)}
`)}
`; } private _renderDashboard() { const s = this._stats; const rows = this._taskRows; const L = this._lang; const isOperator = this._isOperator; // v2.10.0: count archived tasks so the reveal toggle can show how many are // hidden (and hide itself entirely when there is nothing archived). const archivedCount = this._objects.reduce( (n, o) => n + o.tasks.filter((tk) => tk.archived).length, 0, ); // Filters actively narrowing the list — shown on the collapsed toggle so // "why is my list short?" has a visible answer even with filters hidden. const activeFilterCount = (this._filterStatus ? 1 : 0) + (this._filterUser ? 1 : 0) + (this._filterLabel ? 1 : 0) + (this._filterPriority ? 1 : 0) + (this._activeViewId ? 1 : 0); return html` ${this.narrow ? html`
{ this._filtersOpen = !this._filtersOpen; }} > ${t("filter_label", L)}${activeFilterCount > 0 ? ` (${activeFilterCount})` : ""} ${!isOperator ? this._renderNewMenu(L) : nothing}
` : nothing}
${!isOperator ? html` this._openSavedViewsDialog()} > ` : nothing} ${this._allLabels.length > 0 ? html` ` : nothing} ${archivedCount > 0 ? html` { this._showArchived = !this._showArchived; this._activeViewId = ""; }} > ${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedCount})`} ` : nothing} ${!isOperator && rows.length > 0 ? html` this._toggleBulkMode()} > ${this._bulkMode ? t("cancel", L) : t("bulk_select", L)} ` : nothing} ${!isOperator && !this.narrow ? this._renderNewMenu(L) : nothing}
${!isOperator ? this._renderGettingStartedChips(L) : nothing} ${rows.length === 0 ? html`

${t("no_tasks", L)}

${!isOperator && this._objects.length === 0 ? html`

${t("onboard_hint", L)}

this._openTemplateGallery()}> ${t("templates_from", L)} this._ui("maintenance-object-dialog").then((d) => d?.openCreate())}> ${t("new_object", L)}
` : nothing}
` : html` ${this._bulkMode ? this._renderBulkBar(rows, L) : nothing} ${this._splitActive() ? html`
${this._groupByMode === "none" ? this._renderTaskTable(rows) : this._renderGroupedTasks(rows, L)}
${this._selectedEntryId && this._selectedTaskId && this._getTask(this._selectedEntryId, this._selectedTaskId) ? this._renderTaskDetail() : html`

${t("split_select_hint", L)}

`}
` : this._groupByMode === "none" ? this._renderTaskTable(rows) : this._renderGroupedTasks(rows, L)} `} ${this._features.groups && !isOperator ? this._renderGroupsSection() : nothing} ${!isOperator ? html`) => { const eid = e.detail?.entry_id; if (eid) this._showObject(eid); }} >` : nothing} `; } /** The flat dashboard task table. Below VIRTUAL_MIN_ROWS (or on narrow * layouts, where rows aren't uniform-height) every row renders directly. * Above it, only the scroll window is in the DOM: two grid-spanning * spacers keep the scrollbar honest, and a hidden "sizer" row pins the * content-sized badge column to the widest badge set across ALL rows so * the shared subgrid tracks can't jitter as rows enter/leave the window. */ private _renderTaskTable(rows: TaskRow[]) { const bulkCls = this._bulkMode ? " bulk" : ""; this._virtTotalRows = rows.length; if (this.narrow || rows.length < VIRTUAL_MIN_ROWS) { return html`
${rows.map((row) => this._renderOverviewRow(row))}
`; } const total = rows.length; const rh = this._virtRowHeight; let start = Math.max(0, Math.min(this._virtStart, total)); let end = this._virtEnd > 0 ? Math.min(this._virtEnd, total) : Math.min(total, 40); if (end < start) { start = 0; end = Math.min(total, 40); } const padTop = start * rh; const padBottom = (total - end) * rh; return html`
${this._renderVirtSizerRow(rows)} ${padTop > 0 ? html`
` : nothing} ${rows.slice(start, end).map((row) => this._renderOverviewRow(row))} ${padBottom > 0 ? html`
` : nothing}
`; } /** Invisible zero-height row whose badge cell carries the union of the * widest badges across ALL rows. Because the subgrid's `auto` badge track * sizes to the max over rendered rows, including this row makes the track * width independent of which data rows happen to be in the window. */ private _renderVirtSizerRow(rows: TaskRow[]) { const L = this._lang; let widest = ""; let anyDisabled = false; let anyNfc = false; let anyPriority = false; for (const r of rows) { const label = r.archived ? t("archived", L) : (r.is_done ? t("completed", L) : t(r.status, L)); if (label.length > widest.length) widest = label; if (!r.enabled) anyDisabled = true; if (r.nfc_tag_id) anyNfc = true; if (r.priority === "high" || r.priority === "low") anyPriority = true; } return html` `; } private _renderBulkBar(rows: TaskRow[], L: string) { const n = this._bulkSelected.size; const allSelected = rows.length > 0 && rows.every((r) => this._bulkSelected.has(this._bulkKey(r))); return html`
${t("bulk_n_selected", L).replace("{n}", String(n))} this._bulkComplete(rows)}> ${t("complete", L)} this._bulkArchive(rows)}> ${t("archive", L)}
`; } private _renderGroupedTasks(rows: TaskRow[], L: string) { const groups = new Map(); const noneLabel = t("unassigned", L); for (const row of rows) { let keys: string[] = []; if (this._groupByMode === "area") { const a = row.area_id ? this.hass?.areas?.[row.area_id]?.name : null; keys = [a || noneLabel]; } else if (this._groupByMode === "user") { const u = row.responsible_user_id ? this._userService?.getUserName(row.responsible_user_id) : null; keys = [u || noneLabel]; } else if (this._groupByMode === "group") { keys = row.group_names.length > 0 ? row.group_names : [noneLabel]; } else if (this._groupByMode === "object") { // #150(3): group the dashboard by the owning object/device. keys = [row.object_name]; } for (const k of keys) { if (!groups.has(k)) groups.set(k, []); groups.get(k)!.push(row); } } const sorted = [...groups.entries()].sort(([a], [b]) => { // Push "unassigned" / "no area" to bottom if (a === noneLabel && b !== noneLabel) return 1; if (b === noneLabel && a !== noneLabel) return -1; return a.localeCompare(b); }); const icon = this._groupByMode === "area" ? "mdi:map-marker-outline" : this._groupByMode === "group" ? "mdi:folder-outline" : this._groupByMode === "object" ? "mdi:cube-outline" : "mdi:account-outline"; // ONE grid owns the column tracks for every section (each section card // and its row block are column subgrids), so the badge/object/name // columns line up across sections no matter which badges a section // carries — per-section tables started the task name up to 60px apart // ("OK"-only vs overdue+ok sections). A
can't sit in that // chain (Chrome's ::details-content box breaks subgrid), hence the // div-based disclosure with its own collapsed state. return html`
${sorted.map(([key, taskRows]) => { const open = !this._collapsedGroups.has(key); return html`
this._toggleGroup(key)} @keydown=${(e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); this._toggleGroup(key); } }} > ${key} (${taskRows.length})
${open ? html`
${taskRows.map((row) => this._renderOverviewRow(row))}
` : nothing}
`; })}
`; } private _toggleGroup(key: string): void { const next = new Set(this._collapsedGroups); if (next.has(key)) next.delete(key); else next.add(key); this._collapsedGroups = next; } // (#67) Localised warranty label, shared by the detail meta + table cell. private _warrantyLabel(ws: ReturnType, iso: string, L: string): string { if (ws.kind === "expired") return t("warranty_expired", L); if (ws.kind === "expiring") { return t("warranty_expires_in", L).replace("{days}", String(ws.days ?? 0)); } return t("warranty_valid_until", L).replace("{date}", formatDate(iso, L)); } // (#67) Warranty status as a coloured chip in the object detail meta. private _renderWarrantyMeta(iso: string, L: string) { const ws = warrantyStatus(iso); return html`

${t("warranty", L)}: ${this._warrantyLabel(ws, iso, L)}

`; } private _renderAllObjects() { const L = this._lang; const isOperator = this._isOperator; // (#67) Table mode is desktop-only; narrow viewports always fall back to // cards (the table's many columns don't fit a phone). const tableMode = this._objectViewMode === "table" && !this.narrow; // v2.10.0: how many objects are archived (drives the reveal toggle below). const archivedObjCount = this._objects.filter((o) => o.object.archived).length; // Sort + group helpers const minDays = (obj: MaintenanceObjectResponse): number => { let m = Infinity; for (const t of obj.tasks) { const d = t.days_until_due; if (d !== null && d !== undefined && d < m) m = d; } return m; }; const sorted = this._objects.filter((o) => this._showArchived || !o.object.archived); if (this._objectSortMode === "alphabetical") { sorted.sort((a, b) => a.object.name.localeCompare(b.object.name)); } else if (this._objectSortMode === "task_count") { sorted.sort((a, b) => b.tasks.length - a.tasks.length || a.object.name.localeCompare(b.object.name)); } else { // due_soonest sorted.sort((a, b) => minDays(a) - minDays(b) || a.object.name.localeCompare(b.object.name)); } // Group by area for objects view const groupedByArea = (): Map => { const map = new Map(); for (const obj of sorted) { const areaId = obj.object.area_id; const key = areaId ? this.hass?.areas?.[areaId]?.name || t("unassigned", L) : t("no_area", L); if (!map.has(key)) map.set(key, []); map.get(key)!.push(obj); } return new Map([...map.entries()].sort(([a], [b]) => a.localeCompare(b))); }; const renderObject = (obj: MaintenanceObjectResponse) => { const overdue = obj.tasks.some(t => t.status === "overdue" || t.status === "triggered"); return html`
this._showObject(obj.entry_id)}> ${overdue ? html`` : nothing}
${this._objRef(obj.object)}${obj.object.name} ${obj.object.paused ? html` ` : nothing} ${obj.object.document_count ? html` ${obj.object.document_count} ` : nothing} ${obj.tasks.length} ${t("tasks_lower", L)}
${obj.object.manufacturer || obj.object.model ? html`
${[obj.object.manufacturer, obj.object.model].filter(Boolean).join(" ")}
` : nothing} ${obj.tasks.length === 0 ? html`
${t("no_tasks_yet", L)}
` : nothing}
`; }; return html`
${!this.narrow ? html`
` : nothing} ${!tableMode ? html` ` : nothing} ${!isOperator ? html` this._ui("maintenance-object-dialog").then((d) => d?.openCreate())} > ${t("new_object", L)} ` : nothing} this._exportObjectsCsv()}> ${t("settings_export_csv", L)} ${archivedObjCount > 0 ? html` { this._showArchived = !this._showArchived; }} > ${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedObjCount})`} ` : nothing}
${tableMode ? this._renderObjectsTable(sorted) : this._groupByMode === "area" ? html` ${[...groupedByArea().entries()].map(([area, objs]) => html`
${area} (${objs.length})
${objs.map(renderObject)}
`)} ` : html`
${sorted.map(renderObject)}
`} `; } private _setObjectViewMode(mode: "cards" | "table"): void { this._objectViewMode = mode; try { lsSet(LS_KEYS.objectView, mode); } catch { /* private mode */ } } // ── #130: instance-wide parts overview ──────────────────────────────────── private _renderAllParts() { const L = this._lang; const rows = this._allParts; const currency = this._currencySymbol; return html`
this._exportPartsCsv()}> ${t("settings_export_csv", L)}
${rows === null ? html`
…
` : rows.length === 0 ? html`
${t("parts_section", L)}: 0
` : html`
${rows.map((row) => html` this._showObject(row.entry_id)}> `)}
${t("part_name", L)} ${t("object", L)} ${t("part_stock", L)} ${t("part_reorder_threshold", L)} ${t("part_cost", L)} ${t("part_storage_location", L)} ${t("parts_used_by", L)}
${this._objRef(this._getObject(row.entry_id)?.object)}${row.name} ${row.low ? html`` : nothing} ${row.object_name || "—"} ${row.stock !== null ? `${row.stock}${row.unit ? ` ${row.unit}` : ""}` : "—"} ${row.reorder_threshold ?? "—"} ${row.cost != null ? formatCost(row.cost, currency, L) : "—"} ${row.storage_location || "—"} ${row.consumers.length === 0 ? "—" : row.consumers.map((c) => html` ${c.pooled ? `${c.object_name} · ` : ""}${c.task_name ?? c.task_id} `)}
`} `; } /** Client-side CSV of the loaded overview rows (the objects table's CSV is * server-built; the parts rows are already fully materialized here). */ private _exportPartsCsv(): void { const rows = this._allParts || []; const esc = (v: unknown): string => { const s = v == null ? "" : String(v); return /[",\n;]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const header = ["name", "object", "stock", "unit", "reorder_threshold", "unit_cost", "storage_location", "vendor", "used_by"]; const lines = [header.join(",")]; for (const row of rows) { lines.push([ esc(row.name), esc(row.object_name), esc(row.stock), esc(row.unit), esc(row.reorder_threshold), esc(row.cost), esc(row.storage_location), esc(row.vendor), esc(row.consumers.map((c) => `${c.object_name ?? ""}/${c.task_name ?? c.task_id}×${c.quantity}`).join(" | ")), ].join(",")); } const ts = isoDateLocal(new Date()); downloadTextFile(lines.join("\n"), `maintenance_parts_${ts}.csv`, "text/csv;charset=utf-8"); } // (#67 / Phase 3) Download all objects as a one-row-per-object CSV. private async _exportObjectsCsv(): Promise { const result = await runWs<{ csv: string }>(this, { type: "maintenance_supporter/objects/csv" }, { onError: (m) => this._showToast(m) }); if (!result) return; const ts = isoDateLocal(new Date()); downloadTextFile(result.csv, `maintenance_objects_${ts}.csv`, "text/csv;charset=utf-8"); } // (#67) Tabular All-Objects view honouring the configured columns. Desktop // only — the caller falls back to cards on narrow viewports. private _renderObjectsTable(objs: MaintenanceObjectResponse[]) { const L = this._lang; const cols = this._objectsTableColumns; return html`
${cols.map((key) => { const def = OBJECT_COLUMNS.find((c) => c.key === key); // The actions column header stays blank (icon-only cells). const label = def && def.key !== "actions" ? t(def.labelKey, L) : ""; return html``; })} ${objs.map((obj) => html` this._showObject(obj.entry_id)}> ${cols.map((key) => this._renderObjectCell(key, obj, L))} `)}
${label}
`; } private _renderObjectCell(key: string, obj: MaintenanceObjectResponse, L: string) { const o = obj.object; switch (key) { case "name": return html` ${this._objRef(o)}${o.name} ${o.document_count ? html` ${o.document_count} ` : nothing} `; case "manufacturer": return html`${o.manufacturer || "—"}`; case "model": return html`${o.model || "—"}`; case "serial_number": return html`${o.serial_number || "—"}`; case "installation_date": return html`${o.installation_date ? formatDate(o.installation_date, L) : "—"}`; case "warranty_expiry": return html`${this._renderWarrantyCell(o.warranty_expiry, L)}`; case "area_id": { const area = o.area_id ? (this.hass?.areas?.[o.area_id]?.name || o.area_id) : "—"; return html`${area}`; } case "documentation_url": { // Fallback: an UPLOADED manual (category "manual") is the object's // manual just as much as the legacy URL field — an object with its // handbook attached must not render "—" here (prod: Easee vs Epson). const manualDoc = (o.manual_docs || [])[0]; return html`${ isSafeHttpUrl(o.documentation_url) ? html` e.stopPropagation()}>` : manualDoc ? html` { e.preventDefault(); e.stopPropagation(); this._openManualDoc(manualDoc); }} >` : "—" }`; } case "notes": return html`${o.notes || "—"}`; case "task_count": return html`${obj.tasks.length}`; case "ref_no": return html`${objectRef(o) ? `#${objectRef(o)}` : "—"}`; case "actions": return html` { e.stopPropagation(); this._openQrForObject(obj.entry_id, o.name); }}> `; default: return html``; } } // Warranty chip for a table cell — like _renderWarrantyMeta but renders an // em-dash placeholder when the object has no warranty date. private _renderWarrantyCell(iso: string | null | undefined, L: string) { const ws = warrantyStatus(iso); if (ws.kind === "none") return html`—`; return html`${this._warrantyLabel(ws, iso as string, L)}`; } private async _onSettingsChanged(): Promise { await this._loadData(); } private _renderGroupsSection() { if (!this._features.groups) return nothing; const entries = Object.entries(this._groups); const L = this._lang; return html`

${t("groups", L)}

this._openGroupCreate()}> ${t("new_group", L)}
${entries.length === 0 ? html`
${t("no_groups", L)}
` : html`
${entries.map(([gid, group]) => { const taskNames = group.task_refs .map((ref) => this._getTask(ref.entry_id, ref.task_id)?.name) .filter(Boolean); return html`
${group.name}
this._openGroupEdit(gid)}> this._deleteGroup(gid, group.name)}>
${group.description ? html`
${group.description}
` : nothing}
${taskNames.length > 0 ? taskNames.map((n) => html`${n}`) : html`${t("no_tasks_short", L)}`}
`; })}
`}
`; } private _openGroupCreate(): void { this.shadowRoot!.querySelector("maintenance-group-dialog")?.openCreate(); } private _openGroupEdit(groupId: string): void { const group = this._groups[groupId]; if (!group) return; this.shadowRoot!.querySelector("maintenance-group-dialog")?.openEdit(groupId, group); } private async _deleteGroup(groupId: string, name: string): Promise { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); const ok = dlg ? await dlg.confirm({ title: t("delete_group", this._lang), message: t("delete_group_confirm", this._lang).replace("{name}", name), confirmText: t("delete", this._lang), }) : confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`); if (!ok) return; await this._runAction({ type: "maintenance_supporter/group/delete", group_id: groupId, }); } /** Budget as KPI tiles in the stats strip (#125) — replaces the old * full-width budget row. Same semantics as before, incl. #104: a budget * WITHOUT a maximum still shows its plain spent total (no denominator, * no bar), so "what did I spend" always has an answer. Being part of the * strip, the tiles now show on every tab — budget is a global figure * like the overdue count. */ private _renderBudgetTiles() { const b = this._budget; if (!b) return nothing; const L = this._lang; const cs = this._currencySymbol; const tile = (label: string, spent: number, budget: number | null) => { if (budget !== null) { const pct = Math.min(100, Math.max(0, (spent / budget) * 100)); const color = pct >= 100 ? "var(--error-color, #f44336)" : pct >= b.alert_threshold_pct ? "var(--warning-color, #ff9800)" : "var(--success-color, #4caf50)"; return html`
${formatCost(spent, cs, L)} / ${formatCost(budget, cs, L)}
${label}
`; } return html`
${formatCost(spent, cs, L)} ${label}
`; }; return html` ${tile(t("budget_monthly", L), b.monthly_spent || 0, b.monthly_budget > 0 ? b.monthly_budget : null)} ${tile(t("budget_yearly", L), b.yearly_spent || 0, b.yearly_budget > 0 ? b.yearly_budget : null)} `; } private _renderOverviewRow(row: TaskRow) { const L = this._lang; // Compute days progress bar const hasDaysBar = row.schedule_type === "time_based" && row.interval_days && row.interval_days > 0; let pct = 0; let barColor = STATUS_COLORS.ok; let daysOverflow = false; if (hasDaysBar && row.days_until_due !== null) { const p = daysProgress(row.interval_days, row.days_until_due, row.interval_unit); pct = p.pct; daysOverflow = p.overflow; if (row.status === "overdue") barColor = STATUS_COLORS.overdue; else if (row.status === "due_soon") barColor = STATUS_COLORS.due_soon; } const areaName = row.area_id ? this.hass?.areas?.[row.area_id]?.name : null; const userName = row.responsible_user_id ? this._userService?.getUserName(row.responsible_user_id) : null; const hasSub = row.group_names.length > 0 || areaName || userName; const bulkSelected = this._bulkMode && this._bulkSelected.has(this._bulkKey(row)); return html`
${this._bulkMode ? html` ` : nothing} ${this._statusBadge(!!row.archived, row.is_done, row.status)} ${!row.enabled ? html`${t("disabled", L)}` : nothing} ${row.nfc_tag_id ? html`` : nothing} ${row.priority === "high" ? html`` : nothing} ${row.priority === "low" ? html`` : nothing} { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name} this._showTask(row.entry_id, row.task_id)}>${this._listRef(row.entry_id, row.task_id)}${row.task_name} ${row.group_names.length > 0 ? html` ${row.group_names.join(", ")} ` : nothing} ${areaName ? html` ${areaName} ` : nothing} ${userName ? renderPersonChip(this._userService?.getPerson(row.responsible_user_id) ?? null, "sub-chip") : nothing} ${(row.labels || []).map((label) => html` ${label} `)} ${t(row.type, L)} this._showTask(row.entry_id, row.task_id)}> ${formatDueDays(row.days_until_due, L)} ${hasDaysBar ? html`
` : nothing} ${row.trigger_config ? renderTriggerProgress(row, { trend: computeTrend(row, this._miniStatsData), lang: L }) : !hasDaysBar && row.trigger_active ? html`⚡` : nothing} ${renderMiniSparkline(row, this._miniStatsData, this._lang)}
${this._renderRowActions(L, () => this._openCompleteDialogForRow(row), () => this._promptSkipTask(row.entry_id, row.task_id), row.allow_skip)}
`; } /** DESIGN PROTOTYPE (#145 wish 1): row actions as bare icons (current) or * as labelled colour-coded buttons. Switch: localStorage "msp-action-style" * = "buttons" — evaluation only, no settings plumbing yet. */ private _actionStyle(): string { return this._rowActionStyle; } /** The #145 banner's two ways out: keep the buttons (just dismiss) or go * back to icons (style + dismiss in one write). Admin-only, like every * global setting. */ private async _dismissRowActionNotice(backToIcons: boolean): Promise { const settings: Record = { row_action_notice_pending: false }; if (backToIcons) settings.row_action_style = "icons"; const ok = await runWs(this, { type: "maintenance_supporter/global/update", settings }, { onError: (m) => this._showToast(m) }); if (ok === undefined) return; this._rowActionNotice = false; if (backToIcons) this._rowActionStyle = "icons"; invalidateSettingsCache(); } /** #145: row actions as HA buttons — labelled (accent/success + outlined * warning, the task-detail pairing) or, for "buttons_compact" on narrow screens, * icon-only — or the classic icon pair when the household chose "icons". */ private _renderRowActions(L: string, onComplete: () => void, onSkip?: () => void, allowSkip = true): TemplateResult { const style = this._actionStyle(); if (style === "buttons" || style === "buttons_compact") { const iconOnly = style === "buttons_compact" && (this.narrow || this.tight); if (iconOnly) { // Compact narrow form: two icon-only ha-buttons — solid green // Complete, outlined orange Skip (#150). Skip wears HA's warning // variant on every surface, so the colour reads the same whether // or not the word is there. return html` { e.stopPropagation(); onComplete(); }}> ${allowSkip ? html` { e.stopPropagation(); onSkip?.(); }}> ` : nothing} `; } return html` { e.stopPropagation(); onComplete(); }}> ${t("complete", L)} ${allowSkip ? html` { e.stopPropagation(); onSkip?.(); }}> ${t("skip", L)} ` : nothing} `; } return html` { e.stopPropagation(); onComplete(); }}> ${allowSkip ? html` { e.stopPropagation(); onSkip?.(); }}> ` : nothing} `; } private _openCompleteDialogForRow(row: TaskRow): void { const obj = this._objects.find(o => o.entry_id === row.entry_id); const task = obj?.tasks.find(t => t.id === row.task_id); this._openCompleteDialog( row.entry_id, row.task_id, row.task_name, this._features.checklists ? task?.checklist : undefined, this._features.adaptive && !!task?.adaptive_config?.enabled ); } /** * Render a compact trigger progress bar for overview rows. * Works for threshold (value vs limit), counter (value/delta vs target), state_change (count vs target). */ private _renderObjectDetail() { if (!this._selectedEntryId) return nothing; const obj = this._getObject(this._selectedEntryId); if (!obj) return html`

Object not found.

`; const o = obj.object; const L = this._lang; const isOperator = this._isOperator; // v2.10.0: archived tasks are hidden in the object detail too, unless the // shared show-archived toggle is on; a per-detail toggle reveals them when // this object has any (e.g. a task archived individually on an active object). const archivedInObj = obj.tasks.filter((tk) => tk.archived).length; const visibleTasks = obj.tasks.filter((tk) => this._showArchived || !tk.archived); return html`

${o.name} ${renderRefChip(objectRef(o), t("ref_number", L))}

${!isOperator ? html` { void this._ui("maintenance-task-dialog") .then((d) => d?.openCreate(obj.entry_id)); }}>${t("add_task", L)} { void this._ui("maintenance-object-dialog") .then((d) => d?.openEdit(obj.entry_id, o)); }}>${t("edit", L)} ` : nothing}
this._toggleObjMenu()}> ${this._objMenuOpen ? html` ` : nothing}
${o.paused ? html`

${t("object_paused_badge", L)}${o.paused_until ? html` — ${t("paused_until_label", L)} ${formatDate(o.paused_until, L)}` : nothing}

` : nothing} ${o.manufacturer || o.model ? html`

${[o.manufacturer, o.model].filter(Boolean).join(" ")}

` : nothing} ${o.serial_number ? html`

${t("serial_number_label", L)}: ${o.serial_number}

` : nothing} ${isSafeHttpUrl(o.documentation_url) ? html`

${t("documentation_url_label", L)}: ${o.documentation_url}

` : (o.manual_docs || []).length ? html`

${t("documentation_url_label", L)}: ${o.manual_docs!.slice(0, 3).map( (m, i) => html`${i > 0 ? " · " : ""} { e.preventDefault(); this._openManualDoc(m); }}>${m.title}`, )}${o.manual_docs!.length > 3 ? html` … +${o.manual_docs!.length - 3}` : nothing}

` : nothing} ${o.installation_date ? html`

${t("installed", L)}: ${formatDate(o.installation_date, L)}

` : nothing} ${o.warranty_expiry ? this._renderWarrantyMeta(o.warranty_expiry, L) : nothing} ${o.notes ? html`
${t("object_notes_label", L)}
${renderNotesMarkdown(o.notes)}
` : nothing} ${this._renderObjectSection("tasks", t("tasks", L), visibleTasks.length, () => html`

${t("tasks", L)} (${visibleTasks.length})${archivedInObj > 0 ? html` { this._showArchived = !this._showArchived; }} > ${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedInObj})`} ` : nothing}

${obj.tasks.length === 0 ? html`

${t("no_tasks_yet", L)}

{ void this._ui("maintenance-task-dialog") .then((d) => d?.openCreate(obj.entry_id)); }}>${t("add_first_task", L)}
` : html`
${[...visibleTasks].sort((a, b) => { const so: Record = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 }; return (so[a.status] ?? 9) - (so[b.status] ?? 9) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999); }).map((task) => html`
${this._statusBadge(!!task.archived, !!task.is_done, task.status)} ${!task.enabled ? html`${t("disabled", L)}` : nothing} ${task.nfc_tag_id ? html`` : nothing} ${task.document_count ? html`${task.document_count}` : nothing} this._showTask(obj.entry_id, task.id)}>${this._listRef(obj.entry_id, task.id)}${task.name} ${renderUserBadge(task, (id) => this._userService?.getUserName(id) ?? null, (id) => this._userService?.getPerson(id) ?? null)} ${t(task.type, L)} this._showTask(obj.entry_id, task.id)}> ${formatDueDays(task.days_until_due, L)} ${task.trigger_config ? renderTriggerProgress(task, { trend: computeTrend(task, this._miniStatsData), lang: L }) : nothing} ${renderMiniSparkline(task, this._miniStatsData, this._lang)} ${this._renderRowActions( L, () => this._openCompleteDialog(obj.entry_id, task.id, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled), () => this._promptSkipTask(obj.entry_id, task.id), task.allow_skip, )}
`)}
`} `)} ${this._renderObjectSection("documents", t("documents", L), typeof o.document_count === "number" ? o.document_count : null, () => html` `)} ${(obj.parts || []).length || !isOperator ? this._renderObjectSection("parts", t("parts_section", L), (obj.parts || []).length, () => html` this._loadData()} > `) : nothing} ${obj.tasks.some((tk) => (tk.times_performed || 0) > 0 || (tk.history || []).length > 0) ? this._renderObjectSection("history", t("object_history_section", L), null, () => html` this._userService?.getUserName(id) ?? null} @open-task=${(e: CustomEvent<{ taskId: string }>) => this._showTask(obj.entry_id, e.detail.taskId)} > `) : nothing}
`; } /** #179: one foldable block of the object page. Open = the section renders * itself (its own heading, buttons, list) with the chevron in the gutter * beside its heading; collapsed = only a heading row (title, count, * chevron) — the section's component is not in the DOM at all, so no * thumbnails load and there is nothing to scroll past. A visit-only * override (search hit, `?section=` link) opens a collapsed section * without rewriting the saved set. Callers skip the block where the * section itself would render nothing (parts for an operator without * parts, history without a single entry) — a chevron beside nothing. */ private _renderObjectSection(section: ObjectSection, title: string, count: number | null, body: () => unknown) { const L = this._lang; const open = this._objectSectionOverride === section || !this._objectSectionsCollapsed.has(section); const label = open ? t("section_collapse", L) : t("section_expand", L); return html`
${open ? html`
${body()}
` : html`

this._toggleObjectSection(section)}> ${title}${count !== null ? html`${count}` : nothing}

`}
`; } /** * Render compact task header with status chip and action buttons. */ // Object-detail ⋮ menu — same click-away pattern as the task-detail menu. /** #125: the one "New ▾" menu replacing the six action buttons. Same * popup pattern as the object/task menus; on narrow it lives in the * mobile-controls row and replaced the old collapsed actions bar. */ private _renderNewMenu(L: string) { return html`
{ e.stopPropagation(); this._toggleNewMenu(); }}> ${t("add", L)} ${this._newMenuOpen ? html` ` : nothing}
`; } /** Shared popup mechanic (DRY audit 2026-08: this closer was copy-pasted * three times): flip the flag, and while open arm a one-shot document * click that closes it again. The setTimeout defers past the click that * opened the menu. */ private _togglePopup(get: () => boolean, set: (v: boolean) => void): void { const open = !get(); set(open); if (open) { setTimeout(() => { const handler = () => { set(false); document.removeEventListener("click", handler); }; document.addEventListener("click", handler); }, 0); } } private _toggleNewMenu(): void { this._togglePopup(() => this._newMenuOpen, (v) => { this._newMenuOpen = v; }); } private _closeNewMenu(): void { this._newMenuOpen = false; } // ── #125: getting-started chips ────────────────────────────────────────── // Dismissible onboarding hints while the instance is YOUNG (few objects/ // tasks — deliberately a maturity signal, not install age). The fleet chip // is content-driven instead (available && not configured), mirroring the // old inline button's own retirement rule. Nothing ever moves: the New // menu is the stable home from day one; these are visibly temporary. /** Young = the discovery hints still earn their place. Fleet objects don't * count — a one-click fleet setup shouldn't age the install by itself. */ private _isYoungInstall(): boolean { const objs = this._objects.filter((o) => !o.object?.battery_fleet); const tasks = objs.reduce((n, o) => n + o.tasks.length, 0); return objs.length < 3 && tasks < 8; } private _gsDismissed(): Set { try { return new Set(JSON.parse(lsGet(LS_KEYS.gettingStartedDismissed) || "[]")); } catch { return new Set(); } } private _dismissGettingStarted(id: string): void { const next = this._gsDismissed(); next.add(id); try { lsSet(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ } this.requestUpdate(); } /** Discovery counts for the chips — fetched ONCE per panel life and ONLY * while the install is young, so mature installs pay nothing. */ private _maybeLoadGettingStarted(): void { if (this._gsLoaded || !this._isYoungInstall()) return; this._gsLoaded = true; this.hass.connection .sendMessagePromise<{ setups: unknown[] }>({ type: "maintenance_supporter/integration_setups/discover" }) .then((r) => { this._gsSetupsCount = (r.setups || []).length; }) .catch(() => { this._gsSetupsCount = 0; }); this.hass.connection .sendMessagePromise<{ sensors: unknown[] }>({ type: "maintenance_supporter/problem_sensors/discover" }) .then((r) => { this._gsAdoptCount = (r.sensors || []).length; }) .catch(() => { this._gsAdoptCount = 0; }); } private _renderGettingStartedChips(L: string) { const dismissed = this._gsDismissed(); const young = this._isYoungInstall(); const chips: { id: string; icon: string; text: string; run: () => void }[] = []; if (young && this._gsSetupsCount > 0 && !dismissed.has("setups")) { chips.push({ id: "setups", icon: "mdi:auto-fix", text: t("gs_setups_chip", L).replace("{n}", String(this._gsSetupsCount)), run: () => this._openSuggestedSetups(), }); } if (young && this._gsAdoptCount > 0 && !dismissed.has("adopt")) { chips.push({ id: "adopt", icon: "mdi:alert-circle-check-outline", text: t("gs_adopt_chip", L).replace("{n}", String(this._gsAdoptCount)), run: () => this._openAdoptProblemSensors(), }); } if (this._batteryFleetSetupAvailable && !dismissed.has("fleet")) { chips.push({ id: "fleet", icon: "mdi:battery-sync", text: t("gs_fleet_chip", L), run: () => this._setupBatteryFleet(), }); } if (chips.length === 0) return nothing; return html`
${t("gs_label", L)}
${chips.map((c) => html`
c.run()}> ${c.text} { e.stopPropagation(); this._dismissGettingStarted(c.id); }}>
`)}
`; } private _toggleObjMenu(): void { this._togglePopup(() => this._objMenuOpen, (v) => { this._objMenuOpen = v; }); } private _closeObjMenu(): void { this._objMenuOpen = false; } private _toggleMoreMenu(): void { this._togglePopup(() => this._moreMenuOpen, (v) => { this._moreMenuOpen = v; }); } private _closeMoreMenu(): void { this._moreMenuOpen = false; } private get _sparklineCtx(): SparklineContext { return { lang: this._lang, detailStatsData: this._detailStatsData, hasStatsService: !!this._statsService, historyFallbackIds: this._statsService?.historyFallbackIds, isCounterEntity: (tc) => this._isCounterEntity(tc), rangeDays: this._chartRangeDays, setRangeDays: (days) => this._setChartRange(days), hideOutliers: this._hideOutliers, setHideOutliers: (hide) => this._setHideOutliers(hide), }; } private _toggleSection(key: string): void { const next = new Set(this._collapsedSections); if (next.has(key)) next.delete(key); else next.add(key); this._collapsedSections = next; try { lsSet(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ } } /** Build the context the history renderers need from panel state. */ private _historyCtx(): HistoryContext { // v2.20 (#83): reading tasks show value + delta per entry. The delta is // against the chronologically PREVIOUS entry that carries a reading. const task = this._selectedEntryId && this._selectedTaskId ? this._getObject(this._selectedEntryId)?.tasks.find((tk) => tk.id === this._selectedTaskId) : undefined; // Payload diet: the summary's history is truncated to the recent window — // the reading DELTAS must come from the full record (the oldest visible // reading would otherwise lose or falsify its delta), which // _fetchFullHistory loads for the open task. const fh = this._fullHistory; const fullHistory = fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task?.history || []).length ? fh.entries : task?.history || []; const readings = fullHistory .filter((h) => h.reading_value != null) .sort((a, b) => a.timestamp.localeCompare(b.timestamp)); return { lang: this._lang, hass: this.hass, filter: this._historyFilter, search: this._historySearch, currencySymbol: this._currencySymbol, setFilter: (f) => { this._historyFilter = f; }, setSearch: (s) => { this._historySearch = s; }, openEdit: (entry) => this._openHistoryEdit(entry), readingUnit: task?.reading_unit ?? null, taskRef: taskRef(this._selectedEntryId ? this._getObject(this._selectedEntryId)?.object : null, task), // #139: name the phase badge on entries stamped with a phase_id. phaseNames: Object.fromEntries( Object.entries(task?.phases || {}).map(([id, def]) => [id, def.name]), ), readingDelta: (entry) => { const idx = readings.findIndex((h) => h.timestamp === entry.timestamp); if (idx <= 0) return null; return (entry.reading_value as number) - (readings[idx - 1].reading_value as number); }, // #161 phase 2: per-slot delta against the previous entry that // carried the same slot (a skipped meter does not break the chain). readingSlotDelta: (entry, slotId) => readingSlotDelta(fullHistory, entry, slotId), }; } /** Build the context the task-detail renderers need from panel state. * Dialog-opening callbacks stay panel-side (the dialogs live in THIS * shadow root), so the extracted module never touches a dialog itself. */ private _taskDetailCtx(): TaskDetailContext { const entryId = this._selectedEntryId!; const taskId = this._selectedTaskId!; const obj = this._getObject(entryId); return { lang: this._lang, hass: this.hass, entryId, taskId, objectName: obj?.object.name || "", taskRef: taskRef(obj?.object, obj?.tasks.find((tk) => tk.id === taskId)), objectDocUrl: obj?.object?.documentation_url ?? null, objectManualDocs: obj?.object?.manual_docs ?? [], openManualDoc: (doc) => this._openManualDoc(doc), setChecklistItem: (item, done) => this._setChecklistItem(entryId, taskId, item, done), setPhaseCursor: (cursor) => { void this._runAction({ type: "maintenance_supporter/task/set_phase", entry_id: entryId, task_id: taskId, cursor, }); }, isOperator: this._isOperator, actionLoading: this._actionLoading, moreMenuOpen: this._moreMenuOpen, activeTab: this._activeTab, features: this._features, currencySymbol: this._currencySymbol, collapsedSections: this._collapsedSections, costDurationToggle: this._costDurationToggle, suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`), sparkline: this._sparklineCtx, history: this._historyCtx(), getUserName: (id) => this._userService?.getUserName(id) ?? null, getPerson: (id) => this._userService?.getPerson(id) ?? null, setActiveTab: (tab) => { this._activeTab = tab; }, toggleSection: (key) => this._toggleSection(key), setCostDurationToggle: (v) => { this._costDurationToggle = v; }, showTaskView: () => this._showFullTaskPage(entryId, taskId), showObject: () => this._showObject(entryId), toggleMoreMenu: () => this._toggleMoreMenu(), closeMoreMenu: () => this._closeMoreMenu(), openEdit: (tk) => { void this._ui("maintenance-task-dialog").then((d) => d?.openEdit(entryId, tk)); }, openComplete: (tk) => this._openCompleteDialog(entryId, taskId, tk.name, this._features.checklists ? tk.checklist : undefined, this._features.adaptive && !!tk.adaptive_config?.enabled), promptSkip: () => this._promptSkipTask(entryId, taskId), toggleArchive: (archived) => this._toggleArchiveTask(entryId, taskId, archived), openQr: (taskName) => this._openQrForTask(entryId, taskId, obj?.object.name || "", taskName), duplicateTask: () => this._duplicateTask(entryId, taskId), moveTask: () => this._moveTask(entryId, taskId), promptReset: () => this._promptResetTask(entryId, taskId), promptPostpone: () => this._promptPostponeTask(entryId, taskId), snoozeTask: () => this._snoozeTask(entryId, taskId), printWorksheet: () => this._printTaskWorksheet(entryId, taskId), deleteTask: () => this._deleteTask(entryId, taskId), applySuggestion: (interval) => this._applySuggestion(entryId, taskId, interval), reanalyze: () => this._reanalyzeInterval(entryId, taskId), dismissSuggestion: () => this._dismissSuggestion(entryId, taskId), openSeasonalOverrides: (tk) => this._openSeasonalOverrides(tk), }; } /** Full history for the OPEN task (list payloads are truncated to the * most recent window). null until loaded; a failure — e.g. an older * backend without `task/history` — falls back to the truncated list. */ @state() private _fullHistory: { entryId: string; taskId: string; entries: HistoryEntry[] } | null = null; private async _fetchFullHistory(entryId: string, taskId: string): Promise { try { const res = (await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/history", entry_id: entryId, task_id: taskId, })) as { history: HistoryEntry[] }; if (this._selectedEntryId === entryId && this._selectedTaskId === taskId) { this._fullHistory = { entryId, taskId, entries: res.history || [] }; } } catch { this._fullHistory = null; } } private _renderTaskDetail() { if (!this._selectedEntryId || !this._selectedTaskId) return nothing; const task = this._getTask(this._selectedEntryId, this._selectedTaskId); if (!task) return html`

Task not found.

`; const fh = this._fullHistory; const detailTask = fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task.history || []).length ? { ...task, history: fh.entries } : task; return html``; } /** v2.2.0: open the in-place history-edit dialog for the given entry. * After save, the dialog fires `history-entry-saved` which we listen * for in the template to trigger a data refresh. */ private _openHistoryEdit(entry: HistoryEntry): void { if (!this._selectedEntryId || !this._selectedTaskId) return; const editTask = this._getTask(this._selectedEntryId, this._selectedTaskId); const draft: HistoryEntryDraft = buildHistoryEntryDraft(this._selectedEntryId, this._selectedTaskId, entry, editTask); this.shadowRoot ?.querySelector("maintenance-history-edit-dialog") ?.openEdit(draft); } /** Triggered by the dialog after a successful WS save. Refresh the loaded * data so the user sees the updated entry. */ private _onHistoryEntrySaved = async (): Promise => { await this._loadData(); }; static styles = [sharedStyles, panelStyles]; }