/** Maintenance Supporter — custom dashboard strategy. * * Generates a complete Lovelace dashboard from the integration's WS feed. * Honors a ``group_by`` config so YAML users can pick the layout: * * strategy: * type: custom:maintenance-supporter * group_by: area # default — one view per area + Unassigned * * strategy: * type: custom:maintenance-supporter * group_by: status # one view per status (Overdue / Triggered / Due Soon / OK) * * strategy: * type: custom:maintenance-supporter * group_by: floor # one view per floor with area sub-headings (#155 follow-up) * * strategy: * type: custom:maintenance-supporter * group_by: due_date # 5 buckets — Overdue / Today / This Week / This Month / Later * * Layout pattern follows HA's own ``maintenance-view-strategy``, * ``areas-dashboard-strategy`` and ``home-overview-view-strategy``: a leading * "Overview" view of actionable tasks, empty groups skipped, and * STATE_NOT_RUNNING / recovery_mode handled with the same starting / * recovery-mode card placeholders HA core uses. * * Requires Home Assistant 2026.5+ to appear in the "Add Dashboard" picker * (that's when ``window.customStrategies`` got picked up by the frontend). * On older HA versions the registration is a silent no-op. */ import { STATUS_ICONS } from "./status-constants"; interface MaintenanceObjectResp { entry_id: string; object: { id: string; name: string; area_id: string | null; }; tasks: Array<{ status?: string; days_until_due?: number | null }>; } interface AreaEntry { area_id: string; name: string; icon?: string | null; floor_id?: string | null; } interface FloorEntry { floor_id: string; name: string; icon?: string | null; level?: number | null; } interface HassLike { language?: string; config?: { state?: string; recovery_mode?: boolean; }; connection: { sendMessagePromise(msg: Record): Promise; }; areas?: Record; floors?: Record; } type GroupBy = "area" | "status" | "floor" | "due_date" | "calendar"; interface MaintenanceDashboardStrategyConfig { type: "custom:maintenance-supporter" | "maintenance-supporter"; group_by?: GroupBy; } interface DashboardConfig { title?: string; views: ViewConfig[]; } interface ViewConfig { title?: string; path?: string; icon?: string; type?: string; subview?: boolean; cards?: CardConfig[]; sections?: SectionConfig[]; // HA 2026.5+ — banner card above the view (mobile + desktop), and a // narrow column on the right (large screens only). See // home-overview-view-strategy.ts for the canonical usage. header?: { layout?: string; card: CardConfig }; sidebar?: { sections: SectionConfig[]; visibility?: ViewColumnsCondition[]; content_label?: string; sidebar_label?: string; }; max_columns?: number; } interface ViewColumnsCondition { condition: "view_columns"; min?: number; max?: number; } interface SectionConfig { type?: string; cards: CardConfig[]; } interface CardConfig { type: string; [key: string]: unknown; } const STRATEGY_TYPE = "maintenance-supporter"; const STRATEGY_TAG = `ll-strategy-dashboard-${STRATEGY_TYPE}`; const EDITOR_TAG = "hui-maintenance-supporter-strategy-editor"; const STATE_NOT_RUNNING = "NOT_RUNNING"; const STATUS_VIEWS: Array<{ status: string; title: string; icon: string; path: string; }> = [ // Icons come from the shared STATUS_ICONS so the generated dashboard // matches the panel (this table used to hardcode its own set — "Overdue" // wore the panel's due-soon icon). { status: "overdue", title: "Overdue", icon: STATUS_ICONS.overdue, path: "overdue" }, { status: "triggered", title: "Triggered", icon: STATUS_ICONS.triggered, path: "triggered" }, { status: "due_soon", title: "Due Soon", icon: STATUS_ICONS.due_soon, path: "due-soon" }, { status: "ok", title: "OK", icon: STATUS_ICONS.ok, path: "ok" }, ]; const DUE_DATE_VIEWS: Array<{ title: string; icon: string; path: string; filter: { filter_due_min_days?: number; filter_due_max_days?: number }; // Pre-check predicate so we can skip empty buckets without a card render. matches: (days: number) => boolean; }> = [ { title: "Overdue", icon: "mdi:alert-circle", path: "overdue", filter: { filter_due_max_days: -1 }, matches: (d) => d <= -1, }, { title: "Today", icon: "mdi:calendar-today", path: "today", filter: { filter_due_min_days: 0, filter_due_max_days: 0 }, matches: (d) => d === 0, }, { title: "This Week", icon: "mdi:calendar-week", path: "this-week", filter: { filter_due_min_days: 1, filter_due_max_days: 7 }, matches: (d) => d >= 1 && d <= 7, }, { title: "This Month", icon: "mdi:calendar-month", path: "this-month", filter: { filter_due_min_days: 8, filter_due_max_days: 30 }, matches: (d) => d >= 8 && d <= 30, }, { title: "Later", icon: "mdi:calendar-clock", path: "later", filter: { filter_due_min_days: 31 }, matches: (d) => d >= 31, }, ]; function makeCardSection(card: CardConfig): SectionConfig { return { type: "grid", cards: [card] }; } // Replicate HA core's responsive conditions (see panels/lovelace/strategies/ // helpers/view-columns-conditions.ts). They use the "view_columns" condition // rather than raw media queries so they follow whatever breakpoint the user's // view actually has. const LARGE_SCREEN_CONDITION: ViewColumnsCondition = { condition: "view_columns", min: 2, }; const SMALL_SCREEN_CONDITION: ViewColumnsCondition = { condition: "view_columns", max: 1, }; // Headline counts come from the global summary sensors — the single source of // truth shared with the panel KPI chips and the statistics WS endpoint — so the // numbers can't drift. Rendered live via a markdown template instead of being // counted client-side at generation time (which froze them until reload). // (#86) Entity ids come registry-resolved from the statistics WS: the // documented sensor.maintenance_supporter_ ids are NOT guaranteed — // a user rename, a _2 collision suffix, or a pre-pinning install that // registered localized ids all made the hardcoded templates read "unknown". type SummaryKey = "overdue" | "triggered" | "due_soon" | "ok"; type SummaryEntityIds = Partial>; type SummaryCounts = Partial>; // One chip's value: prefer the live summary sensor (reactive template) when its // entity exists, but fall back to the literal count from the statistics WS when // it doesn't. The summary sensors only exist while the global "Maintenance // Supporter" entry is set up; if it was deleted (orphan object entries — #86, // 2nd report) the templated `states()` reads "unknown". The count from the WS // is always authoritative, so the chips stay correct either way. function kpiValue(key: SummaryKey, ids: SummaryEntityIds, counts: SummaryCounts): string { const id = ids[key]; if (id) return `{{ states('${id}') }}`; const n = counts[key]; return n === undefined || n === null ? "—" : String(n); } export function kpiMarkdownCard(ids: SummaryEntityIds = {}, counts: SummaryCounts = {}): CardConfig { return { type: "markdown", text_only: true, content: [ `🔴 **${kpiValue("overdue", ids, counts)}** overdue`, `⚡ **${kpiValue("triggered", ids, counts)}** triggered`, `🟡 **${kpiValue("due_soon", ids, counts)}** due soon`, `🟢 **${kpiValue("ok", ids, counts)}** ok`, ].join(" · "), }; } // Onboarding card shown when there are zero maintenance objects. Pattern // follows HA core's home-overview-view-strategy empty-state branch. // // Two buttons: // • "Open Maintenance panel" — plain navigate, the dependable path. // • "Add object" — fire-dom-event with a custom ll-custom payload that // our document-level handler (see registerLlCustomHandler below) catches // and turns into a deep-link navigation. This demonstrates the fire-dom // pattern HA core uses for in-place actions, without requiring us to // rip our object-add dialog out of the panel into a custom element. function emptyStateView(): ViewConfig { return { title: "Maintenance", type: "panel", cards: [ { type: "empty-state", icon: "mdi:wrench-clock", icon_color: "primary", content_only: true, title: "No maintenance objects yet", content: "Open the Maintenance panel to add your first object — pool pump, HVAC filter, vehicle, anything that needs scheduled care.", buttons: [ { icon: "mdi:wrench", text: "Open Maintenance panel", appearance: "filled", variant: "brand", tap_action: { action: "navigate", navigation_path: "/maintenance-supporter", }, }, { icon: "mdi:plus", text: "Add object", appearance: "outlined", variant: "brand", tap_action: { action: "fire-dom-event", ll_custom: { type: "maintenance-supporter:add-object" }, }, }, ], }, ], }; } function overviewView(summaryIds: SummaryEntityIds = {}, counts: SummaryCounts = {}): ViewConfig { const kpiCard = kpiMarkdownCard(summaryIds, counts); return { title: "Overview", icon: "mdi:wrench-clock", path: "overview", type: "sections", // Header runs above the main content — present on every screen size // so the user always sees the headline counts. Layout "responsive" // matches HA core's home strategy. header: { layout: "responsive", card: kpiCard }, // Sidebar appears only on screens wide enough for ≥2 columns. Mirrors // the same KPI card so power users with a wide dashboard never lose // sight of it as they scroll long lists. sidebar: { sections: [ { type: "grid", cards: [ { type: "heading", heading: "Status", heading_style: "title", }, kpiCard, ], }, ], visibility: [LARGE_SCREEN_CONDITION], content_label: "Tasks", sidebar_label: "Status", }, sections: [ makeCardSection({ type: "custom:maintenance-supporter-card", show_header: false, filter_status: ["overdue", "triggered", "due_soon"], }), ], }; } function viewsByArea( objects: MaintenanceObjectResp[], areas: Record, ): ViewConfig[] { const byArea = new Map(); for (const obj of objects) { const aid = obj.object.area_id || null; if (!byArea.has(aid)) byArea.set(aid, []); byArea.get(aid)!.push(obj); } const areaIds = Array.from(byArea.keys()).filter( (a): a is string => a !== null, ); areaIds.sort((a, b) => { const na = areas[a]?.name || a; const nb = areas[b]?.name || b; return na.localeCompare(nb); }); const views: ViewConfig[] = []; for (const areaId of areaIds) { const objs = byArea.get(areaId)!; if (objs.length === 0) continue; const areaInfo = areas[areaId]; views.push({ title: areaInfo?.name || areaId, icon: areaInfo?.icon || "mdi:floor-plan", path: `area-${areaId}`, type: "sections", sections: [ makeCardSection({ type: "custom:maintenance-supporter-card", show_header: false, filter_objects: objs.map((o) => o.object.name), }), ], }); } const unassigned = byArea.get(null); if (unassigned && unassigned.length > 0) { views.push({ title: "Unassigned", icon: "mdi:help-circle-outline", path: "unassigned", type: "sections", sections: [ makeCardSection({ type: "custom:maintenance-supporter-card", show_header: false, filter_objects: unassigned.map((o) => o.object.name), }), ], }); } return views; } function viewsByStatus(objects: MaintenanceObjectResp[]): ViewConfig[] { const present = new Set(); for (const obj of objects) { for (const task of obj.tasks || []) { if (task.status) present.add(task.status); } } const views: ViewConfig[] = []; for (const v of STATUS_VIEWS) { if (!present.has(v.status)) continue; views.push({ title: v.title, icon: v.icon, path: v.path, type: "sections", sections: [ makeCardSection({ type: "custom:maintenance-supporter-card", show_header: false, filter_status: [v.status], }), ], }); } return views; } function viewsByFloor( objects: MaintenanceObjectResp[], areas: Record, floors: Record, ): ViewConfig[] { // Build {floor_id → [object…]} via area.floor_id lookup; null bucket for // objects whose area has no floor OR whose object has no area at all. const byFloor = new Map(); for (const obj of objects) { const aid = obj.object.area_id; const fid = aid ? areas[aid]?.floor_id || null : null; if (!byFloor.has(fid)) byFloor.set(fid, []); byFloor.get(fid)!.push(obj); } // Floor sort: by `level` then name (matches HA's own floor sorting). const floorIds = Array.from(byFloor.keys()).filter( (f): f is string => f !== null, ); floorIds.sort((a, b) => { const fa = floors[a]; const fb = floors[b]; const la = fa?.level ?? 0; const lb = fb?.level ?? 0; if (la !== lb) return la - lb; return (fa?.name || a).localeCompare(fb?.name || b); }); const views: ViewConfig[] = []; for (const floorId of floorIds) { const objs = byFloor.get(floorId)!; if (objs.length === 0) continue; const floor = floors[floorId]; views.push({ title: floor?.name || floorId, icon: floor?.icon || "mdi:home-floor-1", path: `floor-${floorId}`, type: "sections", sections: [ makeCardSection({ type: "custom:maintenance-supporter-card", show_header: false, filter_objects: objs.map((o) => o.object.name), }), ], }); } const unassigned = byFloor.get(null); if (unassigned && unassigned.length > 0) { views.push({ title: "Other", icon: "mdi:help-circle-outline", path: "other", type: "sections", sections: [ makeCardSection({ type: "custom:maintenance-supporter-card", show_header: false, filter_objects: unassigned.map((o) => o.object.name), }), ], }); } return views; } function viewsByCalendar(_objects: MaintenanceObjectResp[]): ViewConfig[] { // One view per window. Each is a panel-typed view with a single // ``maintenance-supporter-calendar`` card pinned to the matching // window_days. Window-chips inside the card are hidden because the // tab-bar already serves as the window selector. const WINDOWS: Array<{ title: string; icon: string; path: string; window_days: 7 | 14 | 30 | 365; }> = [ { title: "Week", icon: "mdi:calendar-week", path: "cal-7", window_days: 7 }, { title: "Fortnight", icon: "mdi:calendar-week-begin", path: "cal-14", window_days: 14 }, { title: "Month", icon: "mdi:calendar-month", path: "cal-30", window_days: 30 }, { title: "Year", icon: "mdi:calendar-clock", path: "cal-365", window_days: 365 }, ]; return WINDOWS.map((w) => ({ title: w.title, icon: w.icon, path: w.path, type: "panel", cards: [ { type: "custom:maintenance-supporter-calendar-card", window_days: w.window_days, show_window_chips: false, show_user_filter: true, }, ], })); } function viewsByDueDate(objects: MaintenanceObjectResp[]): ViewConfig[] { // Bucket-presence pre-check so we don't render an empty "Today" tab. const presentBuckets = new Set(); for (const obj of objects) { for (const task of obj.tasks || []) { const d = task.days_until_due; if (d === null || d === undefined) continue; DUE_DATE_VIEWS.forEach((v, i) => { if (v.matches(d)) presentBuckets.add(i); }); } } const views: ViewConfig[] = []; DUE_DATE_VIEWS.forEach((v, i) => { if (!presentBuckets.has(i)) return; views.push({ title: v.title, icon: v.icon, path: v.path, type: "sections", sections: [ makeCardSection({ type: "custom:maintenance-supporter-card", show_header: false, ...v.filter, }), ], }); }); return views; } export class MaintenanceDashboardStrategy extends HTMLElement { static getCreateSuggestions(_hass: HassLike) { return { title: "Maintenance Supporter", icon: "mdi:wrench-clock", }; } // Lazy-load editor so picker users who never hit "Edit Dashboard" don't // pay for the LitElement bundle. HA's areas-dashboard-strategy uses the // same pattern. static async getConfigElement() { // The editor element is registered in the same bundle below — just // construct it. The dynamic import would work too, but with esbuild // bundling everything into one file there's nothing extra to load. return document.createElement(EDITOR_TAG); } static async generate( config: MaintenanceDashboardStrategyConfig | undefined, hass: HassLike, ): Promise { if (hass.config?.state === STATE_NOT_RUNNING) { return { views: [ { type: "sections", sections: [{ cards: [{ type: "starting" }] }] }, ], }; } if (hass.config?.recovery_mode) { return { views: [ { type: "sections", sections: [{ cards: [{ type: "recovery-mode" }] }], }, ], }; } let response: { objects: MaintenanceObjectResp[] }; try { response = await hass.connection.sendMessagePromise<{ objects: MaintenanceObjectResp[]; }>({ type: "maintenance_supporter/objects" }); } catch { return { title: "Maintenance", views: [ { title: "Maintenance", cards: [ { type: "markdown", content: "**Maintenance Supporter** is not loaded. Install/enable the integration first.", }, ], }, ], }; } const objects = response.objects || []; // Onboarding short-circuit. With zero objects the area / status / floor / // due_date branches all produce just the Overview view (which shows // "no tasks") — that's a useless first impression. Show an actionable // empty-state instead so the user knows where to click next. if (objects.length === 0) { return { title: "Maintenance", views: [emptyStateView()] }; } const groupBy: GroupBy = config?.group_by ?? "area"; // Record-of-builders pattern from HA core's home-overview-view-strategy // — each entry is a no-arg lambda producing the per-mode views. Extending // with a new group_by mode means adding one entry instead of growing an // if/else chain. const viewBuilders: Record ViewConfig[]> = { area: () => viewsByArea(objects, hass.areas || {}), status: () => viewsByStatus(objects), floor: () => viewsByFloor(objects, hass.areas || {}, hass.floors || {}), due_date: () => viewsByDueDate(objects), calendar: () => viewsByCalendar(objects), }; // Registry-resolved summary-sensor ids + live counts (#86); tolerate older // backends. The counts are the fallback the chips render when the summary // sensors don't exist (orphan object entries after the global entry was // deleted), so they never read "unknown". let summaryIds: SummaryEntityIds = {}; let counts: SummaryCounts = {}; try { const stats = await hass.connection.sendMessagePromise< { summary_entity_ids?: SummaryEntityIds } & SummaryCounts >({ type: "maintenance_supporter/statistics", }); summaryIds = stats.summary_entity_ids || {}; counts = { overdue: stats.overdue, triggered: stats.triggered, due_soon: stats.due_soon, ok: stats.ok, }; } catch { /* fall back to the documented default ids */ } return { title: "Maintenance", views: [ overviewView(summaryIds, counts), ...(viewBuilders[groupBy] ?? viewBuilders.area)(), ], }; } } // ── Editor ────────────────────────────────────────────────────────────────── // // Minimal LovelaceStrategyEditor: a single dropdown for ``group_by``. Pattern // follows HA core's ``hui-areas-dashboard-strategy-editor`` — LitElement that // exposes setConfig(), holds the current config in @state, and dispatches // "config-changed" with the new config on user input. // // We keep the editor as a plain HTMLElement (no Lit dependency) because the // strategy file otherwise stays Lit-free. It's enough HTML for one ${options}
The "Overview" view is always first. Empty groups are skipped.
`; const select = this.querySelector("#group-by") as HTMLSelectElement | null; select?.addEventListener("change", () => { const newConfig: MaintenanceDashboardStrategyConfig = { ...this._config, group_by: select.value as GroupBy, }; this._config = newConfig; this.dispatchEvent( new CustomEvent("config-changed", { detail: { config: newConfig }, bubbles: true, composed: true, }), ); }); } } // ── Section Strategy ──────────────────────────────────────────────────────── // // A standalone section users can drop into ANY view (HA's home dashboard, the // areas dashboard's per-area view, a custom dashboard) to surface a slice of // maintenance tasks in context. Mirrors HA core's ``common-controls`` section // strategy pattern. YAML usage: // // sections: // - strategy: // type: custom:maintenance-supporter-section // area_id: kitchen # optional — restrict to one area // filter_status: # optional — restrict to certain statuses // - overdue // - triggered // title: Kitchen upkeep # optional heading card // max_items: 5 # optional row limit // // At least one filter is recommended; with no filters it shows everything, // which is what the dashboard strategy already does. const SECTION_STRATEGY_TYPE = "maintenance-supporter-section"; const SECTION_STRATEGY_TAG = `ll-strategy-section-${SECTION_STRATEGY_TYPE}`; interface MaintenanceSectionStrategyConfig { type: "custom:maintenance-supporter-section" | "maintenance-supporter-section"; area_id?: string; filter_status?: string[]; filter_objects?: string[]; filter_due_min_days?: number; filter_due_max_days?: number; title?: string; max_items?: number; } class MaintenanceSectionStrategy extends HTMLElement { static async generate( config: MaintenanceSectionStrategyConfig | undefined, hass: HassLike, ): Promise { const card: CardConfig = { type: "custom:maintenance-supporter-card", show_header: false, }; // Pass through optional filters if (config?.filter_status?.length) card.filter_status = config.filter_status; if (config?.max_items) card.max_items = config.max_items; if (config?.filter_due_min_days !== undefined) { card.filter_due_min_days = config.filter_due_min_days; } if (config?.filter_due_max_days !== undefined) { card.filter_due_max_days = config.filter_due_max_days; } // Resolve area_id → object names via WS (the card filters by name). // Falls back gracefully if the WS call fails — empty filter means // "show everything", which still renders something useful. let names: string[] | undefined = config?.filter_objects; if (config?.area_id && !names) { try { const r = await hass.connection.sendMessagePromise<{ objects: MaintenanceObjectResp[]; }>({ type: "maintenance_supporter/objects" }); names = (r.objects || []) .filter((o) => o.object.area_id === config.area_id) .map((o) => o.object.name); } catch { // ignore — card will show all } } if (names && names.length > 0) card.filter_objects = names; const cards: CardConfig[] = []; if (config?.title) { cards.push({ type: "heading", heading: config.title, heading_style: "title", }); } cards.push(card); return { type: "grid", cards }; } } // v2.3.5 (issue #52 follow-up): wrap registration in try/catch + emit a // banner-style console.log so that when ANY user hits a strategy timeout // we can see in the browser console exactly which step failed instead of // guessing. The non-error console.log also serves as a positive load // signal — if the user doesn't see "[maintenance-supporter] strategy // bundle loaded" at all, the script never ran (cached index.html / CSP / // MIME-type problem). If they see the log but still get the timeout, // something else (Lovelace resource conflict, HACS plugin clash) is // holding the registry. const STRATEGY_VERSION = "2.3.6"; const LOG_PREFIX = "[maintenance-supporter]"; function safeDefine(tag: string, ctor: CustomElementConstructor): "ok" | "skipped" | string { try { if (customElements.get(tag)) { return "skipped"; } customElements.define(tag, ctor); return "ok"; } catch (err) { return `ERROR: ${String(err)}`; } } // NOTE: the dashboard tag (STRATEGY_TAG) is intentionally NOT defined here. // It is owned by the tiny zero-import shim (maintenance-strategy-shim.ts), // which is what HA loads via frontend_extra_module_url so registration wins // HA's 5 s whenDefined race even under heavy plugin load. This heavy bundle is // lazy-loaded by the shim on first generate()/getConfigElement(); the shim // calls our exported MaintenanceDashboardStrategy.generate(). We still define // the editor + section strategies here (not on HA's timeout-critical path). const _defineResults = { editor: safeDefine(EDITOR_TAG, MaintenanceStrategyEditor), section: safeDefine(SECTION_STRATEGY_TAG, MaintenanceSectionStrategy), }; console.log( `${LOG_PREFIX} strategy bundle v${STRATEGY_VERSION} loaded — registrations:`, _defineResults, "— run maintenanceSupporterDiagnose() in console for full state", ); // Global devtools helper — typing maintenanceSupporterDiagnose() in the // browser console returns a snapshot of registration state + module URLs // + Lovelace resources. Easier than asking users to paste a multi-line // snippet they then can't run for whatever reason. (window as unknown as { maintenanceSupporterDiagnose?: () => Promise }).maintenanceSupporterDiagnose = async () => { const tag = STRATEGY_TAG; let resources: Array<{ url?: string }> = []; try { const ha = document.querySelector("home-assistant") as | (HTMLElement & { hass?: { connection: { sendMessagePromise(m: Record): Promise } } }) | null; if (ha?.hass) { const r = await ha.hass.connection.sendMessagePromise<{ resources?: Array<{ url?: string }> }>( { type: "lovelace/resources" }, ); resources = r.resources || []; } } catch (e) { resources = [{ url: `` }]; } return { version: STRATEGY_VERSION, el_registered: !!customElements.get(tag), el_class_excerpt: customElements.get(tag)?.toString().slice(0, 100) || null, customStrategies_dashboard: (window as unknown as { customStrategies?: Array<{ type: string; strategyType: string }> }) .customStrategies?.filter((s) => s.strategyType === "dashboard").map((s) => s.type) || [], scriptTagsInPage: Array.from(document.querySelectorAll("script[type=module]")) .map((s) => (s as HTMLScriptElement).src) .filter((s) => /maint|strateg/i.test(s)), lovelaceResources_matching: resources.filter((r) => /maint|strateg/i.test(r.url || "")).map((r) => r.url), registrations: _defineResults, }; }; // ── Phase 5: Status Section-Strategies ────────────────────────────────────── // // Three small status sections so users with a Lovelace-only setup // can monitor vacation / budget / groups state without leaving the dashboard. // v2.4.0 update: each strategy now delegates to a live interactive custom // card (vacation-section-card / budget-section-card / groups-section-card) // that lets admins edit values inline. // // v2.3.4 (issue #52): the section-card modules are LAZY-loaded inside // generate() — bundling them at module top blew the strategy file up to // 509 KB, and HA's 5 s strategy-load timeout was firing on slow connections // before customElements.define for ll-strategy-dashboard-* could run. Now // the strategy bundle is tiny (~10 KB) and registers instantly; the heavy // section-card code only loads when a section actually renders. class MaintenanceVacationSectionStrategy extends HTMLElement { static async generate( _config: { type: string; title?: string }, _hass: HassLike, ): Promise { await import("./components/vacation-section-card"); return { type: "grid", cards: [ { type: "custom:maintenance-vacation-section-card", title: _config?.title, }, ], }; } } class MaintenanceBudgetSectionStrategy extends HTMLElement { static async generate( _config: { type: string; title?: string }, _hass: HassLike, ): Promise { await import("./components/budget-section-card"); return { type: "grid", cards: [ { type: "custom:maintenance-budget-section-card", title: _config?.title, }, ], }; } } class MaintenanceGroupsSectionStrategy extends HTMLElement { static async generate( _config: { type: string; title?: string }, _hass: HassLike, ): Promise { await import("./components/groups-section-card"); return { type: "grid", cards: [ { type: "custom:maintenance-groups-section-card", title: _config?.title, }, ], }; } } // Register the three section strategies const VACATION_TAG = "ll-strategy-section-maintenance-supporter-vacation"; const BUDGET_TAG = "ll-strategy-section-maintenance-supporter-budget"; const GROUPS_TAG = "ll-strategy-section-maintenance-supporter-groups"; if (!customElements.get(VACATION_TAG)) customElements.define(VACATION_TAG, MaintenanceVacationSectionStrategy); if (!customElements.get(BUDGET_TAG)) customElements.define(BUDGET_TAG, MaintenanceBudgetSectionStrategy); if (!customElements.get(GROUPS_TAG)) customElements.define(GROUPS_TAG, MaintenanceGroupsSectionStrategy); // ── fire-dom-event handler ────────────────────────────────────────────────── // // HA's standard ``tap_action: { action: "fire-dom-event", ll_custom: {...} }`` // dispatches an ``ll-custom`` CustomEvent on the tapped element. We register // one bubble-phase listener at document level that handles every payload // whose ``type`` is namespaced with ``maintenance-supporter:`` — currently: // // maintenance-supporter:add-object → in-place open of MaintenanceObjectDialog // maintenance-supporter:open-task → in-place open of MaintenanceTaskDialog // (with entry_id + task_id in detail) // // The dialog components are imported via dialog-mount.ts and mounted lazily // onto document.body the first time they are needed. If the in-place mount // fails (e.g. not yet ready), we fall back to deep-linking // the panel via ?ms_action=… so the user still ends up where they want. // v2.3.4 (issue #52): dialog-mount is heavy (pulls in 7 dialog components + // their Lit runtime). We only need it when the user actually triggers a // fire-dom-event from a strategy-generated card, so the import is dynamic // and runs the first time a maintenance-supporter:* event fires. type DialogMountModule = typeof import("./dialog-mount"); let _dialogMountPromise: Promise | null = null; function getDialogMount(): Promise { if (!_dialogMountPromise) { // Don't cache a REJECTED import: after a version bump the browser may hold a // stale strategy bundle whose hashed dialog-mount chunk 404s once; caching // that rejection would dead-end every dialog action until a full reload. // Clear the cache on failure so a later attempt (or fixed chunk) can retry. _dialogMountPromise = import("./dialog-mount").catch((err) => { _dialogMountPromise = null; throw err; }); } return _dialogMountPromise; } interface LlCustomEventDetail { type?: string; entry_id?: unknown; task_id?: unknown; [key: string]: unknown; } const LL_CUSTOM_HANDLER_FLAG = "_msSupporterLlCustomBound"; function deepLink(path: string): void { history.pushState(null, "", path); window.dispatchEvent(new CustomEvent("location-changed")); } function registerLlCustomHandler(): void { const w = window as unknown as Record; if (w[LL_CUSTOM_HANDLER_FLAG]) return; // idempotent — strategy file may load twice w[LL_CUSTOM_HANDLER_FLAG] = true; document.addEventListener("ll-custom", (event: Event) => { const raw = (event as CustomEvent).detail; if (!raw || typeof raw !== "object") return; // HA's `tap_action: { action: "fire-dom-event", ll_custom: {…} }` dispatches // `ll-custom` with the ENTIRE action config as `detail`, nesting our payload // under `.ll_custom` (verified live on HA 2026.6 — issue #69: "Add object" // did nothing because we read `detail.type`, which was undefined). Direct // dispatchers (the calendar card, the panel) put the payload at the top // level. Accept both: prefer the nested `ll_custom` object when present. const nested = (raw as { ll_custom?: unknown }).ll_custom; const detail = ( nested && typeof nested === "object" ? nested : raw ) as LlCustomEventDetail; if (!detail || typeof detail.type !== "string") return; if (!detail.type.startsWith("maintenance-supporter:")) return; const action = detail.type.slice("maintenance-supporter:".length); if (action === "deep-link" && typeof detail.path === "string") { deepLink(detail.path); return; } // Everything else needs the heavy dialog-mount module — load it lazily. void (async () => { let dm: DialogMountModule; try { dm = await getDialogMount(); } catch { // dialog-mount chunk unavailable (e.g. stale bundle after an update) — // degrade to opening the panel instead of a silent, unhandled failure. deepLink("/maintenance-supporter"); return; } if (action === "add-object") { if (dm.openCreateObjectDialog()) return; deepLink("/maintenance-supporter?ms_action=add_object"); return; } if ( action === "open-task" && typeof detail.entry_id === "string" && typeof detail.task_id === "string" ) { if (dm.openTaskQuickActions(detail.entry_id, detail.task_id)) return; if (dm.openEditTaskDialog(detail.entry_id, detail.task_id)) return; deepLink( `/maintenance-supporter?entry_id=${encodeURIComponent(detail.entry_id)}` + `&task_id=${encodeURIComponent(detail.task_id)}`, ); return; } if (action === "open-object" && typeof detail.entry_id === "string") { if (dm.openObjectQuickActions(detail.entry_id)) return; deepLink(`/maintenance-supporter?entry_id=${encodeURIComponent(detail.entry_id)}`); return; } if ( action === "edit-history" && typeof detail.entry_id === "string" && typeof detail.task_id === "string" && typeof detail.original_timestamp === "string" ) { const haRoot = document.querySelector( "home-assistant", ) as (HTMLElement & { hass?: { connection: { sendMessagePromise(m: Record): Promise } } }) | null; const hass = haRoot?.hass; if (!hass) return; try { const r = await hass.connection.sendMessagePromise<{ tasks?: Array<{ id?: string; history?: Array> }>; }>({ type: "maintenance_supporter/object", entry_id: detail.entry_id, }); const task = r.tasks?.find((t) => t.id === detail.task_id); const histEntry = task?.history?.find( (h) => h.timestamp === detail.original_timestamp, ); if (!histEntry) return; dm.openHistoryEditDialog({ entry_id: detail.entry_id as string, task_id: detail.task_id as string, original_timestamp: detail.original_timestamp as string, type: (histEntry.type as string) || "completed", timestamp: (histEntry.timestamp as string) || (detail.original_timestamp as string), notes: (histEntry.notes as string) ?? null, cost: (histEntry.cost as number | undefined) ?? null, duration: (histEntry.duration as number | undefined) ?? null, completed_by: (histEntry.completed_by as string) ?? null, }); } catch { deepLink("/maintenance-supporter"); } } })(); }); } registerLlCustomHandler(); // Discovery — picked up by HA 2026.5+. Older HA ignores it silently. const w = window as unknown as { customStrategies?: Array<{ type: string; strategyType: string; name: string; description?: string; documentationURL?: string; }>; }; w.customStrategies = w.customStrategies || []; function registerStrategy(entry: { type: string; strategyType: string; name: string; description?: string; documentationURL?: string; }): void { const exists = w.customStrategies!.some( (s) => s.type === entry.type && s.strategyType === entry.strategyType, ); if (!exists) w.customStrategies!.push(entry); } registerStrategy({ type: STRATEGY_TYPE, strategyType: "dashboard", name: "Maintenance Supporter", description: "Auto-generated dashboard. Group views by area, status, floor, or due date — picked from the strategy editor or YAML.", documentationURL: "https://github.com/iluebbe/maintenance_supporter#dashboard-strategy", }); registerStrategy({ type: SECTION_STRATEGY_TYPE, strategyType: "section", name: "Maintenance Supporter — Section", description: "Embed maintenance tasks (filterable by area, status, due date) as a section in any dashboard view.", documentationURL: "https://github.com/iluebbe/maintenance_supporter#section-strategy", }); // Phase 5 status sections registerStrategy({ type: "maintenance-supporter-vacation", strategyType: "section", name: "Maintenance Supporter — Vacation Status", description: "Compact vacation-mode status banner. Tap to open settings in the panel.", }); registerStrategy({ type: "maintenance-supporter-budget", strategyType: "section", name: "Maintenance Supporter — Budget Status", description: "Monthly + yearly maintenance budget overview. Tap for details.", }); registerStrategy({ type: "maintenance-supporter-groups", strategyType: "section", name: "Maintenance Supporter — Groups", description: "List of configured task groups with member counts.", }); export {};