456 files

This commit is contained in:
Home Assistant Version Control
2026-10-01 05:38:21 +00:00
parent 4192ecbaae
commit 5d6d845ebf
456 changed files with 64951 additions and 3828 deletions
@@ -26,6 +26,7 @@ import { effectivePhase } from "./helpers/phases";
import { renderEventTitles } from "./helpers/event-titles";
import { recommendationReason, type HomeProfile, type TemplateRecommendation } from "./helpers/home-profile";
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
import { areaDisplayName, areaKeyOf } from "./helpers/area-history";
import { panelStyles } from "./panel-styles";
// Global search (#171): what the panel needs to know about a hit — enough
@@ -130,7 +131,8 @@ import { readingSlotDelta } from "./helpers/reading-slots";
import { bulkResultMessage, runWs, runWsEach } from "./helpers/ws-run";
import { STATUS_ORDER, statusRank } from "./status-constants";
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
// #191: all_areas = the areas page, area = one area's history and costs.
type View = "overview" | "object" | "task" | "all_objects" | "all_parts" | "all_areas" | "area";
/** 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
@@ -221,6 +223,8 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _allParts: PartsOverviewRow[] | null = null;
@state() private _selectedEntryId: string | null = null;
@state() private _selectedTaskId: string | null = null;
/** #191: the area on the `area` view (HA area id or area-history NO_AREA). */
@state() private _selectedAreaId: string | null = null;
@state() private _filterStatus = "";
@state() private _filterUser: string | null = null;
@state() private _filterLabel: string | null = null;
@@ -272,6 +276,9 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _moreMenuOpen = false;
@state() private _objMenuOpen = false;
@state() private _toastMessage = "";
/** error = red (a refusal, a failure); info = neutral like HA's own
* toasts — a success used to show in the error red too (2.95). */
@state() private _toastKind: "error" | "info" = "error";
@state() private _toastUndo: (() => void) | null = null;
@state() private _toastActionLabel = "";
// Narrow-viewport disclosure (UX 2026-07): filters and create-actions are
@@ -284,6 +291,10 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _gsSetupsCount = 0;
@state() private _gsAdoptCount = 0;
private _gsLoaded = false;
// 2.95: tasks whose completion could also reset the integration's counter
// — offered to every install (the fix for tasks adopted before), once.
private _resetOffersLoaded = false;
@state() private _resetOffersCount = 0;
// Battery Fleet: offer one-click setup only when Battery Notes is present
// and the fleet isn't set up yet.
@state() private _batteryFleetSetupAvailable = false;
@@ -837,7 +848,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// state-less entry would restore nothing and leave the task page up.
const state = history.state as { msp_view?: View } | null;
if (!state?.msp_view) {
history.replaceState({ msp_view: this._view, msp_entry: this._selectedEntryId, msp_task: this._selectedTaskId }, "");
history.replaceState({ msp_view: this._view, msp_entry: this._selectedEntryId, msp_task: this._selectedTaskId, msp_area: this._selectedAreaId }, "");
}
}
@@ -964,6 +975,17 @@ export class MaintenanceSupporterPanel extends LitElement {
}
}
// #191: `?area=<area_id>` opens that area's page (a dashboard button per
// room); an area no object sits in lands on the areas list instead.
const areaParam = params.get("area");
if (areaParam && !params.get("entry_id")) {
this._deepLinkHandled = true;
cleanMsActionUrl();
if (this._objects.some((o) => areaKeyOf(o.object) === areaParam)) this._showArea(areaParam);
else this._showAllAreas();
return;
}
const entryId = params.get("entry_id");
if (!entryId) return;
this._deepLinkHandled = true;
@@ -1212,6 +1234,7 @@ export class MaintenanceSupporterPanel extends LitElement {
trigger_current_delta: task.trigger_current_delta ?? null,
trigger_config: task.trigger_config ?? null,
trigger_entity_info: task.trigger_entity_info ?? null,
battery_fleet_task: task.battery_fleet_task === true,
times_performed: task.times_performed,
total_cost: task.total_cost,
interval_days: task.interval_days ?? null,
@@ -1293,15 +1316,15 @@ export class MaintenanceSupporterPanel extends LitElement {
/** Push a browser history entry so the back button navigates within the
* panel. An in-app deep link arrives on the entry HA's navigate() just
* pushed — that one is taken over instead (see _onLocationChanged). */
private _pushPanelState(view: View, entryId?: string | null, taskId?: string | null): void {
const state = { msp_view: view, msp_entry: entryId || null, msp_task: taskId || null };
private _pushPanelState(view: View, entryId?: string | null, taskId?: string | null, areaId?: string | null): void {
const state = { msp_view: view, msp_entry: entryId || null, msp_task: taskId || null, msp_area: areaId || null };
if (this._deepLinkInPlace) history.replaceState(state, "");
else history.pushState(state, "");
}
/** Handle browser back/forward button. */
private _onPopState(e: PopStateEvent): void {
const s = e.state as { msp_view?: View; msp_entry?: string; msp_task?: string } | null;
const s = e.state as { msp_view?: View; msp_entry?: string; msp_task?: string; msp_area?: string } | null;
if (!s?.msp_view) {
// No panel state → we're leaving the panel, let HA handle it
return;
@@ -1310,6 +1333,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._view = s.msp_view;
this._selectedEntryId = s.msp_entry || null;
this._selectedTaskId = s.msp_task || null;
this._selectedAreaId = s.msp_area || null;
this._moreMenuOpen = false;
this._objectSectionOverride = null;
if (s.msp_view === "all_parts") void this._loadAllParts();
@@ -1362,6 +1386,35 @@ export class MaintenanceSupporterPanel extends LitElement {
}
}
// #191: the areas page (sibling of All objects / All parts) and one area.
private _showAllAreas(): void {
this._pushPanelState("all_areas");
this._view = "all_areas";
this._selectedEntryId = null;
this._selectedTaskId = null;
this._scrollContentToTop();
}
private _showArea(areaId: string): void {
this._pushPanelState("area", null, null, areaId);
this._view = "area";
this._selectedAreaId = areaId;
this._selectedEntryId = null;
this._selectedTaskId = null;
this._scrollContentToTop();
}
/** The two area components are a code-split chunk, fetched the first time
* either view renders (every route in — chip, Back, `?area=` — renders).
* Until then the element is an unknown tag; Lit keeps the properties it
* was given and the upgrade picks them up. */
private _areaUi: Promise<unknown> | null = null;
private _ensureAreaUi(): void {
// A failed chunk fetch (connection blip) is retried on the next render.
this._areaUi ??= Promise.all([import("./components/areas-view"), import("./components/area-view")])
.catch(() => { this._areaUi = null; });
}
/** v2.1.0 (Discussion #49 — @byoung79): tap a KPI value to auto-filter
* the task list. Empty string clears the filter (used by "Tasks" KPI). */
private _filterByStatus(status: string): void {
@@ -1568,8 +1621,16 @@ export class MaintenanceSupporterPanel extends LitElement {
// --- Toast ---
private _showToast(msg: string): void {
/** A count sentence with its own singular for exactly one ("1 devices"
* read in every language, 2.95). Both keys literal, so the locale
* hygiene scan sees them. */
private _countText(key: string, oneKey: string, placeholder: string, n: number, L: string = this._lang): string {
return n === 1 ? t(oneKey, L) : t(key, L).replace(`{${placeholder}}`, String(n));
}
private _showToast(msg: string, kind: "error" | "info" = "error"): void {
if (this._toastTimer) clearTimeout(this._toastTimer);
this._toastKind = kind;
this._toastUndo = null;
this._toastActionLabel = "";
this._toastMessage = msg;
@@ -1587,6 +1648,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _showUndoToast(msg: string, undo: () => void): void {
if (this._toastTimer) clearTimeout(this._toastTimer);
this._toastKind = "info";
this._toastActionLabel = "";
this._toastMessage = msg;
this._toastUndo = undo;
@@ -1886,7 +1948,7 @@ export class MaintenanceSupporterPanel extends LitElement {
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));
const msg = this._countText("adopt_problem_done", "adopt_problem_done_one", "tasks", 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.
@@ -1900,7 +1962,7 @@ export class MaintenanceSupporterPanel extends LitElement {
}
});
} else {
this._showToast(msg);
this._showToast(msg, "info");
}
}
@@ -1925,7 +1987,7 @@ export class MaintenanceSupporterPanel extends LitElement {
if (obj && tk) {
this._showTask(obj.entry_id, tk.id);
}
this._showToast(t("battery_fleet_setup_done", this._lang));
this._showToast(t("battery_fleet_setup_done", this._lang), "info");
}
private _openSuggestedSetups(): void {
@@ -1933,9 +1995,18 @@ export class MaintenanceSupporterPanel extends LitElement {
.then((d) => d?.open());
}
private _onResetsWired(e: CustomEvent): void {
const count = e.detail?.wired ?? 0;
this._showToast(this._countText("reset_offers_done", "reset_offers_done_one", "count", count), "info");
this._resetOffersLoaded = false;
this._resetOffersCount = 0;
this._maybeLoadResetOffers();
this._loadData();
}
private _onSetupsAdopted(e: CustomEvent): void {
const tasks = e.detail?.tasks_created ?? 0;
this._showToast(t("setups_done", this._lang).replace("{tasks}", String(tasks)));
this._showToast(this._countText("setups_done", "setups_done_one", "tasks", tasks), "info");
this._loadData();
}
@@ -1971,7 +2042,7 @@ export class MaintenanceSupporterPanel extends LitElement {
await this._loadData();
},
successToast: t("template_created", this._lang),
onSuccess: (m) => this._showToast(m),
onSuccess: (m) => this._showToast(m, "info"),
onError: (m) => this._showToast(m),
},
);
@@ -2109,7 +2180,7 @@ export class MaintenanceSupporterPanel extends LitElement {
await this._loadData();
const msg = bulkResultMessage(doneMsg(done.length), failed, this._lang);
if (undo && done.length > 0) this._showUndoToast(msg, () => undo(done));
else this._showToast(msg);
else this._showToast(msg, failed.length > 0 ? "error" : "info");
}
/** One WS message per selected task row. */
@@ -2284,7 +2355,7 @@ export class MaintenanceSupporterPanel extends LitElement {
busy: (b) => { this._actionLoading = b; },
reload: () => this._loadData(),
successToast: opts?.successToast,
onSuccess: (m) => this._showToast(m),
onSuccess: (m) => this._showToast(m, "info"),
onError: (m) => this._showToast(m),
});
return res === undefined ? null : ((res ?? {}) as T);
@@ -2546,9 +2617,10 @@ export class MaintenanceSupporterPanel extends LitElement {
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)})`,
"info",
);
} else {
this._showToast(t("reanalyze_insufficient_data", this._lang));
this._showToast(t("reanalyze_insufficient_data", this._lang), "info");
}
}
@@ -2653,7 +2725,7 @@ export class MaintenanceSupporterPanel extends LitElement {
}
return;
}
this._showToast(t("quick_complete_success", this._lang));
this._showToast(t("quick_complete_success", this._lang), "info");
// 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 */ }
@@ -2906,6 +2978,10 @@ export class MaintenanceSupporterPanel extends LitElement {
? this._renderAllObjects()
: this._view === "all_parts"
? this._renderAllParts()
: this._view === "all_areas"
? this._renderAllAreas()
: this._view === "area"
? this._renderArea()
: this._view === "object"
? this._renderObjectDetail()
: this._renderTaskDetail()}
@@ -2955,12 +3031,13 @@ export class MaintenanceSupporterPanel extends LitElement {
<maintenance-suggested-setups-dialog
.hass=${this.hass}
@integration-setups-adopted=${(e: CustomEvent) => this._onSetupsAdopted(e)}
@reset-counters-wired=${(e: CustomEvent) => this._onResetsWired(e)}
></maintenance-suggested-setups-dialog>
<maintenance-saved-views-dialog
.hass=${this.hass}
@saved-views-changed=${(e: CustomEvent<{ views: SavedView[] }>) => this._onSavedViewsChanged(e)}
></maintenance-saved-views-dialog>
${this._toastMessage ? html`<div class="toast">
${this._toastMessage ? html`<div class="toast ${this._toastKind}" role=${this._toastKind === "error" ? "alert" : "status"}>
<span>${this._toastMessage}</span>
${this._toastUndo ? html`<button class="toast-undo" @click=${() => this._runToastUndo()}>${this._toastActionLabel || t("undo", this._lang)}</button>` : nothing}
</div>` : nothing}
@@ -3000,6 +3077,10 @@ export class MaintenanceSupporterPanel extends LitElement {
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
crumbs.push({ label: task?.name || "Task" });
}
if (this._view === "area" && this._selectedAreaId) {
crumbs.push({ label: t("all_areas", this._lang), action: () => this._showAllAreas() });
crumbs.push({ label: areaDisplayName(this._selectedAreaId, this.hass?.areas, t("no_area", this._lang)) });
}
return html`
<div class="header">
@@ -3009,6 +3090,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.path=${"M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z"}
@click=${() => {
if (this._view === "task") this._showObject(this._selectedEntryId!);
else if (this._view === "area") this._showAllAreas();
else this._showOverview();
}}
></ha-icon-button>`
@@ -3726,6 +3808,9 @@ export class MaintenanceSupporterPanel extends LitElement {
<button class="sibling-view-chip" @click=${() => this._showAllParts()}>
<ha-icon icon="mdi:package-variant-closed"></ha-icon> ${t("all_parts", L)}
</button>
<button class="sibling-view-chip" data-view="all_areas" @click=${() => this._showAllAreas()}>
<ha-icon icon="mdi:floor-plan"></ha-icon> ${t("all_areas", L)}
</button>
</div>
<div class="filter-bar">
<label class="filter-field">
@@ -3837,6 +3922,9 @@ export class MaintenanceSupporterPanel extends LitElement {
<button class="sibling-view-chip" @click=${() => this._showAllObjects()}>
<ha-icon icon="mdi:devices"></ha-icon> ${t("all_objects", L)}
</button>
<button class="sibling-view-chip" data-view="all_areas" @click=${() => this._showAllAreas()}>
<ha-icon icon="mdi:floor-plan"></ha-icon> ${t("all_areas", L)}
</button>
</div>
<div class="filter-bar">
<ha-button appearance="plain" @click=${() => this._exportPartsCsv()}>
@@ -3895,6 +3983,59 @@ export class MaintenanceSupporterPanel extends LitElement {
`;
}
// ── #191: areas ──────────────────────────────────────────────────────────
private _renderAllAreas() {
const L = this._lang;
this._ensureAreaUi();
return html`
<div class="breadcrumb">
<ha-icon-button @click=${() => this._showAllObjects()}>
<ha-icon icon="mdi:arrow-left"></ha-icon>
</ha-icon-button>
<span>${t("all_areas", L)}</span>
<button class="sibling-view-chip" @click=${() => this._showAllObjects()}>
<ha-icon icon="mdi:devices"></ha-icon> ${t("all_objects", L)}
</button>
<button class="sibling-view-chip" @click=${() => this._showAllParts()}>
<ha-icon icon="mdi:package-variant-closed"></ha-icon> ${t("all_parts", L)}
</button>
</div>
<maintenance-areas-view
.hass=${this.hass}
.objects=${this._objects}
.showArchived=${this._showArchived}
.currencySymbol=${this._currencySymbol}
@open-area=${(e: CustomEvent<{ areaKey: string }>) => this._showArea(e.detail.areaKey)}
@archived-toggle=${() => { this._showArchived = !this._showArchived; }}
></maintenance-areas-view>
`;
}
private _renderArea() {
if (!this._selectedAreaId) return nothing;
const L = this._lang;
this._ensureAreaUi();
return html`
<div class="breadcrumb">
<ha-icon-button @click=${() => this._showAllAreas()}>
<ha-icon icon="mdi:arrow-left"></ha-icon>
</ha-icon-button>
<span>${t("all_areas", L)}</span>
</div>
<maintenance-area-view
.hass=${this.hass}
.areaKey=${this._selectedAreaId}
.objects=${this._objects}
.showArchived=${this._showArchived}
.currencySymbol=${this._currencySymbol}
.userName=${(id: string) => this._userService?.getUserName(id) ?? null}
@open-object=${(e: CustomEvent<{ entryId: string }>) => this._showObject(e.detail.entryId)}
@open-task=${(e: CustomEvent<{ entryId: string; taskId: string }>) => this._showTask(e.detail.entryId, e.detail.taskId)}
></maintenance-area-view>
`;
}
/** 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 {
@@ -4362,6 +4503,11 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<p class="meta">${[o.manufacturer, o.model].filter(Boolean).join(" ")}</p>`
: nothing}
${o.serial_number ? html`<p class="meta">${t("serial_number_label", L)}: ${o.serial_number}</p>` : nothing}
${o.area_id
? html`<p class="meta">${t("area", L)}:
<a href="#" class="object-area-link" @click=${(e: Event) => { e.preventDefault(); this._showArea(o.area_id!); }}
>${areaDisplayName(o.area_id, this.hass?.areas, t("no_area", L))}</a></p>`
: nothing}
${isSafeHttpUrl(o.documentation_url)
? html`<p class="meta">${t("documentation_url_label", L)}:
<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer">${o.documentation_url}</a>
@@ -4598,7 +4744,17 @@ export class MaintenanceSupporterPanel extends LitElement {
/** Discovery counts for the chips — fetched ONCE per panel life and ONLY
* while the install is young, so mature installs pay nothing. */
private _maybeLoadResetOffers(): void {
if (this._resetOffersLoaded) return;
this._resetOffersLoaded = true;
this.hass.connection
.sendMessagePromise<{ offers: unknown[] }>({ type: "maintenance_supporter/integration_setups/reset_offers" })
.then((r) => { this._resetOffersCount = (r.offers || []).length; })
.catch(() => { this._resetOffersCount = 0; });
}
private _maybeLoadGettingStarted(): void {
this._maybeLoadResetOffers();
if (this._gsLoaded || !this._isYoungInstall()) return;
this._gsLoaded = true;
this.hass.connection
@@ -4618,17 +4774,24 @@ export class MaintenanceSupporterPanel extends LitElement {
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)),
text: this._countText("gs_setups_chip", "gs_setups_chip_one", "n", this._gsSetupsCount, L),
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)),
text: this._countText("gs_adopt_chip", "gs_adopt_chip_one", "n", this._gsAdoptCount, L),
run: () => this._openAdoptProblemSensors(),
});
}
if (this._resetOffersCount > 0 && !dismissed.has("resets")) {
chips.push({
id: "resets", icon: "mdi:counter",
text: this._countText("gs_reset_chip", "gs_reset_chip_one", "n", this._resetOffersCount, L),
run: () => this._openSuggestedSetups(),
});
}
if (this._batteryFleetSetupAvailable && !dismissed.has("fleet")) {
chips.push({
id: "fleet", icon: "mdi:battery-sync",