This commit is contained in:
Home Assistant Version Control
2026-08-06 15:24:19 +00:00
parent 6aaf37b9bc
commit d5da256341
93 changed files with 5199 additions and 8790 deletions
@@ -7,6 +7,8 @@ import { isStaleBundle } from "./helpers/bundle-version";
import { customElement, property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
import { LS_KEYS } from "./helpers/storage-keys";
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";
@@ -34,20 +36,18 @@ import type {
} from "./types";
import { StatisticsService } from "./statistics-service";
import { UserService } from "./user-service";
import "./components/object-dialog";
// 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/task-documents";
import "./components/task-dialog";
import type { MaintenanceTaskDialog } from "./components/task-dialog";
import "./components/complete-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
import "./components/qr-dialog";
import type { MaintenanceQrDialog } from "./components/qr-dialog";
import "./components/adopt-problem-sensors-dialog";
import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-dialog";
import "./components/suggested-setups-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
@@ -61,7 +61,6 @@ import type {
} from "./components/history-edit-dialog";
import "./components/confirm-dialog";
import type { MaintenanceConfirmDialog } from "./components/confirm-dialog";
import "./components/settings-view";
import "./components/storage-section-card";
import "./components/seasonal-overrides-dialog";
import type { SeasonalOverridesDialog } from "./components/seasonal-overrides-dialog";
@@ -236,8 +235,45 @@ export class MaintenanceSupporterPanel extends LitElement {
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
/** Lazy UI chunks (perf wave 2, item 5): the six dialogs + the settings
* view are code-split out of the entry bundle and fetched in parallel
* right after mount — the critical parse path shrinks while every open
* path goes through _ui(), so a click can never race a still-loading
* chunk. */
private _lazyUi: Promise<unknown> | null = null;
private _ensureLazyUi(): Promise<unknown> {
if (!this._lazyUi) {
this._lazyUi = Promise.all([
import("./components/object-dialog"),
import("./components/task-dialog"),
import("./components/complete-dialog"),
import("./components/qr-dialog"),
import("./components/adopt-problem-sensors-dialog"),
import("./components/suggested-setups-dialog"),
import("./components/settings-view"),
]).then(() => this.updateComplete);
}
return this._lazyUi;
}
/** The requested dialog element, guaranteed upgraded (chunk loaded). */
private async _ui<T extends Element>(tag: string): Promise<T | null> {
await this._ensureLazyUi();
return this.shadowRoot?.querySelector<T>(tag) ?? null;
}
connectedCallback(): void {
super.connectedCallback();
// Prefetch the lazy UI chunks AFTER the first paint settles (idle), not
// here: an eager prefetch put 7 fetches + ~280 KB of parsing in direct
// competition with the initial data load and measurably slowed first
// paint. Deep links and early clicks stay safe either way — every open
// path awaits _ui().
const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => void }).requestIdleCallback;
const kick = () => this._ensureLazyUi();
if (idle) idle(kick, { timeout: 3000 });
else window.setTimeout(kick, 1500);
window.addEventListener("popstate", this._popstateHandler);
window.addEventListener("keydown", this._paletteKeydown);
window.addEventListener("resize", this._onVirtualScroll, { passive: true });
@@ -265,6 +301,15 @@ export class MaintenanceSupporterPanel extends LitElement {
} catch {
// storage blocked — keep the defaults
}
// Skeleton from cache: paint the previous visit's task list immediately;
// the live payload replaces it through the normal _objects assignment.
if (this._objects.length === 0) {
const cached = readObjectsCache<MaintenanceObjectResponse, StatisticsResponse>();
if (cached) {
this._objects = cached.objects;
if (cached.stats) this._stats = cached.stats;
}
}
}
disconnectedCallback(): void {
@@ -378,7 +423,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private async _loadData(): Promise<void> {
const [objResult, statsResult, budgetResult, groupsResult, settingsResult, viewsResult] = await Promise.all([
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", compact: true }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/budget_status" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/groups" }).catch(() => null),
@@ -386,7 +431,10 @@ export class MaintenanceSupporterPanel extends LitElement {
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/views/list" }).catch(() => null),
]);
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
if (objResult) {
this._objects = hydrateObjects((objResult as { objects: MaintenanceObjectResponse[] }).objects);
writeObjectsCache(this._objects, (statsResult as StatisticsResponse | null) ?? this._stats ?? null);
}
// A data refresh means the open task's history may have grown (complete,
// history edit) — the truncated list payload can't tell, so refetch.
if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) {
@@ -470,11 +518,8 @@ export class MaintenanceSupporterPanel extends LitElement {
if (msAction === "add_object") {
this._deepLinkHandled = true;
cleanMsActionUrl();
requestAnimationFrame(() => {
this.shadowRoot
?.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")
?.openCreate();
});
void this._ui<MaintenanceObjectDialog>("maintenance-object-dialog")
.then((d) => d?.openCreate());
return;
}
if (msAction === "open_vacation"
@@ -486,14 +531,15 @@ export class MaintenanceSupporterPanel extends LitElement {
// Switch to the Settings tab — that's where Vacation/Budget/Groups live.
this._overviewTab = "settings";
// Hint the settings-view which sub-section to scroll to (its own
// settings-view component reads this on attribute change).
requestAnimationFrame(() => {
// settings-view component reads this on attribute change). The view is
// a lazy chunk — wait for it before poking at the element.
void this._ensureLazyUi().then(() => requestAnimationFrame(() => {
const settingsView = this.shadowRoot?.querySelector(
"maintenance-settings-view",
) as (HTMLElement & { scrollToSection?: (s: string) => void }) | null;
const target = msAction.replace("open_", ""); // "vacation" / "budget" / "groups" / "settings"
settingsView?.scrollToSection?.(target);
});
}));
return;
}
@@ -601,15 +647,25 @@ export class MaintenanceSupporterPanel extends LitElement {
try {
const unsub = await this.hass.connection.subscribeMessage(
(msg: unknown) => {
const next = mergeSubscriptionEvent(
this._objects,
msg as SubscriptionEvent<MaintenanceObjectResponse>,
);
if (next !== null) this._objects = next;
const ev = msg as SubscriptionEvent<MaintenanceObjectResponse>;
// Compact payloads: hydrate incoming entries before merging so
// everything downstream keeps seeing the full shape.
if (ev.objects) hydrateObjects(ev.objects);
if (ev.delta) hydrateObjects(ev.delta);
const next = mergeSubscriptionEvent(this._objects, ev);
if (next !== null) {
this._objects = next;
// Full snapshots are rare (subscribe start) — keep the skeleton
// cache fresh from them; per-delta writes would churn storage.
if ((msg as SubscriptionEvent<MaintenanceObjectResponse>).objects) {
writeObjectsCache(next, this._stats ?? null);
}
}
},
// deltas: only entries whose rebuilt response actually changed —
// no-op timer waves send nothing, a real change ships one object.
{ type: "maintenance_supporter/subscribe", deltas: true }
// compact: empty keys stripped server-side, hydrated above.
{ type: "maintenance_supporter/subscribe", deltas: true, compact: true }
);
// If the element was detached while the subscribe was in flight, drop the
// now-orphaned subscription instead of storing it on a dead component.
@@ -1051,9 +1107,8 @@ export class MaintenanceSupporterPanel extends LitElement {
// --- Adopt problem sensors ---
private _openAdoptProblemSensors(): void {
this.shadowRoot!
.querySelector<MaintenanceAdoptProblemSensorsDialog>("maintenance-adopt-problem-sensors-dialog")
?.open();
void this._ui<MaintenanceAdoptProblemSensorsDialog>("maintenance-adopt-problem-sensors-dialog")
.then((d) => d?.open());
}
private async _onProblemSensorsAdopted(e: CustomEvent): Promise<void> {
@@ -1069,7 +1124,8 @@ export class MaintenanceSupporterPanel extends LitElement {
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) {
this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openEdit(ref.entry_id, tk);
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
.then((d) => d?.openEdit(ref.entry_id, tk));
}
});
} else {
@@ -1100,9 +1156,8 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _openSuggestedSetups(): void {
this.shadowRoot!
.querySelector<MaintenanceSuggestedSetupsDialog>("maintenance-suggested-setups-dialog")
?.open();
void this._ui<MaintenanceSuggestedSetupsDialog>("maintenance-suggested-setups-dialog")
.then((d) => d?.open());
}
private _onSetupsAdopted(e: CustomEvent): void {
@@ -1850,8 +1905,11 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
if (!dlg) return;
void this._ui<MaintenanceCompleteDialog>("maintenance-complete-dialog")
.then((dlg) => dlg && this._fillAndOpenCompleteDialog(dlg, entryId, taskId, taskName, checklist, adaptiveEnabled));
}
private _fillAndOpenCompleteDialog(dlg: MaintenanceCompleteDialog, entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
dlg.entryId = entryId;
dlg.taskId = taskId;
dlg.taskName = taskName;
@@ -1873,6 +1931,10 @@ export class MaintenanceSupporterPanel extends LitElement {
const objParts = this._objects.find((o) => o.entry_id === entryId)?.parts || [];
const refPart = tk?.part_ref ? objParts.find((pt) => pt.id === tk.part_ref!.part_id) : undefined;
dlg.restockDefault = tk?.part_ref ? (refPart?.restock_quantity ?? 1) : null;
// #104 follow-up: parts carry unit costs — the dialog offers their sum
// as a one-click cost suggestion (buy task: restock qty × unit cost).
dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null;
dlg.currencySymbol = this._budget?.currency_symbol || "";
// #111: a link may point at another object's pool — name that object, and
// never drop a line that fails to resolve (the old .filter(Boolean) hid it).
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
@@ -1888,13 +1950,13 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _openQrForObject(entryId: string, objectName: string): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceQrDialog>("maintenance-qr-dialog");
dlg?.openForObject(entryId, objectName);
void this._ui<MaintenanceQrDialog>("maintenance-qr-dialog")
.then((dlg) => dlg?.openForObject(entryId, objectName));
}
private _openQrForTask(entryId: string, taskId: string, objectName: string, taskName: string): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceQrDialog>("maintenance-qr-dialog");
dlg?.openForTask(entryId, taskId, objectName, taskName);
void this._ui<MaintenanceQrDialog>("maintenance-qr-dialog")
.then((dlg) => dlg?.openForTask(entryId, taskId, objectName, taskName));
}
private _onDialogEvent = async (): Promise<void> => {
@@ -2385,12 +2447,12 @@ export class MaintenanceSupporterPanel extends LitElement {
@click=${() => { if (this.narrow) this._actionsMenuOpen = false; }}
>
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openCreate("", this._objects)}
@click=${() => this._ui<MaintenanceTaskDialog>("maintenance-task-dialog").then((d) => d?.openCreate("", this._objects))}
>
${t("new_task", L)}
</ha-button>
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}
@click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}
>
${t("new_object", L)}
</ha-button>
@@ -2422,7 +2484,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-button appearance="filled" @click=${() => this._openTemplateGallery()}>
<ha-icon icon="mdi:view-grid-plus-outline"></ha-icon> ${t("templates_from", L)}
</ha-button>
<ha-button appearance="plain" @click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}>
<ha-button appearance="plain" @click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}>
${t("new_object", L)}
</ha-button>
</div>
@@ -2720,7 +2782,7 @@ export class MaintenanceSupporterPanel extends LitElement {
` : nothing}
${!isOperator ? html`
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}
@click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}
>
${t("new_object", L)}
</ha-button>
@@ -3115,12 +3177,12 @@ export class MaintenanceSupporterPanel extends LitElement {
<div class="action-buttons">
${!isOperator ? html`
<ha-button appearance="filled" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
dlg?.openCreate(obj.entry_id);
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
.then((d) => d?.openCreate(obj.entry_id));
}}>${t("add_task", L)}</ha-button>
<ha-button appearance="plain" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog");
dlg?.openEdit(obj.entry_id, o);
void this._ui<MaintenanceObjectDialog>("maintenance-object-dialog")
.then((d) => d?.openEdit(obj.entry_id, o));
}}>${t("edit", L)}</ha-button>
` : nothing}
<div class="more-menu-wrapper">
@@ -3192,8 +3254,8 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<div class="empty-state-centered">
<p class="empty">${t("no_tasks_yet", L)}</p>
<ha-button appearance="filled" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
dlg?.openCreate(obj.entry_id);
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
.then((d) => d?.openCreate(obj.entry_id));
}}>${t("add_first_task", L)}</ha-button>
</div>`
: html`<div class="task-table">${[...visibleTasks].sort((a, b) => {
@@ -3372,7 +3434,7 @@ export class MaintenanceSupporterPanel extends LitElement {
showObject: () => this._showObject(entryId),
toggleMoreMenu: () => this._toggleMoreMenu(),
closeMoreMenu: () => this._closeMoreMenu(),
openEdit: (tk) => { this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openEdit(entryId, tk); },
openEdit: (tk) => { void this._ui<MaintenanceTaskDialog>("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),