93 files
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user