/** Maintenance Supporter Lovelace Card. */ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles"; import type { HomeAssistant, MaintenanceObjectResponse, MaintenanceTask, StatisticsResponse, CardConfig, SavedView, SavedViewFilters, } from "./types"; import { UserService } from "./user-service"; import { partsForCompletion } from "./helpers/shared-parts"; import "./maintenance-card-editor"; import "./components/complete-dialog"; import { openCreateObjectDialog, openCreateTaskDialog, openTaskQuickActions, } from "./dialog-mount"; interface CardDoc { id: string; title: string; kind: string; url?: string | null; } interface FlatTask { entry_id: string; object_name: string; task: MaintenanceTask; } export class MaintenanceSupporterCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _config: CardConfig = { type: "custom:maintenance-supporter-card" }; @state() private _objects: MaintenanceObjectResponse[] = []; @state() private _stats: StatisticsResponse | null = null; @state() private _unsub: (() => void) | null = null; @state() private _viewFilters: SavedViewFilters | null = null; /** id → display name for the assignee badge. Empty until users/list * resolves; the badge stays hidden rather than showing a raw uuid. */ @state() private _userNames: Record = {}; private _userService: UserService | null = null; private _userNamesLoaded = false; /** entry_id → (task_id → documents) for the row chips. */ @state() private _taskDocs: Record> = {}; private _docsLoadedFor = new Set(); private get _lang(): string { return this.hass?.language || "en"; } static getConfigElement() { return document.createElement("maintenance-supporter-card-editor"); } static getStubConfig() { // Opinionated default: when a user picks "Maintenance Supporter" from the // card picker, show only the actionable tasks (overdue + triggered + due // soon, max 10) — not the full task list which is overwhelming on first // add. The user can broaden the filter via the editor afterwards. return { type: "custom:maintenance-supporter-card", show_header: true, show_actions: true, filter_status: ["overdue", "triggered", "due_soon"], max_items: 10, }; } setConfig(config: CardConfig): void { const viewChanged = config.view_id !== this._config.view_id; this._config = config; // Config can change after the initial load (editor preview) — re-resolve // the referenced view then; the initial resolve happens in _loadData. if (viewChanged && this._dataLoaded && this.hass) { this._loadViewFilters(); } } getCardSize(): number { return 3; } private _dataLoaded = false; private _lastConnection: unknown = null; connectedCallback(): void { super.connectedCallback(); } disconnectedCallback(): void { super.disconnectedCallback(); if (this._unsub) { this._unsub(); this._unsub = null; } this._dataLoaded = false; this._lastConnection = null; } updated(changedProps: Map): void { super.updated(changedProps); // Dates/times follow the HA profile format, not just the language (#97). if (changedProps.has("hass")) setDateTimePrefs(this.hass?.locale); const lang = this.hass?.language; if (lang && !isLocaleLoaded(lang)) { ensureLocale(lang).then(() => this.requestUpdate()); } // Assignee names: resolved once, only when the config allows it AND some // task actually carries a responsible user. The condition is checked // synchronously here — an unconditional call whose promise triggered a // re-render looped the element forever. Assigning `_userNames` (@state) // is what re-renders once the list arrives. if ( this.hass && !this._userNamesLoaded && this._config.show_assignee !== false && this._objects.some((o) => o.tasks.some((tk) => tk.responsible_user_id)) ) { this._loadUserNames(); } // Documents: same shape of guard — fetch per object, once, and only for // objects that actually have a document attached to one of their tasks. if (this.hass && this._config.show_documents !== false) { for (const obj of this._objects) { if (this._docsLoadedFor.has(obj.entry_id)) continue; if (!obj.tasks.some((tk) => (tk.document_count ?? 0) > 0)) continue; this._loadDocuments(obj.entry_id); } } if (changedProps.has("hass") && this.hass) { if (!this._dataLoaded) { this._dataLoaded = true; this._lastConnection = this.hass.connection; this._loadData(); this._subscribe(); } else if (this.hass.connection !== this._lastConnection) { this._lastConnection = this.hass.connection; if (this._unsub) { try { this._unsub(); } catch { /* ignore */ } this._unsub = null; } this._subscribe(); this._loadData(); } } } private async _loadData(): Promise { try { const [objResult, statsResult] = await Promise.all([ this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects" }), this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }), ]); this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects; this._stats = statsResult as StatisticsResponse; } catch { // WS not available yet } await this._loadViewFilters(); } /** Display name of the task's responsible user, or "" when the badge must * stay hidden (feature off, nobody assigned, or the name not resolved). * With a rotation this is whoever is up next — the pointer the engine * advances on every completion. */ private _assigneeName(task: MaintenanceTask): string { if (this._config.show_assignee === false) return ""; const id = task.responsible_user_id; if (!id) return ""; return this._userNames[id] || ""; } /** Resolve display names for the assignee badge (best-effort). * * `users/list` is a READ-tier command, so the household members this card * is built for can call it without admin rights. A failure (or a task * whose user was deleted) leaves the name unresolved and the badge simply * does not render — never a raw user id. */ private async _loadUserNames(): Promise { this._userNamesLoaded = true; if (!this._userService) this._userService = new UserService(this.hass); else this._userService.updateHass(this.hass); try { const users = await this._userService.getUsers(); this._userNames = Object.fromEntries(users.map((u) => [u.id, u.name])); } catch { // leave whatever we had; the badge hides for unresolved ids } } /** Fetch one object's documents and index them by task. * * `documents/list` is READ tier, like `users/list` — the household members * this card is for may call it. A failure leaves the row without chips * rather than breaking the card. */ private async _loadDocuments(entryId: string): Promise { this._docsLoadedFor.add(entryId); try { const res = (await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/documents/list", entry_id: entryId, })) as { documents: Array }; const byTask: Record = {}; for (const doc of res.documents || []) { for (const taskId of doc.task_ids || []) { (byTask[taskId] ||= []).push({ id: doc.id, title: doc.title, kind: doc.kind, url: doc.url }); } } this._taskDocs = { ...this._taskDocs, [entryId]: byTask }; } catch { // no chips for this object; the card is unaffected otherwise } } /** Chips to render on a row: linked documents plus the task's own manual * link, which is the same "the manual is one tap away" affordance. */ private _docsFor(entryId: string, task: MaintenanceTask): CardDoc[] { if (this._config.show_documents === false) return []; const linked = this._taskDocs[entryId]?.[task.id] || []; const out = [...linked]; if (task.documentation_url) { out.push({ id: `url:${task.id}`, title: t("documentation_label", this._lang), kind: "weblink", url: task.documentation_url }); } return out; } /** Open a chip: a web link directly, a stored file through a signed path * (the same route the panel uses, so it works in the Companion app). */ private async _openDoc(doc: CardDoc): Promise { if (doc.kind === "weblink" && doc.url) { window.open(doc.url, "_blank", "noopener"); return; } const win = window.open("about:blank", "_blank"); try { const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ type: "auth/sign_path", path: `/api/maintenance_supporter/document/${doc.id}`, expires: 300, }); if (win) win.location.href = new URL(signed.path, window.location.origin).href; } catch { if (win) win.close(); } } /** Resolve the configured saved view's filters (best-effort). A missing or * deleted view degrades to "no view filter" — same fallback semantics as * the backend's notification routing, never an inexplicably empty card. */ private async _loadViewFilters(): Promise { if (!this._config.view_id) { this._viewFilters = null; return; } try { const res = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/views/list", }) as { views: SavedView[] }; const view = (res.views || []).find((v) => v.id === this._config.view_id); this._viewFilters = view ? view.filters : null; } catch { this._viewFilters = null; } } private async _subscribe(): Promise { try { const unsub = await this.hass.connection.subscribeMessage( (msg: unknown) => { const data = msg as { objects: MaintenanceObjectResponse[] }; this._objects = data.objects; }, { type: "maintenance_supporter/subscribe" } ); // Detached mid-subscribe → drop the orphaned subscription. if (!this.isConnected) { unsub(); return; } this._unsub = unsub; } catch { // Subscription failed } } private get _flatTasks(): FlatTask[] { const tasks: FlatTask[] = []; const { filter_status, filter_objects, entity_ids, filter_due_min_days, filter_due_max_days, filter_labels, filter_areas, max_items, } = this._config; const entityFilter = entity_ids?.length ? new Set(entity_ids) : null; const hasDueRange = filter_due_min_days !== undefined || filter_due_max_days !== undefined; // Saved-view scope: unlike the panel (where applying a view REPLACES the // filter state), the card ANDs the view's task-selecting filters with its // own config — the card's filters are static YAML, not transient UI state. // The view's `current_user` sentinel resolves against the logged-in user // here (client-side), which the backend notification routing cannot do. const vf = this._viewFilters; const viewUser = vf?.user_id === "current_user" ? (this.hass.user?.id ?? null) : (vf?.user_id ?? null); for (const obj of this._objects) { if (filter_objects?.length && !filter_objects.includes(obj.object.name)) continue; // Areas (C8): object-level, so it selects whole objects like // filter_objects does — "the tasks for this room". An object with no // area_id can never satisfy a non-empty list. if (filter_areas?.length) { const areaId = obj.object.area_id; if (!areaId || !filter_areas.includes(areaId)) continue; } for (const task of obj.tasks) { // Completed one-time tasks ("done") are hidden from the active list. if (task.is_done) continue; // v2.10.0: archived tasks (and tasks of an archived object) are inert — // never shown on the Lovelace card. if (task.archived || obj.object.archived) continue; if (filter_status?.length && !filter_status.includes(task.status)) continue; // Labels: a task passes when it carries at least one configured label. if (filter_labels?.length && !(task.labels || []).some((lb) => filter_labels.includes(lb))) continue; // entity_ids: HA-native filter — match the task's sensor or // binary_sensor entity_id. Both fields come pre-resolved from the // backend WS response (see _build_task_summary in websocket/__init__.py). if (entityFilter) { const matches = (task.sensor_entity_id && entityFilter.has(task.sensor_entity_id)) || (task.binary_sensor_entity_id && entityFilter.has(task.binary_sensor_entity_id)); if (!matches) continue; } // due-days range — used by group_by=due_date strategy buckets. // Tasks without a numeric days_until_due are excluded from any // ranged view; they show up in unfiltered or status-only views. if (hasDueRange) { const days = task.days_until_due; if (days === null || days === undefined) continue; if (filter_due_min_days !== undefined && days < filter_due_min_days) continue; if (filter_due_max_days !== undefined && days > filter_due_max_days) continue; } if (vf) { if (vf.status && task.status !== vf.status) continue; if (vf.label && !(task.labels || []).includes(vf.label)) continue; if (viewUser && task.responsible_user_id !== viewUser) continue; } tasks.push({ entry_id: obj.entry_id, object_name: obj.object.name, task }); } } const order: Record = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 }; tasks.sort((a, b) => { const byStatus = (order[a.task.status] ?? 9) - (order[b.task.status] ?? 9); if (byStatus !== 0) return byStatus; // Within a status, soonest-due first; tasks without a due date go last. return (a.task.days_until_due ?? Infinity) - (b.task.days_until_due ?? Infinity); }); if (max_items && max_items > 0) { return tasks.slice(0, max_items); } return tasks; } private _onCompleted = async (): Promise => { await this._loadData(); }; /** Open the per-task quick-actions dialog (Complete / Skip / Reset / Edit / * QR / Delete) — full per-task panel parity. Mounted on document.body via * the shared dialog-mount helper, so the card works on any dashboard * without depending on the strategy bundle's ll-custom handler. */ private _openTaskDetail(entryId: string, taskId: string): void { openTaskQuickActions(entryId, taskId); } render() { const L = this._lang; const title = this._config.title || t("maintenance", L); const showHeader = this._config.show_header !== false; const showActions = this._config.show_actions !== false; const compact = this._config.compact || false; const tasks = this._flatTasks; const s = this._stats; return html`

${title}

${showHeader && s ? html`
${s.overdue > 0 ? html`${s.overdue}` : nothing} ${s.due_soon > 0 ? html`${s.due_soon}` : nothing} ${s.triggered > 0 ? html`${s.triggered}` : nothing}
` : nothing} ${showActions ? html` openCreateObjectDialog()} > openCreateTaskDialog()} > ` : nothing}
${tasks.length === 0 ? this._objects.some((o) => o.tasks.length > 0) ? html`
✓ ${t("card_all_caught_up", L)}
` : html`
${t("card_no_tasks_title", L)}
${t("card_no_tasks_cta", L)}
` : html`
${tasks.map( ({ entry_id, object_name, task }) => html`
this._openTaskDetail(entry_id, task.id)} title="${t("open_task", L) || "Open task"}">
${task.name} ${(task as any).due_override ? html`` : nothing}
${!compact ? html`
${object_name} · ${t(task.type, L)}${this._assigneeName(task) ? html` · ${this._assigneeName(task)}` : nothing}
` : this._assigneeName(task) ? html`
${this._assigneeName(task)}
` : nothing}
${this._docsFor(entry_id, task).length ? html`
${this._docsFor(entry_id, task).map((doc) => html` `)}
` : nothing}
${task.days_until_due !== null && task.days_until_due !== undefined ? task.days_until_due < 0 ? html`${formatDueDays(task.days_until_due, L)}` : formatDueDays(task.days_until_due, L) : task.trigger_active ? "⚡" : "—"}
${showActions ? html` { // Stop the row's open-task handler from also firing e.stopPropagation(); const dlg = this.shadowRoot!.querySelector("maintenance-complete-dialog") as any; dlg.entryId = entry_id; dlg.taskId = task.id; dlg.taskName = task.name; dlg.checklist = task.checklist || []; dlg.adaptiveEnabled = !!task.adaptive_config?.enabled; dlg.taskType = task.type || ""; dlg.readingUnit = (task as any).reading_unit || ""; dlg.requiredFields = task.required_completion_fields || []; dlg.lang = L; // #99: editable per-completion parts selection // (skip on buy tasks — those restock instead). // #111: the list also carries the shared pools // this task draws on, each named after its // owner — resolving against the object's own // parts alone left a foreign link invisible. const isBuy = !!(task as any).part_ref; dlg.parts = isBuy ? [] : partsForCompletion(task, entry_id, this._objects, L); dlg.consumesParts = isBuy ? [] : (task.consumes_parts || []); dlg.open(); }} > ` : nothing}
` )}
`}
`; } static styles = [ sharedStyles, css` ha-card { overflow: hidden; } .card-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 16px 8px; } .card-header h1 { margin: 0; font-size: 18px; font-weight: 500; } .header-right { display: flex; align-items: center; gap: 6px; } .header-stats { display: flex; gap: 6px; } .hdr-add { --mdc-icon-button-size: 32px; --mdc-icon-size: 20px; color: var(--primary-color); } .badge { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 22px; border-radius: 11px; font-size: 12px; font-weight: 600; color: white; padding: 0 6px; } .badge.overdue { background: var(--error-color, #f44336); } .badge.due_soon { background: var(--warning-color, #ff9800); } .badge.triggered { background: #ff5722; } .empty-card { padding: 24px 16px; text-align: center; color: var(--secondary-text-color); display: flex; flex-direction: column; gap: 10px; align-items: center; } .empty-link { color: var(--primary-color); text-decoration: none; font-size: 13px; } .empty-link:hover { text-decoration: underline; } .all-caught-up { color: var(--success-color, #4caf50); font-weight: 500; } .task-list { padding: 0 16px 16px; } .task-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--divider-color); } .task-item:last-child { border-bottom: none; } .task-list.compact .task-item { padding: 4px 0; } /* Row click opens the task editor (in-place via the strategy bundle's ll-custom handler). Hover state hints that the row is interactive. */ .task-item.clickable { cursor: pointer; transition: background 0.12s; } .task-item.clickable:hover { background: var(--state-icon-color, rgba(255,255,255,0.04)); } .status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .task-info { flex: 1; min-width: 0; } .task-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .postponed-icon { --mdc-icon-size: 14px; color: var(--secondary-text-color); vertical-align: text-bottom; margin-inline-start: 4px; } .task-meta { font-size: 12px; color: var(--secondary-text-color); } .assignee, .compact-assignee { display: inline-flex; align-items: center; gap: 2px; white-space: nowrap; } .assignee ha-icon, .compact-assignee ha-icon { --mdc-icon-size: 13px; width: 13px; height: 13px; } /* Compact rows have no meta line of their own — keep the name from pushing the due column off a narrow phone card. */ .compact-assignee { max-width: 11ch; overflow: hidden; text-overflow: ellipsis; } .doc-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-right: 6px; max-width: 45%; } .doc-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 14ch; padding: 1px 6px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 10px; background: none; color: var(--secondary-text-color); font: inherit; font-size: 11px; cursor: pointer; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .doc-chip:hover { color: var(--primary-color); border-color: var(--primary-color); } .doc-chip ha-icon { --mdc-icon-size: 12px; width: 12px; height: 12px; } /* nowrap: the due label is localized via formatDueDays ("5 d overdue", "5 T überfällig") — without it a narrow phone card wraps that onto a second line and the row grows taller. The name column ellipsizes instead, which it already does by design. */ .task-due { font-size: 13px; color: var(--secondary-text-color); min-width: 40px; text-align: right; white-space: nowrap; } .overdue-text { color: var(--error-color); font-weight: 500; } .complete-btn { --mdc-icon-button-size: 32px; --mdc-icon-size: 18px; color: var(--primary-color); } `, ]; } // Module-bottom registration so esbuild's tree-shaker doesn't drop the // element class when the only reference is the @customElement decorator // (which triggered this exact bug for the dialog components — issue #32: // "card not showing in the card selector, busy spinner only"). if (!customElements.get("maintenance-supporter-card")) { customElements.define("maintenance-supporter-card", MaintenanceSupporterCard); } // Register as custom card so the Lovelace card picker lists it. (window as any).customCards = (window as any).customCards || []; (window as any).customCards.push({ type: "maintenance-supporter-card", name: "Maintenance Supporter", description: "Overview of your maintenance tasks with quick actions.", preview: true, });