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
@@ -9,7 +9,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { UserService } from "../user-service";
import type { HAUser, HomeAssistant } from "../types";
@@ -55,7 +55,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
private _userService: UserService | null = null;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -6,7 +6,8 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
@@ -84,7 +85,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
connectedCallback(): void {
@@ -232,24 +233,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</svg>`;
}
private static readonly _SORT_KEY = "ms_bf_roster_sort";
private static _storedSort(): "name" | "urgency" {
try {
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
return v === "name" ? "name" : "urgency";
} catch {
return "urgency";
}
return lsGet(LS_KEYS.batteryRosterSort) === "name" ? "name" : "urgency";
}
private _setSort(mode: "name" | "urgency"): void {
this._rosterSort = mode;
try {
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode);
} catch {
// storage unavailable — the toggle still works for this visit
}
// Storage may be unavailable — the toggle still works for this visit.
lsSet(LS_KEYS.batteryRosterSort, mode);
}
/** Urgency (the default, issue #123): low rows first — emptiest first —
@@ -8,7 +8,8 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles";
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { BudgetStatus, HomeAssistant } from "../types";
@@ -40,7 +41,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
private get _isAdmin(): boolean {
@@ -277,9 +278,7 @@ if (!customElements.get("maintenance-budget-section-card")) {
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
registerCustomCard({
type: "maintenance-budget-section-card",
name: "Maintenance Supporter — Budget",
description: "Inline monthly + yearly budget editor",
@@ -10,10 +10,12 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes";
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import type { HomeAssistant } from "../types";
interface MaintenanceDocument {
@@ -28,16 +30,6 @@ interface MaintenanceDocument {
added_at?: string;
}
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
const CATEGORY_ICONS: Record<string, string> = {
manual: "mdi:book-open-variant",
warranty: "mdi:shield-check",
invoice: "mdi:receipt-text-outline",
spare_parts: "mdi:cog-outline",
photo: "mdi:image-outline",
other: "mdi:file-document-outline",
};
export class MaintenanceDocumentsSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string;
@@ -67,16 +59,11 @@ export class MaintenanceDocumentsSection extends LitElement {
}
private async _sign(doc: MaintenanceDocument): Promise<string> {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
return signed.path;
return signDocumentPath(this.hass, doc.id);
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -210,12 +197,7 @@ export class MaintenanceDocumentsSection extends LitElement {
private async _download(doc: MaintenanceDocument): Promise<void> {
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(signed.path, doc.filename || doc.title || "document");
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
@@ -228,15 +210,9 @@ export class MaintenanceDocumentsSection extends LitElement {
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
return;
}
// Open the tab synchronously (in the click gesture) so it isn't popup-blocked,
// then point it at the freshly signed URL once it resolves.
const win = window.open("about:blank", "_blank");
try {
const url = await this._sign(doc);
// Absolute URL so it always resolves against the blank popup (about:blank).
if (win) win.location.href = new URL(url, window.location.origin).href;
await openSignedDocument(this.hass, doc.id);
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
@@ -3,7 +3,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import "./ms-textfield";
import type {
@@ -26,7 +26,7 @@ export class MaintenanceGroupDialog extends LitElement {
@state() private _selected: Set<string> = new Set(); // "entry_id:task_id"
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
return langOf(this.hass);
}
public openCreate(): void {
@@ -8,7 +8,8 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types";
@@ -47,7 +48,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
private get _isAdmin(): boolean {
@@ -330,9 +331,7 @@ if (!customElements.get("maintenance-groups-section-card")) {
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
registerCustomCard({
type: "maintenance-groups-section-card",
name: "Maintenance Supporter — Groups",
description: "Inline group CRUD",
@@ -9,7 +9,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { t, langOf } from "../styles";
import type { HomeAssistant } from "../types";
import { describeWsError } from "../ws-errors";
@@ -50,7 +50,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
private _originalSnapshot: HistoryEntryDraft | null = null;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
// #130: selectable parts + the edited selection (part key -> quantity;
@@ -10,6 +10,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { signDocumentPath } from "../helpers/document-url";
export class MaintenanceHistoryPhoto extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -29,12 +30,7 @@ export class MaintenanceHistoryPhoto extends LitElement {
private async _sign(): Promise<void> {
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${this.docId}`,
expires: 300,
});
this._url = signed.path;
this._url = await signDocumentPath(this.hass, this.docId);
} catch {
this._failed = true;
}
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
import { t } from "../styles";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import "./ms-textfield";
@@ -33,7 +33,7 @@ export class MaintenanceObjectDialog extends LitElement {
@state() private _entryId: string | null = null; // null = create, string = update
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
return langOf(this.hass);
}
public openCreate(): void {
@@ -11,7 +11,7 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, STATUS_COLORS } from "../styles";
import { t, STATUS_COLORS, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
@@ -31,7 +31,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
@state() private _error = "";
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
public async openFor(entryId: string): Promise<void> {
@@ -15,7 +15,7 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, MaintenancePart } from "../types";
// Per-part document links (v2.26) — the task-documents component in part mode.
@@ -70,7 +70,7 @@ export class MaintenancePartsSection extends LitElement {
@state() private _docsFor: string | null = null;
private get _lang(): string {
return this.hass?.locale?.language || this.hass?.language || "en";
return langOf(this.hass);
}
public connectedCallback(): void {
@@ -12,7 +12,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
@@ -33,7 +33,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -171,7 +171,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 340px;
min-width: min(360px, calc(100vw - 24px));
max-width: 480px;
width: 90vw;
max-height: 80vh;
@@ -3,7 +3,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
@@ -23,7 +23,7 @@ export class SeasonalOverridesDialog extends LitElement {
@state() private _values: string[] = new Array(12).fill("");
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
return langOf(this.hass);
}
public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void {
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
import { t } from "../styles";
import { t, langOf } from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
@@ -160,7 +162,7 @@ export class MaintenanceSettingsView extends LitElement {
private _userService: UserService | null = null;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changedProps: Map<string, unknown>): void {
@@ -1430,6 +1432,7 @@ export class MaintenanceSettingsView extends LitElement {
<button @click=${this._exportJson}>${t("settings_export_json", L)}</button>
<button @click=${this._exportYaml}>${t("settings_export_yaml", L)}</button>
<button @click=${this._exportCsv}>${t("settings_export_csv", L)}</button>
<button @click=${this._exportSettings}>${t("settings_export_settings", L)}</button>
</div>
<div class="settings-actions docs-archive-block">
<h4>${t("settings_docs_archive", L)}</h4>
@@ -1510,6 +1513,22 @@ export class MaintenanceSettingsView extends LitElement {
}
}
/** The SECOND export: the global entry's settings (groups, saved views,
* vacation, notification/budget settings, feature toggles) — the objects
* export deliberately excludes them. Re-import via the regular import. */
private async _exportSettings(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/settings/export",
}) as { data: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
}
}
private async _exportYaml(): Promise<void> {
try {
const ids = this._selectedEntryIds;
@@ -1573,17 +1592,8 @@ export class MaintenanceSettingsView extends LitElement {
try {
const raw = this._selectedEntryIds;
const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : "";
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/documents/archive${q}`,
expires: 300,
});
const a = document.createElement("a");
a.href = signed.path;
a.download = "maintenance-documents.zip";
document.body.appendChild(a);
a.click();
a.remove();
const signed = await signApiPath(this.hass, `/api/maintenance_supporter/documents/archive${q}`);
downloadUrl(signed, "maintenance-documents.zip");
} catch {
this._showToast(t("action_error", this._lang));
}
@@ -8,8 +8,9 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { openSignedDocument } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes";
import type { HomeAssistant } from "../types";
@@ -55,7 +56,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
private _searchTimer = 0;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -137,17 +138,9 @@ export class MaintenanceStorageSectionCard extends LitElement {
window.open(doc.url, "_blank", "noopener");
return;
}
const win = window.open("about:blank", "_blank");
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
// Absolute URL so it navigates the blank popup reliably (about:blank base).
if (win) win.location.href = new URL(s.path, window.location.origin).href;
await openSignedDocument(this.hass, doc.id);
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
@@ -11,7 +11,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
@@ -61,7 +61,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, t, weekdayName } from "../styles";
import { formatDate, t, weekdayName, langOf } from "../styles";
import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import {
@@ -24,6 +24,8 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
// The type selector additionally offers "compound" (a group of conditions
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
// Backend defaults for the adaptive tuning fields (ewa_alpha / min / max days).
const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const;
/** One condition of a compound trigger — a flat trigger the user edits inline.
* String-typed like the top-level fields (form inputs); coerced on save. */
@@ -279,9 +281,9 @@ export class MaintenanceTaskDialog extends LitElement {
// Adaptive tuning (parity with the options flow's adaptive step) — Store-
// managed like the environmental binding, saved through task/set_adaptive.
@state() private _adaptiveEnabled = false;
@state() private _adaptiveAlpha = "0.3";
@state() private _adaptiveMin = "7";
@state() private _adaptiveMax = "365";
@state() private _adaptiveAlpha: string = ADAPTIVE_DEFAULTS.alpha;
@state() private _adaptiveMin: string = ADAPTIVE_DEFAULTS.min;
@state() private _adaptiveMax: string = ADAPTIVE_DEFAULTS.max;
@state() private _adaptiveSeasonal = true;
@state() private _adaptivePrediction = true;
private _adaptiveInitial = "";
@@ -295,7 +297,7 @@ export class MaintenanceTaskDialog extends LitElement {
private _userService: UserService | null = null;
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
return langOf(this.hass);
}
public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise<void> {
@@ -404,9 +406,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._environmentalInitial = this._environmentalEntity;
this._environmentalAttributeInitial = this._environmentalAttribute;
this._adaptiveEnabled = !!ac.enabled;
this._adaptiveAlpha = (ac.ewa_alpha ?? 0.3).toString();
this._adaptiveMin = (ac.min_interval_days ?? 7).toString();
this._adaptiveMax = (ac.max_interval_days ?? 365).toString();
this._adaptiveAlpha = ac.ewa_alpha?.toString() ?? ADAPTIVE_DEFAULTS.alpha;
this._adaptiveMin = ac.min_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.min;
this._adaptiveMax = ac.max_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.max;
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
this._adaptiveInitial = this._adaptiveSnapshot();
@@ -497,9 +499,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._environmentalInitial = "";
this._environmentalAttributeInitial = "";
this._adaptiveEnabled = false;
this._adaptiveAlpha = "0.3";
this._adaptiveMin = "7";
this._adaptiveMax = "365";
this._adaptiveAlpha = ADAPTIVE_DEFAULTS.alpha;
this._adaptiveMin = ADAPTIVE_DEFAULTS.min;
this._adaptiveMax = ADAPTIVE_DEFAULTS.max;
this._adaptiveSeasonal = true;
this._adaptivePrediction = true;
this._adaptiveInitial = this._adaptiveSnapshot();
@@ -1314,34 +1316,13 @@ export class MaintenanceTaskDialog extends LitElement {
</select>
</div>
` : nothing}
${this._availableAttributes.length > 0
? html`
<div class="select-row">
<label>${t("attribute_optional", L)}</label>
<select
.value=${this._triggerAttribute}
@change=${(e: Event) => (this._triggerAttribute = (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!this._triggerAttribute}>${t("use_entity_state", L)}</option>
${this._suggestedAttributes.map(
(attr) => html`<option value=${attr} ?selected=${attr === this._triggerAttribute}>${attr} ★</option>`
)}
${this._availableAttributes
.filter((a) => !this._suggestedAttributes.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === this._triggerAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`
: html`
<ms-textfield
label="${t("attribute_optional", L)}"
.value=${this._triggerAttribute}
@input=${(e: Event) => (this._triggerAttribute = (e.target as HTMLInputElement).value)}
></ms-textfield>
`
}
${this._renderAttributeSelect({
label: t("attribute_optional", L),
value: this._triggerAttribute,
suggested: this._suggestedAttributes,
available: this._availableAttributes,
onSelect: (v) => (this._triggerAttribute = v),
})}
${this._renderTriggerTypeFields()}
${this._renderTriggerLiveHint()}
`}
@@ -1576,76 +1557,73 @@ export class MaintenanceTaskDialog extends LitElement {
`;
}
/** Shared attribute selector: "use entity state" + suggested★ + remaining
* attributes (non-numeric flagged), textfield fallback when none known. */
private _renderAttributeSelect(cfg: {
label: string;
value: string;
suggested: string[];
available: { name: string; numeric: boolean }[];
onSelect: (value: string) => void;
}) {
const L = this._lang;
if (cfg.available.length > 0) {
return html`
<div class="select-row">
<label>${cfg.label}</label>
<select
.value=${cfg.value}
@change=${(e: Event) => cfg.onSelect((e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!cfg.value}>${t("use_entity_state", L)}</option>
${cfg.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === cfg.value}>${attr} ★</option>`
)}
${cfg.available
.filter((a) => !cfg.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === cfg.value}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${cfg.label}"
.value=${cfg.value}
@input=${(e: Event) => cfg.onSelect((e.target as HTMLInputElement).value.trim())}
></ms-textfield>
`;
}
/** Environmental attribute — the same live-fetched dropdown the flat and
* compound attribute fields use, keyed by the environmental entity. */
private _renderEnvironmentalAttribute(L: string) {
this._fetchConditionAttributes(this._environmentalEntity);
const opts = this._conditionAttrOptions[this._environmentalEntity];
if (opts && opts.available.length > 0) {
return html`
<div class="select-row">
<label>${t("environmental_attribute_optional", L)}</label>
<select
.value=${this._environmentalAttribute}
@change=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!this._environmentalAttribute}>${t("use_entity_state", L)}</option>
${opts.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === this._environmentalAttribute}>${attr} ★</option>`
)}
${opts.available
.filter((a) => !opts.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === this._environmentalAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${t("environmental_attribute_optional", L)}"
.value=${this._environmentalAttribute}
@input=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())}
></ms-textfield>
`;
return this._renderAttributeSelect({
label: t("environmental_attribute_optional", L),
value: this._environmentalAttribute,
suggested: opts?.suggested ?? [],
available: opts?.available ?? [],
onSelect: (v) => (this._environmentalAttribute = v),
});
}
/** Attribute selector for one compound condition — the same live-fetched
* dropdown the flat editor has, keyed by the condition's first entity. */
private _renderConditionAttribute(c: CompoundConditionDraft, i: number) {
const L = this._lang;
const firstId = c.entityIds.split(",")[0]?.trim() || "";
if (firstId) this._fetchConditionAttributes(firstId);
const opts = firstId ? this._conditionAttrOptions[firstId] : undefined;
if (opts && opts.available.length > 0) {
return html`
<div class="select-row">
<label>${t("attribute_optional", L)}</label>
<select
.value=${c.attribute}
@change=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLSelectElement).value })}
>
<option value="" ?selected=${!c.attribute}>${t("use_entity_state", L)}</option>
${opts.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === c.attribute}>${attr} ★</option>`
)}
${opts.available
.filter((a) => !opts.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === c.attribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${t("attribute_optional", L)}"
.value=${c.attribute}
@input=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLInputElement).value.trim() })}
></ms-textfield>
`;
return this._renderAttributeSelect({
label: t("attribute_optional", this._lang),
value: c.attribute,
suggested: opts?.suggested ?? [],
available: opts?.available ?? [],
onSelect: (v) => this._patchCondition(i, { attribute: v }),
});
}
/** Type-specific inputs for a single compound condition (mirrors the flat
@@ -11,10 +11,12 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes";
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import type { HomeAssistant } from "../types";
interface Doc {
@@ -31,16 +33,6 @@ interface Doc {
part_ids?: string[];
}
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
const CATEGORY_ICONS: Record<string, string> = {
manual: "mdi:book-open-variant",
warranty: "mdi:shield-check",
invoice: "mdi:receipt-text-outline",
spare_parts: "mdi:cog-outline",
photo: "mdi:image-outline",
other: "mdi:file-document-outline",
};
export class MaintenanceTaskDocuments extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string;
@@ -59,7 +51,7 @@ export class MaintenanceTaskDocuments extends LitElement {
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
/** The id linked docs reference — a task id or (part mode) a part id. */
@@ -158,19 +150,9 @@ export class MaintenanceTaskDocuments extends LitElement {
// A per-task page hint jumps straight to the relevant page via the PDF
// viewer's #page=N fragment (client-side, so it never breaks the signature).
const page = this._pageFor(doc);
const frag = page ? `#page=${page}` : "";
const win = window.open("about:blank", "_blank");
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
// Absolute URL: a fragment on a *root-relative* path won't resolve against
// the blank popup's about:blank base, so it would silently stay blank.
if (win) win.location.href = new URL(s.path + frag, window.location.origin).href;
await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : "");
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
@@ -196,12 +178,7 @@ export class MaintenanceTaskDocuments extends LitElement {
private async _download(doc: Doc): Promise<void> {
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(s.path, doc.filename || doc.title || "document");
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
@@ -14,7 +14,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
@@ -62,7 +62,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
private _featuresLoaded = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
@@ -13,7 +13,8 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types";
@@ -55,7 +56,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
private get _isAdmin(): boolean {
@@ -317,9 +318,7 @@ if (!customElements.get("maintenance-vacation-section-card")) {
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
registerCustomCard({
type: "maintenance-vacation-section-card",
name: "Maintenance Supporter — Vacation",
description: "Inline vacation mode toggle + dates",
@@ -18,4 +18,29 @@ export const LS_KEYS = {
objectView: "maintenance_supporter_object_view",
objectsCache: "msp-objects-cache",
gettingStartedDismissed: "msp-gs-dismissed",
batteryRosterSort: "ms_bf_roster_sort",
} as const;
/**
* Guarded storage access — localStorage can THROW (Safari private mode /
* locked-down policies raise instead of returning null), and an unguarded
* call aborts the surrounding handler. The 2026-08 DRY audit found two
* hand-written call sites that had missed the guard; every access now goes
* through these two (a source tripwire forbids direct localStorage use
* outside this module).
*/
export function lsGet(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function lsSet(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* private mode / storage blocked */
}
}
@@ -483,6 +483,7 @@
"settings_export_json": "Exportovat JSON",
"settings_export_yaml": "Exportovat YAML",
"settings_export_csv": "Exportovat CSV",
"settings_export_settings": "Exportovat nastavení (JSON)",
"settings_import_csv": "Importovat CSV",
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
"settings_import_btn": "Importovat",
@@ -484,6 +484,7 @@
"settings_export_json": "Eksporter JSON",
"settings_export_yaml": "Eksporter YAML",
"settings_export_csv": "Eksporter CSV",
"settings_export_settings": "Eksportér indstillinger (JSON)",
"settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
"settings_import_btn": "Importer",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON exportieren",
"settings_export_yaml": "YAML exportieren",
"settings_export_csv": "CSV exportieren",
"settings_export_settings": "Einstellungen exportieren (JSON)",
"settings_import_csv": "CSV importieren",
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
"settings_import_btn": "Importieren",
@@ -484,6 +484,7 @@
"settings_export_json": "Export JSON",
"settings_export_yaml": "Export YAML",
"settings_export_csv": "Export CSV",
"settings_export_settings": "Export settings (JSON)",
"settings_import_csv": "Import CSV",
"settings_import_placeholder": "Paste JSON or CSV content here…",
"settings_import_btn": "Import",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar ajustes (JSON)",
"settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
"settings_import_btn": "Importar",
@@ -484,6 +484,7 @@
"settings_export_json": "Vie JSON",
"settings_export_yaml": "Vie YAML",
"settings_export_csv": "Vie CSV",
"settings_export_settings": "Vie asetukset (JSON)",
"settings_import_csv": "Tuo CSV",
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
"settings_import_btn": "Tuo",
@@ -483,6 +483,7 @@
"settings_export_json": "Exporter JSON",
"settings_export_yaml": "Exporter YAML",
"settings_export_csv": "Exporter CSV",
"settings_export_settings": "Exporter les réglages (JSON)",
"settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
"settings_import_btn": "Importer",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON निर्यात करें",
"settings_export_yaml": "YAML निर्यात करें",
"settings_export_csv": "CSV निर्यात करें",
"settings_export_settings": "सेटिंग्स निर्यात करें (JSON)",
"settings_import_csv": "CSV आयात करें",
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
"settings_import_btn": "आयात करें",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON exportálása",
"settings_export_yaml": "YAML exportálása",
"settings_export_csv": "CSV exportálása",
"settings_export_settings": "Beállítások exportálása (JSON)",
"settings_import_csv": "CSV importálása",
"settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…",
"settings_import_btn": "Importálás",
@@ -483,6 +483,7 @@
"settings_export_json": "Esporta JSON",
"settings_export_yaml": "Esporta YAML",
"settings_export_csv": "Esporta CSV",
"settings_export_settings": "Esporta impostazioni (JSON)",
"settings_import_csv": "Importa CSV",
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
"settings_import_btn": "Importa",
@@ -484,6 +484,7 @@
"settings_export_json": "JSONをエクスポート",
"settings_export_yaml": "YAMLをエクスポート",
"settings_export_csv": "CSVをエクスポート",
"settings_export_settings": "設定をエクスポート(JSON)",
"settings_import_csv": "CSVをインポート",
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
"settings_import_btn": "インポート",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON 내보내기",
"settings_export_yaml": "YAML 내보내기",
"settings_export_csv": "CSV 내보내기",
"settings_export_settings": "설정 내보내기 (JSON)",
"settings_import_csv": "CSV 가져오기",
"settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…",
"settings_import_btn": "가져오기",
@@ -484,6 +484,7 @@
"settings_export_json": "Eksporter JSON",
"settings_export_yaml": "Eksporter YAML",
"settings_export_csv": "Eksporter CSV",
"settings_export_settings": "Eksporter innstillinger (JSON)",
"settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
"settings_import_btn": "Importer",
@@ -483,6 +483,7 @@
"settings_export_json": "JSON exporteren",
"settings_export_yaml": "YAML exporteren",
"settings_export_csv": "CSV exporteren",
"settings_export_settings": "Instellingen exporteren (JSON)",
"settings_import_csv": "CSV importeren",
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
"settings_import_btn": "Importeren",
@@ -483,6 +483,7 @@
"settings_export_json": "Eksportuj JSON",
"settings_export_yaml": "Eksportuj YAML",
"settings_export_csv": "Eksportuj CSV",
"settings_export_settings": "Eksportuj ustawienia (JSON)",
"settings_import_csv": "Importuj CSV",
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
"settings_import_btn": "Importuj",
@@ -484,6 +484,7 @@
"settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar configurações (JSON)",
"settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
"settings_import_btn": "Importar",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar definições (JSON)",
"settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
"settings_import_btn": "Importar",
@@ -483,6 +483,7 @@
"settings_export_json": "Экспорт JSON",
"settings_export_yaml": "Экспорт YAML",
"settings_export_csv": "Экспорт CSV",
"settings_export_settings": "Экспорт настроек (JSON)",
"settings_import_csv": "Импорт CSV",
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
"settings_import_btn": "Импортировать",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportera JSON",
"settings_export_yaml": "Exportera YAML",
"settings_export_csv": "Exportera CSV",
"settings_export_settings": "Exportera inställningar (JSON)",
"settings_import_csv": "Importera CSV",
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
"settings_import_btn": "Importera",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON dışa aktar",
"settings_export_yaml": "YAML dışa aktar",
"settings_export_csv": "CSV dışa aktar",
"settings_export_settings": "Ayarları dışa aktar (JSON)",
"settings_import_csv": "CSV içe aktar",
"settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…",
"settings_import_btn": "İçe aktar",
@@ -483,6 +483,7 @@
"settings_export_json": "Експортувати JSON",
"settings_export_yaml": "Експортувати YAML",
"settings_export_csv": "Експортувати CSV",
"settings_export_settings": "Експорт налаштувань (JSON)",
"settings_import_csv": "Імпортувати CSV",
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
"settings_import_btn": "Імпортувати",
@@ -484,6 +484,7 @@
"settings_export_json": "导出 JSON",
"settings_export_yaml": "导出 YAML",
"settings_export_csv": "导出 CSV",
"settings_export_settings": "导出设置(JSON)",
"settings_import_csv": "导入 CSV",
"settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…",
"settings_import_btn": "导入",
@@ -34,7 +34,8 @@ import {
type CalendarEvent,
} from "./helpers/calendar-bucket";
import { calendarStyles } from "./calendar-styles";
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
import { registerCustomCard } from "./helpers/register-card";
import type {
HomeAssistant,
MaintenanceObjectResponse,
@@ -121,7 +122,7 @@ export class MaintenanceCalendarCard extends LitElement {
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
disconnectedCallback(): void {
@@ -642,31 +643,15 @@ if (!customElements.get("maintenance-supporter-calendar-card-editor")) {
}
// Register with HACS / customCards so the picker shows it
const w = window as unknown as {
customCards?: Array<{
type: string;
name: string;
description: string;
preview?: boolean;
}>;
};
w.customCards = w.customCards || [];
// HA's custom-card resolver maps ``custom:X`` → element tag ``X``. Our element
// is registered as ``maintenance-supporter-calendar-card``, so the customCards
// type MUST match that exact suffix or the card-picker entry resolves to a
// non-existent element and the strategy's calendar mode throws a config error.
const CALENDAR_CARD_TYPE = "maintenance-supporter-calendar-card";
const alreadyRegistered = w.customCards.some(
(c) => c.type === CALENDAR_CARD_TYPE,
);
if (!alreadyRegistered) {
w.customCards.push({
type: CALENDAR_CARD_TYPE,
name: "Maintenance Supporter — Calendar",
description:
"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
preview: true,
});
}
// The type MUST match the registered element tag exactly (custom:X → tag X),
// or the picker entry resolves to a non-existent element and the strategy's
// calendar mode throws a config error.
registerCustomCard({
type: "maintenance-supporter-calendar-card",
name: "Maintenance Supporter — Calendar",
description:
"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
preview: true,
});
export {};
@@ -2,7 +2,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "./styles";
import { t, langOf } from "./styles";
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types";
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
@@ -18,7 +18,7 @@ export class MaintenanceSupporterCardEditor extends LitElement {
private _objectsLoaded = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
setConfig(config: CardConfig): void {
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { hydrateObjects } from "./helpers/hydrate-objects";
import { property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
import { openSignedDocument } from "./helpers/document-url";
import { registerCustomCard } from "./helpers/register-card";
import type {
HomeAssistant,
MaintenanceObjectResponse,
@@ -54,7 +56,7 @@ export class MaintenanceSupporterCard extends LitElement {
private _docsLoadedFor = new Set<string>();
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
static getConfigElement() {
@@ -240,16 +242,10 @@ export class MaintenanceSupporterCard extends LitElement {
window.open(doc.url, "_blank", "noopener");
return;
}
const win = window.open("about:blank", "_blank");
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
await openSignedDocument(this.hass, doc.id);
} catch {
if (win) win.close();
/* silent — the card has no error surface */
}
}
@@ -696,8 +692,7 @@ if (!customElements.get("maintenance-supporter-card")) {
}
// Register as custom card so the Lovelace card picker lists it.
(window as any).customCards = (window as any).customCards || [];
(window as any).customCards.push({
registerCustomCard({
type: "maintenance-supporter-card",
name: "Maintenance Supporter",
description: "Overview of your maintenance tasks with quick actions.",
@@ -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}`),
@@ -79,6 +79,17 @@ export function t(key: string, lang?: string): string {
return STORE[l]?.[key] ?? STORE.en[key] ?? key;
}
/**
* The UI language for a hass object — the ONE rule every component's `_lang`
* getter delegates to. Before this helper the 23 hand-copied getters had
* drifted into three variants (`|| "en"`, `?? navigator.language...`, and a
* `locale.language` probe), so sibling components could resolve different
* languages from the same hass.
*/
export function langOf(hass?: { language?: string }): string {
return hass?.language || "en";
}
/** True when *lang*'s table is in memory (English is always bundled). */
export function isLocaleLoaded(lang?: string): boolean {
const l = normLang(lang);