182 files

This commit is contained in:
Home Assistant Version Control
2026-08-17 12:11:35 +00:00
parent 7dddf6bb13
commit ece15a1c1b
183 changed files with 11457 additions and 3092 deletions
@@ -5,8 +5,9 @@ import { isSafeHttpUrl } from "./helpers/url";
import { applySubscriptionEvent, 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";
import { LS_KEYS } from "./helpers/storage-keys";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs, langOf } from "./styles";
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
import { openSignedDocument, signApiPath } from "./helpers/document-url";
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
import { hydrateObjects } from "./helpers/hydrate-objects";
import { daysProgress } from "./helpers/interval";
@@ -134,17 +135,22 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _unsub: (() => void) | null = null;
@state() private _chartRangeDays = (() => {
try {
const v = parseInt(localStorage.getItem(LS_KEYS.chartRange) || "", 10);
const v = parseInt(lsGet(LS_KEYS.chartRange) || "", 10);
return [7, 30, 90, 365].includes(v) ? v : 30;
} catch {
return 30;
}
})();
@state() private _hideOutliers = (() => {
try { return localStorage.getItem(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
try { return lsGet(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
})();
@state() private _historyFilter: string | null = null;
@state() private _budget: BudgetStatus | null = null;
private get _currencySymbol(): string {
return this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
}
@state() private _groups: Record<string, MaintenanceGroup> = {};
@state() private _detailStatsData: Map<string, StatisticsPoint[]> = new Map();
@state() private _miniStatsData: Map<string, StatisticsPoint[]> = new Map();
@@ -185,7 +191,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// Dashboard redesign state
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
try {
const v = localStorage.getItem(LS_KEYS.overviewTab);
const v = lsGet(LS_KEYS.overviewTab);
return v === "today" || v === "calendar" ? v : "dashboard";
} catch { return "dashboard"; }
})();
@@ -217,7 +223,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
// remembered per section across visits.
@state() private _collapsedSections: Set<string> = (() => {
try { return new Set(JSON.parse(localStorage.getItem(LS_KEYS.collapsedSections) || "[]")); }
try { return new Set(JSON.parse(lsGet(LS_KEYS.collapsedSections) || "[]")); }
catch { return new Set(); }
})();
// v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own
@@ -241,7 +247,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _lastConnection: unknown = null;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
/**
@@ -314,19 +320,19 @@ export class MaintenanceSupporterPanel extends LitElement {
// read here would abort connectedCallback and leave the panel blank. Every
// other storage access in this file is wrapped; wrap these too.
try {
const saved = localStorage.getItem(LS_KEYS.taskSort);
const saved = lsGet(LS_KEYS.taskSort);
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
this._sortMode = saved as SortMode;
}
const savedObj = localStorage.getItem(LS_KEYS.objectSort);
const savedObj = lsGet(LS_KEYS.objectSort);
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
this._objectSortMode = savedObj as ObjectSortMode;
}
const savedGroup = localStorage.getItem(LS_KEYS.groupBy);
const savedGroup = lsGet(LS_KEYS.groupBy);
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode;
}
const savedView = localStorage.getItem(LS_KEYS.objectView);
const savedView = lsGet(LS_KEYS.objectView);
if (savedView === "cards" || savedView === "table") {
this._objectViewMode = savedView;
}
@@ -640,7 +646,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setChartRange(days: number): void {
if (days === this._chartRangeDays) return;
this._chartRangeDays = days;
try { localStorage.setItem(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
try { lsSet(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
const task = this._selectedEntryId && this._selectedTaskId
? this._getTask(this._selectedEntryId, this._selectedTaskId)
: null;
@@ -659,7 +665,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// Outlier filtering is client-side on the already-fetched series, so just
// flip the flag and let renderChart re-filter — no re-fetch needed.
this._hideOutliers = hide;
try { localStorage.setItem(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
try { lsSet(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
}
private async _fetchMiniStatsForOverview(): Promise<void> {
@@ -948,8 +954,8 @@ export class MaintenanceSupporterPanel extends LitElement {
}
// Persist sort/group like the manual controls do, so they stick after reload.
try {
localStorage.setItem(LS_KEYS.taskSort, this._sortMode);
localStorage.setItem(LS_KEYS.groupBy, this._groupByMode);
lsSet(LS_KEYS.taskSort, this._sortMode);
lsSet(LS_KEYS.groupBy, this._groupByMode);
} catch {
// ignore private-mode storage errors
}
@@ -1387,6 +1393,29 @@ export class MaintenanceSupporterPanel extends LitElement {
// --- Actions ---
/** Run a mutating WS action: loading state, data reload, and — on failure —
* the SERVER's error message as the toast. The ~16 hand-written action
* methods had drifted (most swallowed the server message behind a generic
* "Action failed", three never set _actionLoading, one skipped the reload).
* Returns the result payload, or null when the call failed (toast shown). */
private async _runAction<T = Record<string, unknown>>(
msg: Record<string, unknown>,
opts?: { successToast?: string },
): Promise<T | null> {
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<T>(msg);
await this._loadData();
if (opts?.successToast) this._showToast(opts.successToast);
return (res ?? {}) as T;
} catch (e) {
this._showToast(describeWsError(e, this._lang));
return null;
} finally {
this._actionLoading = false;
}
}
private async _deleteObject(entryId: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const ok = await dlg?.confirm({
@@ -1396,16 +1425,11 @@ export class MaintenanceSupporterPanel extends LitElement {
danger: true,
});
if (!ok) return;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/delete",
entry_id: entryId,
});
this._showOverview();
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
const res = await this._runAction({
type: "maintenance_supporter/object/delete",
entry_id: entryId,
});
if (res) this._showOverview();
}
/** Open a printable maintenance report for the object in a new tab (the user
@@ -1442,7 +1466,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const html = buildObjectReportHtml(
resp.object, resp.tasks, labels,
(iso) => (iso ? formatDate(iso, L) : ""),
this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
this._currencySymbol,
new Date().toISOString(),
);
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
@@ -1451,20 +1475,11 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private async _duplicateObject(entryId: string): Promise<void> {
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
type: "maintenance_supporter/object/duplicate",
entry_id: entryId,
});
await this._loadData();
this._showToast(t("object_duplicated", this._lang));
if (res?.entry_id) this._showObject(res.entry_id);
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
const res = await this._runAction<{ entry_id?: string }>(
{ type: "maintenance_supporter/object/duplicate", entry_id: entryId },
{ successToast: t("object_duplicated", this._lang) },
);
if (res?.entry_id) this._showObject(res.entry_id);
}
private async _deleteTask(entryId: string, taskId: string): Promise<void> {
@@ -1476,60 +1491,37 @@ export class MaintenanceSupporterPanel extends LitElement {
danger: true,
});
if (!ok) return;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/delete",
entry_id: entryId,
task_id: taskId,
});
this._showObject(entryId);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
const res = await this._runAction({
type: "maintenance_supporter/task/delete",
entry_id: entryId,
task_id: taskId,
});
if (res) this._showObject(entryId);
}
// v2.10.0: archive / unarchive a single task (reversible — no confirm).
private async _duplicateTask(entryId: string, taskId: string): Promise<void> {
this._moreMenuOpen = false;
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ task_id?: string }>({
type: "maintenance_supporter/task/duplicate",
entry_id: entryId,
task_id: taskId,
});
await this._loadData();
this._showToast(t("task_duplicated", this._lang));
// Jump straight to the copy so the user can rename/adjust it.
if (res?.task_id) this._showTask(entryId, res.task_id);
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
const res = await this._runAction<{ task_id?: string }>(
{ type: "maintenance_supporter/task/duplicate", entry_id: entryId, task_id: taskId },
{ successToast: t("task_duplicated", this._lang) },
);
// Jump straight to the copy so the user can rename/adjust it.
if (res?.task_id) this._showTask(entryId, res.task_id);
}
private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise<void> {
this._actionLoading = true;
try {
await this.hass.connection.sendMessagePromise({
type: archived
? "maintenance_supporter/task/unarchive"
: "maintenance_supporter/task/archive",
entry_id: entryId,
task_id: taskId,
});
await this._loadData();
// Just archived → offer a one-tap undo (unarchive) instead of a confirm.
if (!archived) {
this._showUndoToast(t("task_archived", this._lang),
() => this._toggleArchiveTask(entryId, taskId, true));
}
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
const res = await this._runAction({
type: archived
? "maintenance_supporter/task/unarchive"
: "maintenance_supporter/task/archive",
entry_id: entryId,
task_id: taskId,
});
// Just archived → offer a one-tap undo (unarchive) instead of a confirm.
if (res && !archived) {
this._showUndoToast(t("task_archived", this._lang),
() => this._toggleArchiveTask(entryId, taskId, true));
}
}
@@ -1537,20 +1529,15 @@ export class MaintenanceSupporterPanel extends LitElement {
// is fully reversible, so instead of a blocking confirm we run it immediately
// and offer an Undo toast (v2.14.0).
private async _toggleArchiveObject(entryId: string, archived: boolean): Promise<void> {
try {
await this.hass.connection.sendMessagePromise({
type: archived
? "maintenance_supporter/object/unarchive"
: "maintenance_supporter/object/archive",
entry_id: entryId,
});
await this._loadData();
if (!archived) {
this._showUndoToast(t("object_archived", this._lang),
() => this._toggleArchiveObject(entryId, true));
}
} catch {
this._showToast(t("action_error", this._lang));
const res = await this._runAction({
type: archived
? "maintenance_supporter/object/unarchive"
: "maintenance_supporter/object/archive",
entry_id: entryId,
});
if (res && !archived) {
this._showUndoToast(t("object_archived", this._lang),
() => this._toggleArchiveObject(entryId, true));
}
}
@@ -1567,31 +1554,21 @@ export class MaintenanceSupporterPanel extends LitElement {
inputType: "date",
});
if (!result?.confirmed) return;
try {
const msg: Record<string, unknown> = {
type: "maintenance_supporter/object/pause",
entry_id: entryId,
};
if (result.value) msg.until = result.value;
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
const msg: Record<string, unknown> = {
type: "maintenance_supporter/object/pause",
entry_id: entryId,
};
if (result.value) msg.until = result.value;
if (await this._runAction(msg)) {
this._showUndoToast(t("object_paused", this._lang),
() => this._togglePauseObject(entryId, true));
} catch (e) {
this._showToast(describeWsError(e, this._lang));
}
return;
}
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/resume",
entry_id: entryId,
});
await this._loadData();
this._showToast(t("object_resumed", this._lang));
} catch (e) {
this._showToast(describeWsError(e, this._lang));
}
await this._runAction(
{ type: "maintenance_supporter/object/resume", entry_id: entryId },
{ successToast: t("object_resumed", this._lang) },
);
}
// v2.20 (N1): replace a worn-out object with a successor — the old one is
@@ -1608,71 +1585,44 @@ export class MaintenanceSupporterPanel extends LitElement {
inputValue: currentName,
});
if (!result?.confirmed) return;
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
const res = await this._runAction<{ entry_id?: string }>(
{
type: "maintenance_supporter/object/replace",
entry_id: entryId,
name: result.value || currentName,
});
await this._loadData();
this._showToast(t("object_replaced", this._lang));
if (res?.entry_id) this._showObject(res.entry_id);
} catch (e) {
this._showToast(describeWsError(e, this._lang));
} finally {
this._actionLoading = false;
}
},
{ successToast: t("object_replaced", this._lang) },
);
if (res?.entry_id) this._showObject(res.entry_id);
}
private async _skipTask(entryId: string, taskId: string, reason?: string): Promise<void> {
this._actionLoading = true;
try {
const msg: Record<string, unknown> = {
type: "maintenance_supporter/task/skip",
entry_id: entryId,
task_id: taskId,
};
if (reason) msg.reason = reason;
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
const msg: Record<string, unknown> = {
type: "maintenance_supporter/task/skip",
entry_id: entryId,
task_id: taskId,
};
if (reason) msg.reason = reason;
await this._runAction(msg);
}
private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise<void> {
this._actionLoading = true;
try {
const msg: Record<string, unknown> = {
type: "maintenance_supporter/task/reset",
entry_id: entryId,
task_id: taskId,
};
if (resetDate) msg.date = resetDate;
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
const msg: Record<string, unknown> = {
type: "maintenance_supporter/task/reset",
entry_id: entryId,
task_id: taskId,
};
if (resetDate) msg.date = resetDate;
await this._runAction(msg);
}
private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise<void> {
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/apply_suggestion",
entry_id: entryId,
task_id: taskId,
interval: interval,
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
await this._runAction({
type: "maintenance_supporter/task/apply_suggestion",
entry_id: entryId,
task_id: taskId,
interval: interval,
});
}
private _openSeasonalOverrides(task: MaintenanceTask): void {
@@ -1683,28 +1633,24 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private async _reanalyzeInterval(entryId: string, taskId: string): Promise<void> {
try {
const res = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/analyze_interval",
entry_id: entryId,
task_id: taskId,
}) as {
recommended_interval: number | null;
confidence: string;
data_points: number;
recommendation_reason: string | null;
};
if (res.recommended_interval) {
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)})`,
);
} else {
this._showToast(t("reanalyze_insufficient_data", this._lang));
}
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
const res = await this._runAction<{
recommended_interval: number | null;
confidence: string;
data_points: number;
recommendation_reason: string | null;
}>({
type: "maintenance_supporter/task/analyze_interval",
entry_id: entryId,
task_id: taskId,
});
if (!res) return;
if (res.recommended_interval) {
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)})`,
);
} else {
this._showToast(t("reanalyze_insufficient_data", this._lang));
}
}
@@ -1737,21 +1683,10 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
this._actionLoading = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/postpone",
entry_id: entryId,
task_id: taskId,
until,
});
this._showToast(t("postponed", this._lang));
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
await this._runAction(
{ type: "maintenance_supporter/task/postpone", entry_id: entryId, task_id: taskId, until },
{ successToast: t("postponed", this._lang) },
);
}
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
@@ -1769,19 +1704,13 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
this._actionLoading = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/snooze",
entry_id: entryId,
task_id: taskId,
});
this._showToast(t("snoozed", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
// Now reloads like every sibling action — this was the one mutation that
// skipped the refresh, leaving the snoozed due date stale until the next
// poll.
await this._runAction(
{ type: "maintenance_supporter/task/snooze", entry_id: entryId, task_id: taskId },
{ successToast: t("snoozed", this._lang) },
);
}
private _dismissSuggestion(entryId?: string, taskId?: string): void {
@@ -1850,11 +1779,13 @@ export class MaintenanceSupporterPanel extends LitElement {
if (manual) {
const start = manual.task_pages![taskId];
const count = 4;
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`,
expires: 3600,
});
const signed = {
path: await signApiPath(
this.hass,
`/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`,
3600,
),
};
excerpt = {
title: manual.title || manual.filename || "Manual",
startPage: start, endPage: start + count - 1,
@@ -1920,19 +1851,9 @@ export class MaintenanceSupporterPanel extends LitElement {
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());
void openSignedDocument(this.hass, doc.id).catch(() => {
/* tab already closed by the helper; the panel toast adds no value here */
});
}
/** #73: persist one checklist tick. Sends the FULL current state (the
@@ -1947,15 +1868,10 @@ export class MaintenanceSupporterPanel extends LitElement {
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));
}
await this._runAction({
type: "maintenance_supporter/task/checklist_progress",
entry_id: entryId, task_id: taskId, checklist_state: state,
});
}
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
@@ -1988,7 +1904,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// #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 || "";
dlg.currencySymbol = this._currencySymbol;
// #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) =>
@@ -2275,7 +2191,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
this._overviewTab = tab;
try { localStorage.setItem(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
this._scrollContentToTop();
}
@@ -2441,7 +2357,7 @@ export class MaintenanceSupporterPanel extends LitElement {
@change=${(e: Event) => {
this._sortMode = (e.target as HTMLSelectElement).value as SortMode;
this._activeViewId = "";
try { localStorage.setItem(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
try { lsSet(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
}}
>
<option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option>
@@ -2460,7 +2376,7 @@ export class MaintenanceSupporterPanel extends LitElement {
@change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
this._activeViewId = "";
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
}}
>
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
@@ -2765,7 +2681,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._objectSortMode}
@change=${(e: Event) => {
this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode;
localStorage.setItem(LS_KEYS.objectSort, this._objectSortMode);
try { lsSet(LS_KEYS.objectSort, this._objectSortMode); } catch { /* private mode */ }
}}
>
<option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option>
@@ -2794,7 +2710,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._groupByMode}
@change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
}}
>
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
@@ -2843,7 +2759,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setObjectViewMode(mode: "cards" | "table"): void {
this._objectViewMode = mode;
localStorage.setItem(LS_KEYS.objectView, mode);
try { lsSet(LS_KEYS.objectView, mode); } catch { /* private mode */ }
}
// ── #130: instance-wide parts overview ────────────────────────────────────
@@ -2851,7 +2767,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _renderAllParts() {
const L = this._lang;
const rows = this._allParts;
const currency = this._budget?.currency_symbol || "";
const currency = this._currencySymbol;
return html`
<div class="breadcrumb">
<ha-icon-button @click=${() => this._showAllObjects()}>
@@ -3127,15 +3043,10 @@ export class MaintenanceSupporterPanel extends LitElement {
})
: confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`);
if (!ok) return;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/delete",
group_id: groupId,
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
await this._runAction({
type: "maintenance_supporter/group/delete",
group_id: groupId,
});
}
/** Budget as KPI tiles in the stats strip (#125) — replaces the old
@@ -3148,7 +3059,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const b = this._budget;
if (!b) return nothing;
const L = this._lang;
const cs = b.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
const cs = this._currencySymbol;
const tile = (label: string, spent: number, budget: number | null) => {
if (budget !== null) {
const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
@@ -3415,7 +3326,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.entryId=${obj.entry_id}
.parts=${obj.parts || []}
.canWrite=${!isOperator}
.currencySymbol=${this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL}
.currencySymbol=${this._currencySymbol}
@parts-changed=${() => this._loadData()}
></maintenance-parts-section>
</div>
@@ -3506,7 +3417,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _gsDismissed(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(LS_KEYS.gettingStartedDismissed) || "[]"));
return new Set(JSON.parse(lsGet(LS_KEYS.gettingStartedDismissed) || "[]"));
} catch {
return new Set();
}
@@ -3515,7 +3426,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _dismissGettingStarted(id: string): void {
const next = this._gsDismissed();
next.add(id);
try { localStorage.setItem(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ }
try { lsSet(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ }
this.requestUpdate();
}
@@ -3612,7 +3523,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const next = new Set(this._collapsedSections);
if (next.has(key)) next.delete(key); else next.add(key);
this._collapsedSections = next;
try { localStorage.setItem(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
try { lsSet(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
}
/** Build the context the history renderers need from panel state. */
@@ -3639,7 +3550,7 @@ export class MaintenanceSupporterPanel extends LitElement {
hass: this.hass,
filter: this._historyFilter,
search: this._historySearch,
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
currencySymbol: this._currencySymbol,
setFilter: (f) => { this._historyFilter = f; },
setSearch: (s) => { this._historySearch = s; },
openEdit: (entry) => this._openHistoryEdit(entry),
@@ -3674,7 +3585,7 @@ export class MaintenanceSupporterPanel extends LitElement {
moreMenuOpen: this._moreMenuOpen,
activeTab: this._activeTab,
features: this._features,
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
currencySymbol: this._currencySymbol,
collapsedSections: this._collapsedSections,
costDurationToggle: this._costDurationToggle,
suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`),