329 files

This commit is contained in:
Home Assistant Version Control
2026-08-06 13:56:25 +00:00
parent 0df89406fa
commit 7afe7add1d
330 changed files with 13098 additions and 5942 deletions
@@ -2,6 +2,7 @@
import { LitElement, html, nothing } from "lit";
import { isSafeHttpUrl } from "./helpers/url";
import { mergeSubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
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";
@@ -29,6 +30,7 @@ import type {
StatisticsPoint,
SavedView,
SavedViewFilters,
ManualDocRef,
} from "./types";
import { StatisticsService } from "./statistics-service";
import { UserService } from "./user-service";
@@ -385,10 +387,18 @@ export class MaintenanceSupporterPanel extends LitElement {
]);
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
// Battery Fleet availability (Battery Notes present + not yet set up).
// 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) {
this._fetchFullHistory(this._selectedEntryId, this._selectedTaskId);
}
// Battery Fleet availability (batteries present + not yet set up).
// The slim status check, NOT the overview: the full overview runs the
// trend machinery server-side (one recorder regression per healthy
// battery on a cold cache) — far too expensive for hiding a button.
this.hass.connection
.sendMessagePromise<{ available: boolean; configured: boolean }>({
type: "maintenance_supporter/battery_fleet/overview",
type: "maintenance_supporter/battery_fleet/status",
})
.then((ov) => {
this._batteryFleetSetupAvailable = !!ov.available && !ov.configured;
@@ -591,10 +601,15 @@ export class MaintenanceSupporterPanel extends LitElement {
try {
const unsub = await this.hass.connection.subscribeMessage(
(msg: unknown) => {
const data = msg as { objects: MaintenanceObjectResponse[] };
this._objects = data.objects;
const next = mergeSubscriptionEvent(
this._objects,
msg as SubscriptionEvent<MaintenanceObjectResponse>,
);
if (next !== null) this._objects = next;
},
{ type: "maintenance_supporter/subscribe" }
// 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 }
);
// If the element was detached while the subscribe was in flight, drop the
// now-orphaned subscription instead of storing it on a dead component.
@@ -880,6 +895,9 @@ export class MaintenanceSupporterPanel extends LitElement {
this._activeTab = "overview";
this._historyFilter = null;
this._scrollContentToTop();
// Payload diet: list responses carry only the most recent history
// window — the detail's full timeline/charts load here, on demand.
this._fetchFullHistory(entryId, taskId);
// Lazy-load statistics for the task's trigger entity
const task = this._getTask(entryId, taskId);
@@ -1785,6 +1803,52 @@ export class MaintenanceSupporterPanel extends LitElement {
}
}
/** Open a manual-tagged document from the objects table / object header:
* web-links directly, stored files through a signed path (the same
* Companion-safe recipe as the documents section). */
private _openManualDoc(doc: ManualDocRef): void {
if (doc.kind !== "file") {
if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener");
return;
}
// Open the tab synchronously (inside the click gesture) so it isn't
// popup-blocked, then point it at the freshly signed URL.
const win = window.open("about:blank", "_blank");
void this.hass.connection
.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
})
.then((signed) => {
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
})
.catch(() => win?.close());
}
/** #73: persist one checklist tick. Sends the FULL current state (the
* server replaces, not merges — idempotent) and reloads so the progress
* header and any other open surface agree. */
private async _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
const obj = this._getObject(entryId);
const task = obj?.tasks.find((x) => x.id === taskId);
if (!task) return;
const state: Record<string, boolean> = {};
for (const step of task.checklist || []) {
const current = task.checklist_progress?.[step] ?? false;
state[step] = step === item ? done : current;
}
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/checklist_progress",
entry_id: entryId, task_id: taskId, checklist_state: state,
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
}
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;
@@ -1801,6 +1865,8 @@ export class MaintenanceSupporterPanel extends LitElement {
?.tasks.find((tsk) => tsk.id === taskId);
dlg.taskType = tk?.type || "";
dlg.readingUnit = tk?.reading_unit || "";
// #73: ticks recorded during the cycle prefill the dialog's checklist.
dlg.checklistPrefill = tk?.checklist_progress || {};
dlg.requiredFields = tk?.required_completion_fields || [];
// Spare parts: a buy task gets an editable restock-qty field; a consuming
// task shows what it will decrement (incl. the storage location).
@@ -2765,13 +2831,22 @@ export class MaintenanceSupporterPanel extends LitElement {
const area = o.area_id ? (this.hass?.areas?.[o.area_id]?.name || o.area_id) : "—";
return html`<td class="oc-area_id">${area}</td>`;
}
case "documentation_url":
case "documentation_url": {
// Fallback: an UPLOADED manual (category "manual") is the object's
// manual just as much as the legacy URL field — an object with its
// handbook attached must not render "—" here (prod: Easee vs Epson).
const manualDoc = (o.manual_docs || [])[0];
return html`<td class="oc-documentation_url">${
isSafeHttpUrl(o.documentation_url)
? html`<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer"
@click=${(e: Event) => e.stopPropagation()}><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
: "—"
: manualDoc
? html`<a href="#" title=${manualDoc.title}
@click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this._openManualDoc(manualDoc); }}
><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
: "—"
}</td>`;
}
case "notes":
return html`<td class="oc-notes" title=${o.notes || ""}>${o.notes || "—"}</td>`;
case "task_count":
@@ -3085,7 +3160,16 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<p class="meta">${t("documentation_url_label", L)}:
<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer">${o.documentation_url}</a>
</p>`
: nothing}
: (o.manual_docs || []).length
? html`<p class="meta">${t("documentation_url_label", L)}:
${o.manual_docs!.slice(0, 3).map(
(m, i) => html`${i > 0 ? " · " : ""}<a href="#"
@click=${(e: Event) => { e.preventDefault(); this._openManualDoc(m); }}>${m.title}</a>`,
)}${o.manual_docs!.length > 3
? html` … +${o.manual_docs!.length - 3}`
: nothing}
</p>`
: nothing}
${o.installation_date ? html`<p class="meta">${t("installed", L)}: ${formatDate(o.installation_date, L)}</p>` : nothing}
${o.warranty_expiry ? this._renderWarrantyMeta(o.warranty_expiry, L) : nothing}
${o.notes
@@ -3157,6 +3241,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.entryId=${obj.entry_id}
.parts=${obj.parts || []}
.canWrite=${!isOperator}
.currencySymbol=${this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL}
@parts-changed=${() => this._loadData()}
></maintenance-parts-section>
</div>
@@ -3221,7 +3306,16 @@ export class MaintenanceSupporterPanel extends LitElement {
const task = this._selectedEntryId && this._selectedTaskId
? this._getObject(this._selectedEntryId)?.tasks.find((tk) => tk.id === this._selectedTaskId)
: undefined;
const readings = (task?.history || [])
// Payload diet: the summary's history is truncated to the recent window —
// the reading DELTAS must come from the full record (the oldest visible
// reading would otherwise lose or falsify its delta), which
// _fetchFullHistory loads for the open task.
const fh = this._fullHistory;
const fullHistory =
fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task?.history || []).length
? fh.entries
: task?.history || [];
const readings = fullHistory
.filter((h) => h.reading_value != null)
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
return {
@@ -3256,6 +3350,9 @@ export class MaintenanceSupporterPanel extends LitElement {
taskId,
objectName: obj?.object.name || "",
objectDocUrl: obj?.object?.documentation_url ?? null,
objectManualDocs: obj?.object?.manual_docs ?? [],
openManualDoc: (doc) => this._openManualDoc(doc),
setChecklistItem: (item, done) => this._setChecklistItem(entryId, taskId, item, done),
isOperator: this._isOperator,
actionLoading: this._actionLoading,
moreMenuOpen: this._moreMenuOpen,
@@ -3293,12 +3390,37 @@ export class MaintenanceSupporterPanel extends LitElement {
};
}
/** Full history for the OPEN task (list payloads are truncated to the
* most recent window). null until loaded; a failure — e.g. an older
* backend without `task/history` — falls back to the truncated list. */
@state() private _fullHistory: { entryId: string; taskId: string; entries: HistoryEntry[] } | null = null;
private async _fetchFullHistory(entryId: string, taskId: string): Promise<void> {
try {
const res = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/history",
entry_id: entryId,
task_id: taskId,
})) as { history: HistoryEntry[] };
if (this._selectedEntryId === entryId && this._selectedTaskId === taskId) {
this._fullHistory = { entryId, taskId, entries: res.history || [] };
}
} catch {
this._fullHistory = null;
}
}
private _renderTaskDetail() {
if (!this._selectedEntryId || !this._selectedTaskId) return nothing;
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
if (!task) return html`<p>Task not found.</p>`;
const fh = this._fullHistory;
const detailTask =
fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task.history || []).length
? { ...task, history: fh.entries }
: task;
return html`<maintenance-task-detail-view
.task=${task}
.task=${detailTask}
.ctx=${this._taskDetailCtx()}
></maintenance-task-detail-view>`;
}