393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -4,13 +4,21 @@
* and turns each into a maintenance task that triggers while the problem is
* active and clears when it resolves. A selection either attaches to a suggested
* existing object or spins up a fresh object bound to the sensor's device.
*
* 2.94 (duplicates from adopting in a real home): a sensor whose device no
* object is linked to goes by default to the existing object that device most
* likely is (and links it); a sensor that object probably already watches
* under another name starts unticked with that task's name; a summary says
* per object how many tasks it has before and after. Typing another object
* asks the server again (problem_sensors/preview).
*/
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { TRIGGER_FOR_MINUTES_RANGE } from "../helpers/setting-ranges";
import { UserService } from "../user-service";
import type { HAUser, HomeAssistant } from "../types";
@@ -25,8 +33,13 @@ interface ProblemSensor {
suggested_object_name: string;
suggested_part_id: string | null;
suggested_part_name: string | null;
candidate?: { entry_id: string; name: string; reasons: string[] } | null;
target_entry_id?: string | null;
covered_by?: Covered;
}
type Covered = { task_id: string; name: string; reason: string } | null;
interface DiscoverResponse {
sensors: ProblemSensor[];
}
@@ -52,7 +65,10 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
* name on several rows makes them ONE object, an existing object's name
* adds them to it. */
@state() private _objectNames: Record<string, string> = {};
@state() private _objects: Array<{ entry_id: string; name: string }> = [];
@state() private _objects: Array<{ entry_id: string; name: string; tasks: number }> = [];
/** 2.94: the task each sensor's target probably already has for it. */
@state() private _covered: Record<string, Covered> = {};
private _previewTimer: ReturnType<typeof setTimeout> | undefined;
@state() private _users: HAUser[] = [];
@state() private _responsible = "";
// #136: minutes the problem must persist before the created task triggers
@@ -82,51 +98,113 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
this._objectNames = {};
this._responsible = "";
this._forMinutes = "0";
try {
if (!this._userService) this._userService = new UserService(this.hass);
else this._userService.updateHass(this.hass);
const [resp, users, objs] = await Promise.all([
this.hass.connection.sendMessagePromise<DiscoverResponse>({
type: "maintenance_supporter/problem_sensors/discover",
}),
// Best-effort: adoption works fine without the user list.
this._userService.getUsers().catch(() => [] as HAUser[]),
// Best-effort too: the existing objects feed the name suggestions.
this.hass.connection
.sendMessagePromise<{ objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null } }> }>({
type: "maintenance_supporter/objects",
})
.catch(() => ({ objects: [] })),
]);
this._objects = (objs.objects || [])
.filter((o) => !o.object.archived_at)
.map((o) => ({ entry_id: o.entry_id, name: o.object.name }));
this._sensors = resp.sensors || [];
this._selected = new Set(this._sensors.map((s) => s.entity_id));
this._users = users;
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._loading = false;
}
if (!this._userService) this._userService = new UserService(this.hass);
else this._userService.updateHass(this.hass);
const [resp, users, objs] = await Promise.all([
runWs<DiscoverResponse>(this, { type: "maintenance_supporter/problem_sensors/discover" }, {
onError: (m) => { this._error = m; },
}),
// Best-effort: adoption works fine without the user list.
this._userService.getUsers().catch(() => [] as HAUser[]),
// Best-effort too: the existing objects feed the name suggestions.
this.hass.connection
.sendMessagePromise<{
objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null }; tasks?: unknown[] }>;
}>({
type: "maintenance_supporter/objects",
})
.catch(() => ({ objects: [] })),
]);
this._loading = false;
if (resp === undefined) return;
this._objects = (objs?.objects || [])
.filter((o) => !o.object.archived_at)
.map((o) => ({ entry_id: o.entry_id, name: o.object.name, tasks: (o.tasks || []).length }));
this._sensors = resp?.sensors || [];
this._covered = Object.fromEntries(this._sensors.map((s) => [s.entity_id, s.covered_by ?? null]));
// What the target probably already watches starts unticked.
this._selected = new Set(this._sensors.filter((s) => !s.covered_by).map((s) => s.entity_id));
this._users = users;
}
/** The object name a row will be adopted into (typed, else the suggestion). */
/** The default object: the one linked to the device, else the one the
* device most likely is (2.94), else a new one named after the device. */
private _defaultName(s: ProblemSensor): string {
return s.suggested_entry_id ? s.suggested_object_name : (s.candidate?.name ?? s.suggested_object_name);
}
/** The object name a row will be adopted into (typed, else the default). */
private _effectiveName(s: ProblemSensor): string {
const typed = (this._objectNames[s.entity_id] ?? "").trim();
return typed || s.suggested_object_name;
return typed || this._defaultName(s);
}
/** An existing object the row lands in: the suggestion's entry when the
* name is untouched, else an existing object with exactly that name. */
/** An existing object the row lands in: the default's entry when the name
* is untouched, else an existing object with exactly that name. */
private _existingEntryFor(s: ProblemSensor): string | null {
const name = this._effectiveName(s);
if (name === s.suggested_object_name && s.suggested_entry_id) return s.suggested_entry_id;
const defaultEntry = s.suggested_entry_id || s.target_entry_id || null;
if (name === this._defaultName(s) && defaultEntry) return defaultEntry;
const hit = this._objects.find((o) => o.name.trim().toLowerCase() === name.toLowerCase());
return hit ? hit.entry_id : null;
}
/** Another object typed: the server judges the hints again (debounced). */
private _schedulePreview(): void {
clearTimeout(this._previewTimer);
this._previewTimer = setTimeout(() => void this._preview(), 350);
}
private async _preview(): Promise<void> {
const selections = this._sensors.map((s) => ({
entity_id: s.entity_id,
name: s.name,
entry_id: this._existingEntryFor(s),
}));
const resp = await runWs<{ covered: Record<string, Covered> }>(
this,
{ type: "maintenance_supporter/problem_sensors/preview", selections },
{ onError: () => {} },
);
if (resp?.covered) this._covered = { ...this._covered, ...resp.covered };
}
/** Per target object: tasks before → after (only ticked rows count). */
private _summary(L: string) {
const groups = new Map<string, { name: string; entry: string | null; adding: number }>();
for (const s of this._sensors) {
if (!this._selected.has(s.entity_id)) continue;
const entry = this._existingEntryFor(s);
const name = this._effectiveName(s);
const key = entry ?? `new:${name.toLowerCase()}`;
const g = groups.get(key) ?? { name, entry, adding: 0 };
g.adding += 1;
groups.set(key, g);
}
if (groups.size === 0) return nothing;
return html`
<div class="summary">
<div class="summary-title">${t("adopt_summary_title", L)}</div>
${[...groups.values()].map((g) => {
if (!g.entry) {
return html`<div class="summary-line new">
${t("setups_new_object_count", L).replace("{name}", g.name).replace("{count}", String(g.adding))}
</div>`;
}
const before = this._objects.find((o) => o.entry_id === g.entry)?.tasks ?? 0;
return html`<div class="summary-line">
${t("adopt_summary_existing", L)
.replace("{name}", g.name)
.replace("{before}", String(before))
.replace("{after}", String(before + g.adding))}
</div>`;
})}
</div>
`;
}
private _close(): void {
clearTimeout(this._previewTimer);
this._open = false;
}
@@ -147,40 +225,44 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
private _adopt = async (): Promise<void> => {
if (this._selected.size === 0 || this._adopting) return;
this._adopting = true;
this._error = "";
try {
const selections = this._sensors
.filter((s) => this._selected.has(s.entity_id))
.map((s) => ({
entity_id: s.entity_id,
name: s.name,
entry_id: this._existingEntryFor(s) ?? undefined,
object_name: this._effectiveName(s),
device_id: s.device_id ?? undefined,
part_id: s.suggested_part_id ?? undefined,
responsible_user_id: this._responsible || undefined,
for_minutes: parseInt(this._forMinutes, 10) > 0 ? parseInt(this._forMinutes, 10) : undefined,
}));
const result = await this.hass.connection.sendMessagePromise<AdoptResponse>({
type: "maintenance_supporter/problem_sensors/adopt",
selections,
});
this.dispatchEvent(
new CustomEvent("problem-sensors-adopted", {
bubbles: true,
composed: true,
detail: result,
}),
);
this._open = false;
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._adopting = false;
}
const selections = this._sensors
.filter((s) => this._selected.has(s.entity_id))
.map((s) => ({
entity_id: s.entity_id,
name: s.name,
entry_id: this._existingEntryFor(s) ?? undefined,
object_name: this._effectiveName(s),
device_id: s.device_id ?? undefined,
part_id: s.suggested_part_id ?? undefined,
responsible_user_id: this._responsible || undefined,
for_minutes: parseInt(this._forMinutes, 10) > 0 ? parseInt(this._forMinutes, 10) : undefined,
}));
const result = await runWs<AdoptResponse>(
this,
{ type: "maintenance_supporter/problem_sensors/adopt", selections },
{ busy: (b) => { this._adopting = b; }, onError: (m) => { this._error = m; } },
);
if (result === undefined) return;
this.dispatchEvent(
new CustomEvent("problem-sensors-adopted", {
bubbles: true,
composed: true,
detail: result,
}),
);
this._open = false;
};
/** The target line: the found object with why it was chosen, else the name. */
private _targetLabel(s: ProblemSensor, L: string): string {
if (s.candidate && this._existingEntryFor(s) === s.candidate.entry_id) {
const reasons = s.candidate.reasons.map((r) => t(`setups_reason_${r}`, L)).join(", ");
return t("setups_target_match", L).replace("{name}", s.candidate.name).replace("{reasons}", reasons);
}
return this._effectiveName(s);
}
render() {
if (!this._open) return html``;
const L = this._lang;
@@ -241,19 +323,25 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
class="adopt-object"
list="adopt-object-names"
aria-label=${t("object", L)}
placeholder=${s.suggested_object_name}
.value=${this._objectNames[s.entity_id] ?? s.suggested_object_name}
placeholder=${this._defaultName(s)}
.value=${this._objectNames[s.entity_id] ?? this._defaultName(s)}
@input=${(e: Event) => {
this._objectNames = { ...this._objectNames, [s.entity_id]: (e.target as HTMLInputElement).value };
this._schedulePreview();
}}
/>
</div>`
: nothing}
<div class="row-target">
→ ${this._effectiveName(s)}${this._existingEntryFor(s)
→ ${this._targetLabel(s, L)}${this._existingEntryFor(s)
? nothing
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}
</div>
${this._covered[s.entity_id]
? html`<div class="maybe">
${t("setups_maybe_covered", L).replace("{name}", this._covered[s.entity_id]!.name)}
</div>`
: nothing}
${s.suggested_part_name
? html`<div class="row-part">
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
@@ -267,6 +355,8 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
</div>
`}
${!this._loading && this._sensors.length > 0 ? this._summary(L) : nothing}
${!this._loading && this._sensors.length > 0
? html`
<label class="responsible">
@@ -274,8 +364,8 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
<input
class="for-input"
type="number"
min="0"
max="1440"
min=${TRIGGER_FOR_MINUTES_RANGE[0]}
max=${TRIGGER_FOR_MINUTES_RANGE[1]}
.value=${this._forMinutes}
@input=${(e: Event) => (this._forMinutes = (e.target as HTMLInputElement).value)}
/>
@@ -437,6 +527,22 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
.new-tag {
font-style: italic;
}
.maybe {
font-size: 11px;
color: var(--warning-color, #ff9800);
}
.summary {
font-size: 12px;
border-top: 1px dashed var(--divider-color);
padding-top: 6px;
}
.summary-title {
font-weight: 500;
margin-bottom: 2px;
}
.summary-line {
color: var(--primary-color);
}
.chip {
font-size: 11px;
padding: 1px 8px;
@@ -8,7 +8,8 @@ import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf, formatDate, syncLocaleFromHass } from "../styles";
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { px } from "../renderers/chart-utils";
import type { HomeAssistant } from "../types";
@@ -132,17 +133,27 @@ export class MaintenanceBatteryFleetSection extends LitElement {
}
private async _load(): Promise<void> {
this._loading = true;
this._error = "";
try {
this._ov = await this.hass.connection.sendMessagePromise<Overview>({
type: "maintenance_supporter/battery_fleet/overview",
});
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._loading = false;
}
const ov = await runWs<Overview>(this, { type: "maintenance_supporter/battery_fleet/overview" }, {
busy: (b) => { this._loading = b; },
onError: (m) => { this._error = m; },
});
if (ov !== undefined) this._ov = ov;
}
/** One fleet action — helpers/ws-run runWs behind the `_marking` guard,
* with the error line and the overview reload (`onDone` runs first). */
private async _act(payload: Record<string, unknown>, onDone?: () => void): Promise<void> {
if (this._marking) return;
this._error = "";
await runWs(this, payload, {
busy: (b) => { this._marking = b; },
reload: async () => {
onDone?.();
await this._load();
},
onError: (m) => { this._error = m; },
});
}
private _markAll = async (): Promise<void> => {
@@ -152,58 +163,21 @@ export class MaintenanceBatteryFleetSection extends LitElement {
// Re-runs the idempotent setup, which restores the fleet task's trigger
// when a user edit wiped it (issue #106) or recreates a deleted task.
private _repair = async (): Promise<void> => {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/setup",
language: this._lang,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
await this._act({ type: "maintenance_supporter/battery_fleet/setup", language: this._lang });
};
private async _mark(entityIds: string[] | undefined): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/mark_replaced",
...(entityIds ? { entity_ids: entityIds } : {}),
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
await this._act({
type: "maintenance_supporter/battery_fleet/mark_replaced",
...(entityIds ? { entity_ids: entityIds } : {}),
});
}
// Manual exclude/include (#107): a rechargeable device the heuristics
// missed (or any battery the user never wants tracked) leaves the fleet;
// the restore list below the section brings it back.
private async _setExcluded(entityId: string, excluded: boolean): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/set_excluded",
entity_id: entityId,
excluded,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
await this._act({ type: "maintenance_supporter/battery_fleet/set_excluded", entity_id: entityId, excluded });
}
// #135: manually ADD a battery the discovery heuristics missed. The
@@ -211,21 +185,8 @@ export class MaintenanceBatteryFleetSection extends LitElement {
// server-side; picking an entity acts immediately (no extra button).
private async _addBattery(e: CustomEvent<{ value?: string }>): Promise<void> {
const entityId = e.detail?.value;
if (!entityId || this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/set_included",
entity_id: entityId,
included: true,
});
await this._load();
} catch (e2) {
this._error = describeWsError(e2, this._lang);
} finally {
this._marking = false;
}
if (!entityId) return;
await this._act({ type: "maintenance_supporter/battery_fleet/set_included", entity_id: entityId, included: true });
}
// #135 follow-up: fleet-wide opt-in that keeps self-charging devices
@@ -241,20 +202,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
}
private async _setFleetOption(command: "set_track_self_charging" | "set_due_without_sensor", enabled: boolean): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: `maintenance_supporter/battery_fleet/${command}`,
enabled,
});
await this._load();
} catch (e2) {
this._error = describeWsError(e2, this._lang);
} finally {
this._marking = false;
}
await this._act({ type: `maintenance_supporter/battery_fleet/${command}`, enabled });
}
/** Lazy: the recorder-backed history is fetched once, when the roster is
@@ -380,9 +328,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* a direct Intl.DateTimeFormat call here ignored the profile and showed
* 9/2/2026 to a DD/MM/YYYY user). */
private _fmtDate(epochMs: number): string {
const d = new Date(epochMs);
const pad = (n: number) => String(n).padStart(2, "0");
return formatDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, this._lang);
return formatDate(isoDateLocal(new Date(epochMs)), this._lang);
}
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
@@ -414,22 +360,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* backend (#181) so the swap also consumes the type-part cells from
* stock, exactly like the Replaced action. */
private async _recordJump(entityId: string, jump: { at: number; device_id: string }): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
await this._act(
{
type: "maintenance_supporter/battery_fleet/record_replacement",
entity_id: entityId,
replaced_at: new Date(jump.at * 1000).toISOString(),
});
this._recorded = [...this._recorded, entityId];
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
},
() => { this._recorded = [...this._recorded, entityId]; },
);
}
/** Purely visual level bar next to the number — scannable at a glance.
@@ -10,7 +10,7 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, syncLocaleFromHass, currencySymbolOf, langOf, formatCost, syncCurrencyDecimals} from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { BudgetStatus, HomeAssistant } from "../types";
@@ -59,43 +59,33 @@ export class MaintenanceBudgetSectionCard extends LitElement {
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<BudgetStatus>({
type: "maintenance_supporter/budget_status",
});
this._status = r;
syncCurrencyDecimals(r);
this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : "";
this._localYearly = r.yearly_budget ? String(r.yearly_budget) : "";
this._dirty = false;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
const r = await runWs<BudgetStatus>(this, { type: "maintenance_supporter/budget_status" }, {
onError: (m) => { this._error = m; },
});
if (!r) return;
this._status = r;
syncCurrencyDecimals(r);
this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : "";
this._localYearly = r.yearly_budget ? String(r.yearly_budget) : "";
this._dirty = false;
}
private async _save(): Promise<void> {
if (!this._isAdmin) return;
this._busy = true;
this._error = "";
try {
// An emptied field means "remove this budget" and must SEND 0 (the
// backend's off-state) — omitting the key kept the old value, so a
// budget could never be cleared from this card (bug audit 2026-08-22).
const m = this._localMonthly.trim() === "" ? 0 : parseFloat(this._localMonthly);
const y = this._localYearly.trim() === "" ? 0 : parseFloat(this._localYearly);
const settings: Record<string, number> = {};
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/global/update",
settings,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
// An emptied field means "remove this budget" and must SEND 0 (the
// backend's off-state) — omitting the key kept the old value, so a
// budget could never be cleared from this card (bug audit 2026-08-22).
const m = this._localMonthly.trim() === "" ? 0 : parseFloat(this._localMonthly);
const y = this._localYearly.trim() === "" ? 0 : parseFloat(this._localYearly);
const settings: Record<string, number> = {};
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
await runWs(this, { type: "maintenance_supporter/global/update", settings }, {
busy: (b) => { this._busy = b; },
reload: () => this._load(),
onError: (msg) => { this._error = msg; },
});
}
private _onDeepLink(): void {
@@ -257,8 +247,10 @@ export class MaintenanceBudgetSectionCard extends LitElement {
.bar-fill.ok { background: var(--primary-color); }
.bar-fill.warning { background: #ff9800; }
.bar-fill.danger { background: var(--error-color, #f44336); }
/* minmax(0, …): a bare 1fr never shrinks below the number inputs'
natural width — the row was 420 px wide on a phone (2026-09-27). */
.inputs-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;
padding-top: 4px; border-top: 1px solid var(--divider-color);
}
.input-field { display: flex; flex-direction: column; gap: 4px; }
@@ -268,7 +260,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
}
.input-wrap { position: relative; display: flex; align-items: center; }
.input-wrap input {
flex: 1; padding: 6px 32px 6px 8px; font-size: 13px;
flex: 1; min-width: 0; width: 100%; box-sizing: border-box; padding: 6px 32px 6px 8px; font-size: 13px;
background: var(--secondary-background-color, #2c2c2c);
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
@@ -4,15 +4,23 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles";
import { describeWsError } from "../ws-errors";
import { t, nativeFieldStyles, formatCost, formatNumber, formatQty } from "../styles";
import { runWs } from "../helpers/ws-run";
import { isoMinuteLocal } from "../helpers/calendar-bucket";
import { PART_QTY_RANGE, RESTOCK_QTY_RANGE } from "../helpers/setting-ranges";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import { parseDurationMinutes } from "../helpers/duration";
import "./ms-date-field";
import "./ms-photo-picker";
import { photoPickerStyles } from "./ms-photo-picker";
/** The server's bounds (websocket USED_PARTS_FIELD quantity / task/complete
* restock_quantity) — validated on save with a message, never dropped.
* One TS source (helpers/setting-ranges), pinned to Python. */
const USED_QTY_RANGE = PART_QTY_RANGE;
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entryId = "";
@@ -88,6 +96,11 @@ export class MaintenanceCompleteDialog extends LitElement {
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
* objects can carry the same part id, so part_id alone would merge pools. */
@state() private _usedParts: Record<string, TaskPartLink> = {};
/** The TYPED quantity per used part (same key). The field used to write
* back a parsed number on every keystroke — clearing "1" to type "3"
* snapped back to "1" first, giving "13" (bug audit 2026-09-26). The
* text is kept as typed and parsed on save. */
@state() private _usedQtyText: Record<string, string> = {};
/** #73: in-cycle ticks (keyed by item TEXT) recorded on the task detail —
* prefill the dialog so nobody re-ticks what is already done. */
@@ -120,6 +133,15 @@ export class MaintenanceCompleteDialog extends LitElement {
// untick or adjust before completing. The whole link is kept, entry_id
// included, so a shared pool survives the edit (#111).
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [partLinkKey(l), { ...l }]));
this._usedQtyText = {};
}
/** "Value must be between {min} and {max}" with locale-formatted bounds. */
private _rangeError(min: number, max: number): string {
const L = this.lang;
return t("settings_value_out_of_range", L)
.replace("{min}", formatNumber(min, L, { maximumFractionDigits: 2 }))
.replace("{max}", formatNumber(max, L, { maximumFractionDigits: 2 }));
}
private _toggleCheck(idx: number): void {
@@ -135,92 +157,110 @@ export class MaintenanceCompleteDialog extends LitElement {
}
private async _complete(): Promise<void> {
this._loading = true;
// A photo still uploading would miss the completion and stay behind as
// an orphan (bug audit 2026-09-26 #2) — the button is disabled meanwhile;
// this guards Enter / a programmatic click as well.
if (this._loading || this._photos.uploading) return;
this._error = "";
this._photos.clearError();
try {
const data: Record<string, unknown> = {
type: "maintenance_supporter/task/complete",
entry_id: this.entryId,
task_id: this.taskId,
};
if (this._notes) data.notes = this._notes;
if (this._cost) {
const cost = parseFloat(this._cost);
if (!isNaN(cost) && cost >= 0) data.cost = cost;
const data: Record<string, unknown> = {
type: "maintenance_supporter/task/complete",
entry_id: this.entryId,
task_id: this.taskId,
};
if (this._notes) data.notes = this._notes;
if (this._cost) {
const cost = parseFloat(this._cost);
if (!isNaN(cost) && cost >= 0) data.cost = cost;
}
// Whole minutes — the server coerces to int (helpers/duration).
const dur = parseDurationMinutes(this._duration);
if (dur !== null) data.duration = dur;
if (this.checklist.length > 0) {
data.checklist_state = this._checklistState;
}
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photos.photos.length > 0) {
data.photo_doc_ids = this._photos.ids;
}
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
// require_tag_scan task can still be finished from the dialog the scan
// opened. Only ever sent when a scan actually happened.
if (this.viaTagScan) {
data.via_tag_scan = true;
}
if (this._completedAt) {
// Client-side guard mirrors the backend rule — a picked future moment
// fails fast with a localized message instead of a WS roundtrip.
if (new Date(this._completedAt).getTime() > Date.now()) {
this._error = t("completed_at_future_error", this.lang);
return;
}
if (this._duration) {
const dur = parseInt(this._duration, 10);
if (!isNaN(dur) && dur >= 0) data.duration = dur;
// Re-add seconds if the datetime-local input drops them (same
// normalisation as the history-edit dialog).
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
}
if (this.readings.length > 0) {
// #161 phase 2: only the meters actually read go out; the backend
// snapshots name + unit per slot id.
const values: Record<string, number> = {};
for (const slot of this.readings) {
const raw = (this._readingValues[slot.id] ?? "").trim();
if (raw === "") continue;
const num = parseFloat(raw.replace(",", "."));
if (!isNaN(num)) values[slot.id] = num;
}
if (this.checklist.length > 0) {
data.checklist_state = this._checklistState;
if (Object.keys(values).length > 0) data.reading_values = values;
} else if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
if (this.restockDefault !== null && this._restockQty.trim() !== "") {
// Fractions are real purchases (0.5 kg, 2.5 l) — the old `>= 1` guard
// dropped them silently and the part's default quantity was stocked
// instead. Out of the server's range: say so, send nothing.
const rq = parseFloat(this._restockQty.replace(",", "."));
if (!Number.isFinite(rq) || rq < RESTOCK_QTY_RANGE[0] || rq > RESTOCK_QTY_RANGE[1]) {
this._error = this._rangeError(...RESTOCK_QTY_RANGE);
return;
}
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photos.photos.length > 0) {
data.photo_doc_ids = this._photos.ids;
}
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
// require_tag_scan task can still be finished from the dialog the scan
// opened. Only ever sent when a scan actually happened.
if (this.viaTagScan) {
data.via_tag_scan = true;
}
if (this._completedAt) {
// Client-side guard mirrors the backend rule — a picked future moment
// fails fast with a localized message instead of a WS roundtrip.
if (new Date(this._completedAt).getTime() > Date.now()) {
this._error = t("completed_at_future_error", this.lang);
this._loading = false;
data.restock_quantity = rq;
}
// #99: with a parts section shown, send the explicit selection — it
// replaces the automatic consumes_parts deduction (empty = none used).
// entry_id travels only when the pool is somebody else's (#111), so an
// own-part payload is byte-identical to what shipped before.
if (this.parts.length > 0) {
// The typed quantities, parsed now (see _usedQtyText).
const used: TaskPartLink[] = [];
for (const [key, link] of Object.entries(this._usedParts)) {
const raw = this._usedQtyText[key];
const qty = raw === undefined ? link.quantity : parseFloat(raw.replace(",", "."));
if (!Number.isFinite(qty) || qty < USED_QTY_RANGE[0] || qty > USED_QTY_RANGE[1]) {
this._error = this._rangeError(...USED_QTY_RANGE);
return;
}
// Re-add seconds if the datetime-local input drops them (same
// normalisation as the history-edit dialog).
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
used.push({ ...link, quantity: qty });
}
if (this.readings.length > 0) {
// #161 phase 2: only the meters actually read go out; the backend
// snapshots name + unit per slot id.
const values: Record<string, number> = {};
for (const slot of this.readings) {
const raw = (this._readingValues[slot.id] ?? "").trim();
if (raw === "") continue;
const num = parseFloat(raw.replace(",", "."));
if (!isNaN(num)) values[slot.id] = num;
}
if (Object.keys(values).length > 0) data.reading_values = values;
} else if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
if (this.restockDefault !== null && this._restockQty !== "") {
const rq = parseFloat(this._restockQty);
if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq;
}
// #99: with a parts section shown, send the explicit selection — it
// replaces the automatic consumes_parts deduction (empty = none used).
// entry_id travels only when the pool is somebody else's (#111), so an
// own-part payload is byte-identical to what shipped before.
if (this.parts.length > 0) {
data.used_parts = Object.values(this._usedParts)
.filter((l) => Number.isFinite(l.quantity) && l.quantity > 0)
.map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
await this.hass.connection.sendMessagePromise(data);
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
} catch (e) {
this._error = describeWsError(e, this.lang, t("save_error", this.lang));
} finally {
this._loading = false;
data.used_parts = used
.map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
const res = await runWs(this, data, {
busy: (b) => { this._loading = b; },
lang: this.lang,
fallbackKey: "save_error",
onError: (m) => { this._error = m; },
});
if (res === undefined) return;
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
}
/** #161 phase 2: one field per slot with the previous value as a hint;
@@ -317,9 +357,7 @@ export class MaintenanceCompleteDialog extends LitElement {
/** Seed the backdate field with the current minute (local, seconds zeroed). */
private _pickCompletedAt(): void {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
this._completedAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
this._completedAt = isoMinuteLocal(new Date());
}
render() {
@@ -374,24 +412,33 @@ export class MaintenanceCompleteDialog extends LitElement {
@change=${(e: Event) => {
const next = { ...this._usedParts };
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
else {
delete next[key];
const text = { ...this._usedQtyText };
delete text[key];
this._usedQtyText = text;
}
this._usedParts = next;
}} />
<span
>${pt.name}${pt.owner_name
? html`<span class="used-part-owner"> (${pt.owner_name})</span>`
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${formatQty(pt.stock, pt.unit, L)})` : ""}</span
>
</label>
${checked
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
.value=${String(link.quantity)}
? html`<input class="used-part-qty" type="number" min=${USED_QTY_RANGE[0]} max=${USED_QTY_RANGE[1]} step="0.01"
.value=${this._usedQtyText[key] ?? String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._usedParts = {
...this._usedParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
const raw = (e.target as HTMLInputElement).value;
this._usedQtyText = { ...this._usedQtyText, [key]: raw };
// A valid entry also feeds the live cost suggestion;
// an empty / partial one leaves the last number
// alone and is judged on save.
const v = parseFloat(raw.replace(",", "."));
if (Number.isFinite(v) && v >= USED_QTY_RANGE[0]) {
this._usedParts = { ...this._usedParts, [key]: { ...base, quantity: v } };
}
}} />`
: nothing}
</div>`;
@@ -406,7 +453,7 @@ export class MaintenanceCompleteDialog extends LitElement {
? html`
<label class="field">
<span class="field-label">${t("restock_quantity_label", L)}</span>
<input type="number" step="0.01" min="0.01" class="field-input"
<input type="number" step="0.01" min=${RESTOCK_QTY_RANGE[0]} max=${RESTOCK_QTY_RANGE[1]} class="field-input"
.value=${this._restockQty}
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)} />
</label>`
@@ -432,7 +479,7 @@ export class MaintenanceCompleteDialog extends LitElement {
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
<input type="number" step="0.01" min="0" class="field-input"
<input type="number" step="1" min="0" inputmode="numeric" class="field-input"
.value=${this._duration}
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
</label>
@@ -495,8 +542,10 @@ export class MaintenanceCompleteDialog extends LitElement {
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading || this._missingRequired.length > 0}
title=${this._missingRequired.length
.disabled=${this._loading || this._photos.uploading || this._missingRequired.length > 0}
title=${this._photos.uploading
? t("uploading", L)
: this._missingRequired.length
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
: ""}
>
@@ -11,11 +11,11 @@ import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes";
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import { docCategory, docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import { DOC_FILTER_MIN, DOC_SORT_MODES, asDocSortMode, filterDocuments, sortDocuments, type DocSortMode } from "../helpers/document-filter";
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import type { HomeAssistant } from "../types";
@@ -66,6 +66,10 @@ export class MaintenanceDocumentsSection extends LitElement {
@state() private _dragOver = false;
private _loadedFor: string | null = null;
/** Only the newest list fetch may land (bug audit 2026-09-26): the panel
* REUSES this element when the user moves to another object, and a slow
* answer for the previous object overwrote the new one's list. */
private _loadSeq = 0;
private _localeReady = false;
private _isImage(doc: MaintenanceDocument): boolean {
@@ -89,34 +93,80 @@ export class MaintenanceDocumentsSection extends LitElement {
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
if (this.hass && this.entryId && this._loadedFor !== this.entryId) {
const switched = this._loadedFor !== null;
this._loadedFor = this.entryId;
if (switched) this._resetForObject();
void this._load();
}
}
/** Another object in the same element: nothing of the previous one may
* survive — its list, thumbnails, an open link / edit form (whose Save
* would have written to the NEW object) or a message. */
private _resetForObject(): void {
this._docs = [];
this._loaded = false;
this._thumbs = {};
this._filter = "";
this._error = "";
this._hint = "";
this._addingLink = false;
this._linkUrl = "";
this._linkTitle = "";
this._linkDescription = "";
this._editingId = "";
this._lightboxUrl = "";
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<{ documents: MaintenanceDocument[] }>({
type: "maintenance_supporter/documents/list",
entry_id: this.entryId,
});
this._docs = r.documents || [];
this._loaded = true;
this._error = "";
this._thumbs = {};
void this._loadThumbs();
} catch (e) {
this._error = describeWsError(e, this._lang);
this._loaded = true;
const entryId = this.entryId;
const seq = ++this._loadSeq;
const stale = () => seq !== this._loadSeq || entryId !== this.entryId;
let failure = "";
const r = await runWs<{ documents: MaintenanceDocument[] }>(
this,
{ type: "maintenance_supporter/documents/list", entry_id: entryId },
{ onError: (m) => { failure = m; } },
);
if (stale()) return;
this._loaded = true;
if (r === undefined) {
this._error = failure;
return;
}
this._docs = r?.documents || [];
this._error = "";
this._thumbs = {};
void this._loadThumbs(stale);
}
/** A document write — helpers/ws-run runWs with the busy flag, the error
* line and the list reload (`onDone` runs first). */
private async _write(payload: Record<string, unknown>, onDone?: () => void, fallbackKey?: string): Promise<void> {
this._error = "";
await runWs(this, payload, {
busy: (b) => { this._busy = b; },
fallbackKey,
reload: async () => {
onDone?.();
await this._load();
},
onError: (m) => { this._error = m; },
});
}
/** The signed open / download helpers, their failure on the error line. */
private async _signed(call: () => Promise<unknown>): Promise<void> {
await runWs(this, call, { onError: (m) => { this._error = m; } });
}
/** Pre-sign a serve URL for each image doc so it can render as a thumbnail. */
private async _loadThumbs(): Promise<void> {
private async _loadThumbs(stale: () => boolean = () => false): Promise<void> {
await Promise.all(
this._docs.filter((d) => this._isImage(d)).map(async (d) => {
try {
const url = await this._sign(d);
if (stale()) return;
this._thumbs = { ...this._thumbs, [d.id]: url };
} catch {
/* leave the fallback icon */
@@ -126,8 +176,7 @@ export class MaintenanceDocumentsSection extends LitElement {
}
private _category_of(doc: MaintenanceDocument): string {
const tag = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x));
return tag || "other";
return docCategory(doc);
}
/** Keyboard support for the file-picker <label>s (Enter/Space → open). */
@@ -173,6 +222,9 @@ export class MaintenanceDocumentsSection extends LitElement {
private async _uploadFiles(files: File[], category?: string): Promise<void> {
const cat = category ?? this._category;
// Captured: the element may be moved to another object mid-upload — the
// remaining files still belong to the object the user dropped them on.
const entryId = this.entryId;
this._busy = true;
this._error = "";
this._hint = "";
@@ -184,7 +236,7 @@ export class MaintenanceDocumentsSection extends LitElement {
for (const file of files) {
let doc;
try {
doc = await uploadDocument(this.hass, this.entryId, file, [cat]);
doc = await uploadDocument(this.hass, entryId, file, [cat]);
} catch (e) {
const key = e instanceof Error ? e.message : "";
if (key !== "doc_too_large" && key !== "doc_upload_failed") throw e;
@@ -194,6 +246,7 @@ export class MaintenanceDocumentsSection extends LitElement {
if (doc.duplicate_in_object) dupInObject++;
else if (doc.deduped) deduped++;
}
if (entryId !== this.entryId) return; // the list shown is another object's now
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
else if (deduped) this._hint = t("doc_deduped", this._lang);
await this._load();
@@ -205,11 +258,7 @@ export class MaintenanceDocumentsSection extends LitElement {
}
private async _download(doc: MaintenanceDocument): Promise<void> {
try {
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
await this._signed(() => downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"));
}
/** Open a document for viewing: images in an in-app lightbox, everything else
@@ -219,11 +268,7 @@ export class MaintenanceDocumentsSection extends LitElement {
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
return;
}
try {
await openSignedDocument(this.hass, doc.id);
} catch (e) {
this._error = describeWsError(e, this._lang);
}
await this._signed(() => openSignedDocument(this.hass, doc.id));
}
/** Open a document from a title/row click (not just the small icons): preview
@@ -259,66 +304,44 @@ export class MaintenanceDocumentsSection extends LitElement {
(x) => !(CATEGORIES as readonly string[]).includes(x),
);
const tags = doc.kind === "file" ? [this._editCategory, ...freeTags] : (doc.tags ?? []);
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
await this._write(
{
type: "maintenance_supporter/documents/update",
doc_id: doc.id,
title: this._editTitle.trim() || doc.filename || doc.url || "",
tags,
description: this._editDescription.trim(),
});
this._editingId = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
},
() => { this._editingId = ""; },
);
}
private async _delete(doc: MaintenanceDocument): Promise<void> {
const name = docDisplayName(doc);
if (!window.confirm(t("doc_delete_confirm", this._lang).replace("{name}", name))) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/delete",
doc_id: doc.id,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
await this._write({ type: "maintenance_supporter/documents/delete", doc_id: doc.id });
}
private async _addLink(): Promise<void> {
const url = this._linkUrl.trim();
if (!url) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
const entryId = this.entryId;
await this._write(
{
type: "maintenance_supporter/documents/add_link",
entry_id: this.entryId,
entry_id: entryId,
url,
title: this._linkTitle.trim() || null,
description: this._linkDescription.trim() || null,
});
this._linkUrl = "";
this._linkTitle = "";
this._linkDescription = "";
this._addingLink = false;
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang, t("doc_link_invalid", this._lang));
} finally {
this._busy = false;
}
},
() => {
this._linkUrl = "";
this._linkTitle = "";
this._linkDescription = "";
this._addingLink = false;
},
"doc_link_invalid",
);
}
render() {
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import "./ms-textfield";
import type {
GroupTaskRef,
@@ -76,33 +76,29 @@ export class MaintenanceGroupDialog extends LitElement {
this._error = t("group_name_required", this._lang);
return;
}
this._loading = true;
this._error = "";
try {
const task_refs = this._buildTaskRefs();
if (this._groupId) {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/update",
group_id: this._groupId,
name,
description: this._description,
task_refs,
});
} else {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/create",
name,
description: this._description,
task_refs,
});
}
this._open = false;
this.dispatchEvent(new CustomEvent("group-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
const task_refs = this._buildTaskRefs();
const res = await runWs(
this,
this._groupId
? {
type: "maintenance_supporter/group/update",
group_id: this._groupId,
name,
description: this._description,
task_refs,
}
: {
type: "maintenance_supporter/group/create",
name,
description: this._description,
task_refs,
},
{ busy: (b) => { this._loading = b; }, fallbackKey: "save_error", onError: (m) => { this._error = m; } },
);
if (res === undefined) return;
this._open = false;
this.dispatchEvent(new CustomEvent("group-saved"));
};
render() {
@@ -10,8 +10,11 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { confirmAction } from "../helpers/confirm";
import type { HomeAssistant } from "../types";
interface GroupEntry {
@@ -36,6 +39,8 @@ export class MaintenanceGroupsSectionCard extends LitElement {
@state() private _newName = "";
@state() private _editingId: string | null = null;
@state() private _editingName = "";
/** Operator-write delegation — group CRUD is write tier (canWrite). */
@state() private _access: WriteAccess = NO_DELEGATION;
private _hasInitiallyLoaded = false;
@@ -51,8 +56,12 @@ export class MaintenanceGroupsSectionCard extends LitElement {
return langOf(this.hass);
}
private get _isAdmin(): boolean {
return (this.hass?.user?.is_admin ?? true) as boolean;
/** Group create / rename / delete are WRITE tier on the server: admins,
* plus allowlisted operators with delegation on — the panel's rule
* (helpers/permissions). `is_admin` alone hid the controls from a
* delegated operator (DRY audit 2026-09-26). */
private get _canWrite(): boolean {
return canWrite(this.hass?.user, this._access);
}
updated(changedProps: Map<string, unknown>): void {
@@ -60,40 +69,39 @@ export class MaintenanceGroupsSectionCard extends LitElement {
if (changedProps.has("hass") && this.hass && !this._hasInitiallyLoaded) {
this._hasInitiallyLoaded = true;
void this._load();
void fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; });
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<GroupsResp>({
type: "maintenance_supporter/groups",
});
this._groups = r.groups || {};
this._loaded = true;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
const r = await runWs<GroupsResp>(this, { type: "maintenance_supporter/groups" }, {
onError: (m) => { this._error = m; },
});
if (r === undefined) return;
this._groups = r?.groups || {};
this._loaded = true;
}
/** One group mutation — helpers/ws-run runWs with the busy flag, the
* error line and the list reload (`onDone` runs first). */
private async _act(payload: Record<string, unknown>, onDone?: () => void): Promise<void> {
this._error = "";
await runWs(this, payload, {
busy: (b) => { this._busy = b; },
reload: async () => {
onDone?.();
await this._load();
},
onError: (m) => { this._error = m; },
});
}
private async _addGroup(): Promise<void> {
if (!this._isAdmin) return;
if (!this._canWrite) return;
const name = this._newName.trim();
if (!name) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/create",
name,
});
this._newName = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
await this._act({ type: "maintenance_supporter/group/create", name }, () => { this._newName = ""; });
}
private _startEdit(id: string): void {
@@ -102,43 +110,25 @@ export class MaintenanceGroupsSectionCard extends LitElement {
}
private async _saveEdit(): Promise<void> {
if (!this._isAdmin || !this._editingId) return;
if (!this._canWrite || !this._editingId) return;
const name = this._editingName.trim();
if (!name) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/update",
group_id: this._editingId,
name,
});
await this._act({ type: "maintenance_supporter/group/update", group_id: this._editingId, name }, () => {
this._editingId = null;
this._editingName = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
});
}
private async _deleteGroup(id: string, name: string): Promise<void> {
if (!this._isAdmin) return;
const confirmText = t("group_delete_confirm", this._lang).replace("{name}", name);
if (!window.confirm(confirmText)) return;
this._busy = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/delete",
group_id: id,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
if (!this._canWrite) return;
const ok = await confirmAction(this.hass, {
title: t("delete", this._lang),
message: t("delete_group_confirm", this._lang).replace("{name}", name),
confirmText: t("delete", this._lang),
danger: true,
});
if (!ok) return;
await this._act({ type: "maintenance_supporter/group/delete", group_id: id });
}
private _onDeepLink(): void {
@@ -210,7 +200,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
: html`
<span class="group-name">${g.name || "Unnamed"}</span>
<span class="task-count">${taskCount}</span>
${this._isAdmin
${this._canWrite
? html`
<button class="icon-btn"
title="${t("edit", L)}"
@@ -233,7 +223,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
</div>
`}
${this._isAdmin
${this._canWrite
? html`
<div class="add-row">
<input type="text"
@@ -11,8 +11,12 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, langOf, formatNumber } from "../styles";
import type { HomeAssistant, ReadingSlot, ReadingValue } from "../types";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { PART_QTY_RANGE } from "../helpers/setting-ranges";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import { parseDurationMinutes } from "../helpers/duration";
import { confirmAction } from "../helpers/confirm";
import "./ms-date-field";
import "./history-photo";
import "./ms-photo-picker";
@@ -76,6 +80,12 @@ export class MaintenanceHistoryEditDialog extends LitElement {
@state() private _partOptions: PartOption[] | null = null;
@state() private _partQty: Record<string, number> = {};
private _partQtyOriginal = "";
/** Bumped by every openEdit() and close(): an async answer started under
* an older value belongs to a dialog session that is gone. The draft's
* identity could not tell — every keystroke replaces `_draft` (`_set`),
* so typing before parts/overview answered dropped the parts section
* and the save skipped used_parts (bug audit 2026-09-26 #2). */
private _openGen = 0;
// #161: the edited photo list (seeded with the entry's photos; the docs
// THIS session uploaded are dropped again by an abandoned edit, a removed
@@ -97,6 +107,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
* pass `original_timestamp` (the entry's current timestamp before edit)
* so the backend can find the entry. */
public openEdit(draft: HistoryEntryDraft): void {
this._openGen++;
this._draft = { ...draft };
this._originalSnapshot = { ...draft };
this._error = "";
@@ -147,8 +158,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
/** The object's own parts + pooled parts this task draws on — from the
* instance-wide overview so pooled owners resolve without extra calls. */
private async _loadPartOptions(): Promise<void> {
// The entry as opened: entry_id / task_id / used_parts never change
// while the dialog is open (only the edited fields do).
const draft = this._draft;
if (!draft) return;
const gen = this._openGen;
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/parts/overview",
@@ -158,6 +172,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
consumers: Array<{ entry_id: string; task_id: string }>;
}>;
};
// The dialog was closed or reopened for ANOTHER entry while this was in
// flight: its options (and quantities) belong to that other entry now
// (bug audit 2026-09-26 — out-of-order answers). Edits typed in the
// meantime do NOT count as another session.
if (gen !== this._openGen) return;
const options: PartOption[] = [];
for (const row of result.parts || []) {
const own = row.entry_id === draft.entry_id;
@@ -193,6 +212,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
this._partQty = qty;
this._partQtyOriginal = this._partSelectionKey();
} catch {
if (gen !== this._openGen) return;
this._partOptions = []; // parts UI unavailable — the rest still edits
}
}
@@ -206,6 +226,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
}
public close(): void {
this._openGen++;
this._open = false;
this._error = "";
this._draft = null;
@@ -227,115 +248,124 @@ export class MaintenanceHistoryEditDialog extends LitElement {
private async _delete(): Promise<void> {
if (!this._draft || !this._originalSnapshot) return;
const L = this._lang;
if (!window.confirm(t("history_delete_confirm", L))) return;
this._saving = true;
// The confirm dialog, not the browser's native modal — this dialog is
// also mounted on dashboards (DRY audit 2026-09-26).
const confirmed = await confirmAction(this.hass, {
title: t("history_delete_entry", L),
message: t("history_delete_confirm", L),
confirmText: t("delete", L),
danger: true,
});
if (!confirmed || !this._draft || !this._originalSnapshot) return;
const draft = this._draft;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/history/delete",
entry_id: this._draft.entry_id,
task_id: this._draft.task_id,
timestamp: this._originalSnapshot.original_timestamp,
});
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: { entry_id: this._draft.entry_id, task_id: this._draft.task_id, deleted: true },
bubbles: true,
composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, L);
} finally {
this._saving = false;
}
const ok = await this._runWs({
type: "maintenance_supporter/task/history/delete",
entry_id: draft.entry_id,
task_id: draft.task_id,
timestamp: this._originalSnapshot.original_timestamp,
});
if (ok === undefined) return;
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: { entry_id: draft.entry_id, task_id: draft.task_id, deleted: true },
bubbles: true,
composed: true,
}),
);
this.close();
}
/** helpers/ws-run runWs bound to the Save/Delete busy flag and the error line. */
private _runWs(payload: Record<string, unknown>): Promise<unknown> {
return runWs(this, payload, {
busy: (b) => { this._saving = b; },
onError: (m) => { this._error = m; },
});
}
private async _save(): Promise<void> {
if (!this._draft || !this._originalSnapshot) return;
this._saving = true;
// Save waits for a photo still uploading — it would miss the patch and
// stay behind as an orphan (bug audit 2026-09-26 #2).
if (this._saving || this._photos.uploading) return;
this._error = "";
this._photos.clearError();
try {
const patch: Record<string, unknown> = {
type: "maintenance_supporter/task/history/update",
entry_id: this._draft.entry_id,
task_id: this._draft.task_id,
original_timestamp: this._originalSnapshot.original_timestamp,
};
// Only send fields that actually changed — keeps the patch minimal
// and the WS schema happy (it treats missing as "no change").
if (this._draft.timestamp !== this._originalSnapshot.timestamp) {
patch.timestamp = this._draft.timestamp;
}
if (this._draft.notes !== this._originalSnapshot.notes) {
patch.notes = this._draft.notes;
}
if (this._draft.cost !== this._originalSnapshot.cost) {
patch.cost = this._draft.cost;
}
if (this._draft.duration !== this._originalSnapshot.duration) {
patch.duration = this._draft.duration;
}
if (this._draft.completed_by !== this._originalSnapshot.completed_by) {
patch.completed_by = this._draft.completed_by;
}
// #130: send the parts selection only when it actually changed — the
// backend reconciles stock by the delta, so a no-op must stay silent.
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
patch.used_parts = (this._partOptions || [])
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
.map((o) => ({
part_id: o.part_id,
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
...(o.foreign ? { entry_id: o.entry_id } : {}),
}));
}
// #161 phase 2: readings. The scalar rides the changed-field check
// like notes; the slot map is sent whole when any value differs —
// the backend replaces the snapshot, a missing id means "unread".
if (this._draft.reading_value !== this._originalSnapshot.reading_value) {
patch.reading_value = this._draft.reading_value ?? null;
}
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
const numbers = this._readingNumbers();
const map: Record<string, number | null> = {};
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
patch.reading_values = map;
}
// #161: the photo list, only when it differs from what we opened with.
if (JSON.stringify(this._photos.ids) !== this._photosOriginal) {
patch.photo_doc_ids = this._photos.ids;
}
// Nothing changed → close without WS call
const changedKeys = Object.keys(patch).filter(
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
);
if (changedKeys.length === 0) {
this.close();
return;
}
await this.hass.connection.sendMessagePromise(patch);
this._photos.markAttached(); // saved — they belong to the entry now
// Notify upstream so they can refresh
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: {
entry_id: this._draft.entry_id,
task_id: this._draft.task_id,
new_timestamp: this._draft.timestamp,
},
bubbles: true,
composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._saving = false;
const draft = this._draft;
const original = this._originalSnapshot;
const patch: Record<string, unknown> = {
type: "maintenance_supporter/task/history/update",
entry_id: draft.entry_id,
task_id: draft.task_id,
original_timestamp: original.original_timestamp,
};
// Only send fields that actually changed — keeps the patch minimal
// and the WS schema happy (it treats missing as "no change").
if (draft.timestamp !== original.timestamp) {
patch.timestamp = draft.timestamp;
}
if (draft.notes !== original.notes) {
patch.notes = draft.notes;
}
if (draft.cost !== original.cost) {
patch.cost = draft.cost;
}
if (draft.duration !== original.duration) {
patch.duration = draft.duration;
}
if (draft.completed_by !== original.completed_by) {
patch.completed_by = draft.completed_by;
}
// #130: send the parts selection only when it actually changed — the
// backend reconciles stock by the delta, so a no-op must stay silent.
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
patch.used_parts = (this._partOptions || [])
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
.map((o) => ({
part_id: o.part_id,
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
...(o.foreign ? { entry_id: o.entry_id } : {}),
}));
}
// #161 phase 2: readings. The scalar rides the changed-field check
// like notes; the slot map is sent whole when any value differs —
// the backend replaces the snapshot, a missing id means "unread".
if (draft.reading_value !== original.reading_value) {
patch.reading_value = draft.reading_value ?? null;
}
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
const numbers = this._readingNumbers();
const map: Record<string, number | null> = {};
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
patch.reading_values = map;
}
// #161: the photo list, only when it differs from what we opened with.
if (JSON.stringify(this._photos.ids) !== this._photosOriginal) {
patch.photo_doc_ids = this._photos.ids;
}
// Nothing changed → close without WS call
const changedKeys = Object.keys(patch).filter(
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
);
if (changedKeys.length === 0) {
this.close();
return;
}
if ((await this._runWs(patch)) === undefined) return;
this._photos.markAttached(); // saved — they belong to the entry now
// Notify upstream so they can refresh
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: {
entry_id: draft.entry_id,
task_id: draft.task_id,
new_timestamp: draft.timestamp,
},
bubbles: true,
composed: true,
}),
);
this.close();
}
render() {
@@ -343,9 +373,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
const L = this._lang;
const d = this._draft;
const error = this._error || this._photos.errorText(L);
return html`
<div class="backdrop" @click=${this.close}></div>
<div class="dialog" role="dialog" aria-modal="true">
return renderModalShell(() => this.close(), html`
<h2>${t("history_edit_title", L)}</h2>
<div class="entry-type">
<ha-icon icon="mdi:tag-outline"></ha-icon>
@@ -387,11 +415,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
</label>
<label>
<span>${t("duration", L)}</span>
<input type="number" min="0"
<input type="number" min="0" step="1" inputmode="numeric"
.value=${d.duration != null ? String(d.duration) : ""}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this._set("duration", v ? Number(v) : null);
// Whole minutes, like the server stores them (helpers/duration).
this._set("duration", parseDurationMinutes((e.target as HTMLInputElement).value));
}} />
</label>
</div>
@@ -411,7 +439,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
}} />
<span class="part-label">${o.name}${o.foreign && o.object_name ? ` (${o.object_name})` : ""}</span>
${qty > 0 ? html`
<input class="part-qty" type="number" min="0.01" max="999" step="0.01"
<input class="part-qty" type="number" min=${PART_QTY_RANGE[0]} max=${PART_QTY_RANGE[1]} step="0.01"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
@@ -449,12 +477,16 @@ export class MaintenanceHistoryEditDialog extends LitElement {
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
${t("cancel", L)}
</button>
<button class="save" @click=${this._save} ?disabled=${this._saving}>
<button class="save" @click=${this._save} ?disabled=${this._saving || this._photos.uploading}
title=${this._photos.uploading ? t("uploading", L) : ""}>
${this._saving ? t("saving", L) : t("save", L)}
</button>
</div>
</div>
`;
`);
}
protected updated(changed: Map<string, unknown>): void {
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
}
/** #161 phase 2: per-slot rows for a slot task (entry snapshot ∪ current
@@ -490,26 +522,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
</label>`;
}
static styles = [photoPickerStyles, css`
:host { display: contents; }
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
}
.dialog {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 95vw; max-width: 480px;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
color: var(--primary-text-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
padding: 20px;
display: flex; flex-direction: column; gap: 12px;
z-index: 101;
max-height: 90vh; overflow: auto;
}
static styles = [photoPickerStyles, modalShellStyles, css`
:host { display: contents; --ms-modal-max-height: 90vh; --ms-modal-gap: 12px; }
h2 { margin: 0; font-size: 18px; }
.entry-type {
display: flex; align-items: center; gap: 6px;
@@ -5,7 +5,7 @@ import { property, state } from "lit/decorators.js";
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import "./ms-textfield";
import "./ms-date-field";
@@ -74,48 +74,31 @@ export class MaintenanceObjectDialog extends LitElement {
private async _save(): Promise<void> {
if (this._loading) return; // synchronous re-entry guard (double-click)
if (!this._name.trim()) return;
this._loading = true;
this._error = "";
try {
if (this._entryId) {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/update",
entry_id: this._entryId,
name: this._name,
manufacturer: this._manufacturer || null,
model: this._model || null,
serial_number: this._serialNumber || null,
area_id: this._areaId || null,
installation_date: this._installationDate || null,
warranty_expiry: this._warrantyExpiry || null,
documentation_url: this._documentationUrl.trim() || null,
notes: this._notes.trim() || null,
ha_device_id: this._haDeviceId || null,
parent_entry_id: this._parentEntryId || null,
});
} else {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/create",
name: this._name,
manufacturer: this._manufacturer || null,
model: this._model || null,
serial_number: this._serialNumber || null,
area_id: this._areaId || null,
installation_date: this._installationDate || null,
warranty_expiry: this._warrantyExpiry || null,
documentation_url: this._documentationUrl.trim() || null,
notes: this._notes.trim() || null,
ha_device_id: this._haDeviceId || null,
parent_entry_id: this._parentEntryId || null,
});
}
this._open = false;
this.dispatchEvent(new CustomEvent("object-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
// One field set for create and update (the two payloads were copies).
const fields = {
name: this._name,
manufacturer: this._manufacturer || null,
model: this._model || null,
serial_number: this._serialNumber || null,
area_id: this._areaId || null,
installation_date: this._installationDate || null,
warranty_expiry: this._warrantyExpiry || null,
documentation_url: this._documentationUrl.trim() || null,
notes: this._notes.trim() || null,
ha_device_id: this._haDeviceId || null,
parent_entry_id: this._parentEntryId || null,
};
const res = await runWs(
this,
this._entryId
? { type: "maintenance_supporter/object/update", entry_id: this._entryId, ...fields }
: { type: "maintenance_supporter/object/create", ...fields },
{ busy: (b) => { this._loading = b; }, fallbackKey: "save_error", onError: (m) => { this._error = m; } },
);
if (res === undefined) return;
this._open = false;
this.dispatchEvent(new CustomEvent("object-saved"));
}
private _parentChoices(): MaintenanceObjectResponse[] {
@@ -12,8 +12,13 @@ import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
import { property, state } from "lit/decorators.js";
import { t, STATUS_COLORS, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { t, langOf } from "../styles";
import { runWs } from "../helpers/ws-run";
import { statusColor, statusKey, statusLabel } from "../renderers/status";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { confirmAction } from "../helpers/confirm";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
interface ObjectFull {
@@ -30,6 +35,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
@state() private _data: ObjectFull | null = null;
@state() private _busy = false;
@state() private _error = "";
/** Operator-write delegation — Add task / Edit / Archive / Delete follow
* canWrite() like the panel (DRY audit 2026-09-26). */
@state() private _access: WriteAccess = NO_DELEGATION;
private get _lang(): string {
return langOf(this.hass);
@@ -39,7 +47,10 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
this._entryId = entryId;
this._error = "";
this._open = true;
await this._load();
await Promise.all([
this._load(),
fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; }),
]);
}
public close(): void {
@@ -50,15 +61,21 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
private async _load(): Promise<void> {
if (!this._entryId) return;
try {
const r = await this.hass.connection.sendMessagePromise<ObjectFull>({
type: "maintenance_supporter/object",
entry_id: this._entryId,
});
this._data = r;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
const r = await runWs<ObjectFull>(
this,
{ type: "maintenance_supporter/object", entry_id: this._entryId },
{ onError: (m) => { this._error = m; } },
);
if (r !== undefined) this._data = r;
}
/** helpers/ws-run runWs bound to this dialog's busy flag and error line. */
private _runWs(payload: Record<string, unknown>): Promise<unknown> {
this._error = "";
return runWs(this, payload, {
busy: (b) => { this._busy = b; },
onError: (m) => { this._error = m; },
});
}
private _onEditObject(): void {
@@ -79,58 +96,50 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
private async _onDelete(): Promise<void> {
if (!this._entryId || !this._data) return;
const confirmText = t("delete_object_confirm", this._lang)
|| `Delete "${this._data.object.name}" and all its tasks?`;
if (!window.confirm(confirmText)) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/delete",
entry_id: this._entryId,
});
this.dispatchEvent(
new CustomEvent("object-deleted", {
detail: { entry_id: this._entryId },
bubbles: true, composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
const ok = await confirmAction(this.hass, {
title: t("delete", this._lang),
message: t("delete_object_confirm", this._lang),
confirmText: t("delete", this._lang),
danger: true,
});
if (!ok) return;
const entryId = this._entryId;
if ((await this._runWs({ type: "maintenance_supporter/object/delete", entry_id: entryId })) === undefined) return;
this.dispatchEvent(
new CustomEvent("object-deleted", {
detail: { entry_id: entryId },
bubbles: true, composed: true,
}),
);
this.close();
}
private async _onArchiveObject(): Promise<void> {
if (!this._entryId || !this._data) return;
const archived = !!this._data.object.archived;
if (!archived) {
const confirmText = t("confirm_archive_object", this._lang);
if (!window.confirm(confirmText)) return;
}
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: archived
? "maintenance_supporter/object/unarchive"
: "maintenance_supporter/object/archive",
entry_id: this._entryId,
const ok = await confirmAction(this.hass, {
title: t("archive_object", this._lang),
message: t("confirm_archive_object", this._lang),
confirmText: t("archive_object", this._lang),
});
this.dispatchEvent(
new CustomEvent("object-changed", {
detail: { entry_id: this._entryId },
bubbles: true, composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
if (!ok) return;
}
const entryId = this._entryId;
const res = await this._runWs({
type: archived
? "maintenance_supporter/object/unarchive"
: "maintenance_supporter/object/archive",
entry_id: entryId,
});
if (res === undefined) return;
this.dispatchEvent(
new CustomEvent("object-changed", {
detail: { entry_id: entryId },
bubbles: true, composed: true,
}),
);
this.close();
}
private _onTaskClick(taskId: string): void {
@@ -148,11 +157,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
const data = this._data;
const obj = data?.object;
const tasks = data?.tasks || [];
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
const writer = canWrite(this.hass?.user, this._access);
return html`
<div class="backdrop" @click=${this.close}></div>
<div class="dialog" role="dialog" aria-modal="true">
return renderModalShell(() => this.close(), html`
${data && obj
? html`
<div class="header">
@@ -173,9 +180,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
<div class="task-list">
${tasks.map((task) => html`
<div class="task-row" @click=${() => this._onTaskClick(task.id)}>
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
<span class="status-dot" style="background: ${statusColor(task)}"></span>
<span class="task-name">${task.name}</span>
<span class="task-status">${t(task.status || "ok", L)}</span>
<span class="task-status ${statusKey(task)}">${statusLabel(statusKey(task), L)}</span>
</div>
`)}
</div>
@@ -191,7 +198,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
`
: nothing}
${isAdmin
${writer
? html`
<div class="actions">
<button class="btn primary" @click=${this._onAddTask} ?disabled=${this._busy}>
@@ -215,8 +222,11 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
: nothing}
`
: html`<div class="loading">${t("loading", L)}</div>`}
</div>
`;
`);
}
protected updated(changed: Map<string, unknown>): void {
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
}
private _renderMetaRow(obj: MaintenanceObject) {
@@ -251,23 +261,8 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
`;
}
static styles = css`
static styles = [modalShellStyles, css`
:host { display: contents; }
.backdrop {
position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.5);
}
.dialog {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 95vw; max-width: 480px;
max-height: 92vh; overflow: auto;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
color: var(--primary-text-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
padding: 20px; z-index: 101;
display: flex; flex-direction: column; gap: 14px;
}
.header { display: flex; flex-direction: column; gap: 6px; }
.title { font-size: 20px; font-weight: 600; }
.meta { display: flex; flex-direction: column; gap: 4px; padding-top: 4px; border-top: 1px solid var(--divider-color); }
@@ -311,7 +306,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
.btn ha-icon { --mdc-icon-size: 16px; }
.loading { padding: 24px; text-align: center; color: var(--secondary-text-color); }
.error { padding: 8px; border-radius: 6px; background: rgba(211,47,47,0.1); color: var(--error-color); font-size: 13px; }
`;
`];
}
if (!customElements.get("maintenance-object-quick-actions-dialog")) {
@@ -15,8 +15,8 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf, formatCost } from "../styles";
import { describeWsError } from "../ws-errors";
import { t, ensureLocale, langOf, formatCost, formatNumber, formatQty } from "../styles";
import { runWs } from "../helpers/ws-run";
import type { HomeAssistant, MaintenancePart } from "../types";
// Per-part document links (v2.26) — the task-documents component in part mode.
import "./task-documents";
@@ -83,17 +83,14 @@ export class MaintenancePartsSection extends LitElement {
this.dispatchEvent(new CustomEvent("parts-changed", { bubbles: true, composed: true }));
}
private async _send<T>(msg: Record<string, unknown>): Promise<T | null> {
this._busy = true;
/** helpers/ws-run runWs bound to the busy flag and the error line;
* undefined = refused (the reason is on the error line). */
private _send<T>(msg: Record<string, unknown>): Promise<T | null | undefined> {
this._error = "";
try {
return await this.hass.connection.sendMessagePromise<T>(msg);
} catch (err) {
this._error = describeWsError(err, this._lang);
return null;
} finally {
this._busy = false;
}
return runWs<T>(this, msg, {
busy: (b) => { this._busy = b; },
onError: (m) => { this._error = m; },
});
}
private _openAdd(): void {
@@ -141,11 +138,11 @@ export class MaintenancePartsSection extends LitElement {
private async _save(): Promise<void> {
const f = this._editing;
if (!f || !f.name.trim()) return;
if (this._busy || !f || !f.name.trim()) return;
const payload = this._formValue(f);
const type = f.id ? "maintenance_supporter/part/update" : "maintenance_supporter/part/create";
const result = await this._send<{ part_id?: string }>(f.id ? { type, part_id: f.id, ...payload } : { type, ...payload });
if (result !== null) {
if (result !== undefined) {
this._editing = null;
this._notifyChanged();
}
@@ -160,10 +157,14 @@ export class MaintenancePartsSection extends LitElement {
entry_id: this.entryId,
part_id: part.id,
});
if (result !== null) this._notifyChanged();
if (result !== undefined) this._notifyChanged();
}
private async _restock(part: MaintenancePart): Promise<void> {
// Enter + a click on ✓ (or a double Enter) fired two restocks — the
// stock went up twice (bug audit 2026-09-26). _send sets _busy before
// its first await, so the second call lands here and stops.
if (this._busy) return;
const qty = parseFloat(this._restockQty);
if (!Number.isFinite(qty) || qty === 0) {
// Don't silently swallow a no-op amount — keep the input open and mark
@@ -179,9 +180,9 @@ export class MaintenancePartsSection extends LitElement {
delta: qty,
});
this._restockFor = null;
if (result !== null) {
if (result !== undefined) {
// Instant local feedback; the panel refresh follows via parts-changed.
part.stock = result.stock;
part.stock = result?.stock;
this.requestUpdate();
this._notifyChanged();
}
@@ -208,8 +209,8 @@ export class MaintenancePartsSection extends LitElement {
: part.name}
${tracked
? html`<span class="stock-badge ${part.is_low ? "low" : ""}"
>${part.stock}${part.unit ? ` ${part.unit}` : ""}${part.reorder_threshold != null
? html`<span class="threshold">/${part.reorder_threshold}</span>`
>${formatQty(part.stock!, part.unit, L)}${part.reorder_threshold != null
? html`<span class="threshold">/${formatNumber(part.reorder_threshold, L)}</span>`
: nothing}</span
>`
: nothing}
@@ -242,7 +243,7 @@ export class MaintenancePartsSection extends LitElement {
if (e.key === "Escape") this._restockFor = null;
}}
/>
<ha-icon-button title=${t("save", L)} @click=${() => this._restock(part)}
<ha-icon-button title=${t("save", L)} .disabled=${this._busy} @click=${() => this._restock(part)}
><ha-icon icon="mdi:check"></ha-icon
></ha-icon-button>
`
@@ -13,7 +13,7 @@ import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
interface ViewsResponse {
@@ -65,40 +65,28 @@ export class MaintenanceSavedViewsDialog extends LitElement {
private _save = async (): Promise<void> => {
const name = this._name.trim();
if (!name || this._busy || !this._filters) return;
this._busy = true;
this._error = "";
try {
const res = await this.hass.connection.sendMessagePromise<ViewsResponse>({
type: "maintenance_supporter/views/save",
name,
filters: this._filters,
});
this._name = "";
this._emitChanged(res.views || []);
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
const res = await this._runWs({ type: "maintenance_supporter/views/save", name, filters: this._filters });
if (res === undefined) return;
this._name = "";
this._emitChanged(res?.views || []);
};
private _delete = async (viewId: string): Promise<void> => {
if (this._busy) return;
this._busy = true;
this._error = "";
try {
const res = await this.hass.connection.sendMessagePromise<ViewsResponse>({
type: "maintenance_supporter/views/delete",
view_id: viewId,
});
this._emitChanged(res.views || []);
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
const res = await this._runWs({ type: "maintenance_supporter/views/delete", view_id: viewId });
if (res === undefined) return;
this._emitChanged(res?.views || []);
};
/** helpers/ws-run runWs bound to the busy flag and the error line. */
private _runWs(payload: Record<string, unknown>): Promise<ViewsResponse | null | undefined> {
this._error = "";
return runWs<ViewsResponse>(this, payload, {
busy: (b) => { this._busy = b; },
onError: (m) => { this._error = m; },
});
}
render() {
if (!this._open) return html``;
const L = this._lang;
@@ -4,7 +4,8 @@ import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { SEASONAL_FACTOR_RANGE } from "../helpers/setting-ranges";
import type { HomeAssistant } from "../types";
const MONTH_KEYS = [
@@ -56,7 +57,7 @@ export class SeasonalOverridesDialog extends LitElement {
this._error = `${t("month_" + ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"][i], this._lang)}: ${t("seasonal_override_invalid", this._lang)}`;
return null;
}
if (num < 0.1 || num > 5.0) {
if (num < SEASONAL_FACTOR_RANGE[0] || num > SEASONAL_FACTOR_RANGE[1]) {
this._error = t("seasonal_override_range", this._lang);
return null;
}
@@ -68,42 +69,32 @@ export class SeasonalOverridesDialog extends LitElement {
private _save = async (): Promise<void> => {
const overrides = this._buildOverrides();
if (overrides === null) return;
this._loading = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/seasonal_overrides",
entry_id: this._entryId,
task_id: this._taskId,
overrides,
});
this._open = false;
this.dispatchEvent(new CustomEvent("overrides-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
if ((await this._send(overrides)) === undefined) return;
this._open = false;
this.dispatchEvent(new CustomEvent("overrides-saved"));
};
private _clearAll = async (): Promise<void> => {
this._loading = true;
/** helpers/ws-run runWs: the overrides write with the loading flag and
* the error line ("Save failed" for a non-WS error). */
private _send(overrides: Record<number, number>): Promise<unknown> {
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/seasonal_overrides",
entry_id: this._entryId,
task_id: this._taskId,
overrides: {},
});
this._values = new Array(12).fill("");
this._open = false;
this.dispatchEvent(new CustomEvent("overrides-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
return runWs(this, {
type: "maintenance_supporter/task/seasonal_overrides",
entry_id: this._entryId,
task_id: this._taskId,
overrides,
}, {
busy: (b) => { this._loading = b; },
fallbackKey: "save_error",
onError: (m) => { this._error = m; },
});
}
private _clearAll = async (): Promise<void> => {
if ((await this._send({})) === undefined) return;
this._values = new Array(12).fill("");
this._open = false;
this.dispatchEvent(new CustomEvent("overrides-saved"));
};
render() {
@@ -118,7 +109,7 @@ export class SeasonalOverridesDialog extends LitElement {
${MONTH_KEYS.map((key, i) => html`
<label class="month">
<span class="mn">${t(key, L)}</span>
<input type="number" step="0.1" min="0.1" max="5.0"
<input type="number" step="0.1" min=${SEASONAL_FACTOR_RANGE[0]} max=${SEASONAL_FACTOR_RANGE[1]}
placeholder="1.0"
.value=${this._values[i]}
@input=${(e: Event) => {
@@ -5,17 +5,20 @@ import { property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { live } from "lit/directives/live.js";
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
import { t, langOf, personStyles, syncCurrencyDecimals} from "../styles";
import { t, langOf, personStyles, syncCurrencyDecimals, formatNumber } from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
import { invalidateSettingsCache } from "../helpers/settings-cache";
import { SETTING_INT_RANGES, settingIntRange } from "../helpers/setting-ranges";
import { invalidateSettingsCache, ROW_ACTION_STYLES } from "../helpers/settings-cache";
import { SETTING_INT_RANGES, VACATION_BUFFER_DAYS_RANGE, settingIntRange } from "../helpers/setting-ranges";
import { ToastTimer } from "../helpers/toast";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { runWs } from "../helpers/ws-run";
import { DOCS_ARCHIVE_MAX_BYTES, postMultipart } from "../helpers/photo-upload";
import { countryName, detectionReasons, dwellingLabel, type HomeProfile } from "../helpers/home-profile";
import "./ms-date-field";
/** One household member and the notify services they actually resolve to.
@@ -37,6 +40,9 @@ interface SettingsResponse {
objects_table_columns?: string[];
/** v2.21: template-gallery curation — hidden template ids. */
disabled_template_ids?: string[];
/** v2.93: home profile dwelling type — auto | house | apartment. */
home_type?: string;
home_region?: string;
general: {
default_warning_days: number;
notifications_enabled: boolean;
@@ -501,9 +507,18 @@ export class MaintenanceSettingsView extends LitElement {
}
};
private readonly _toastTimer = new ToastTimer();
private _showToast(msg: string): void {
this._toast = msg;
setTimeout(() => { this._toast = ""; }, 3000);
this._toastTimer.schedule(() => { this._toast = ""; });
}
disconnectedCallback(): void {
super.disconnectedCallback();
// A pending hide must not fire into a detached view (DRY audit 2026-09-26).
this._toastTimer.clear();
this._toast = "";
}
/** One WS round-trip; a rejection becomes a toast carrying the server's
@@ -595,6 +610,7 @@ export class MaintenanceSettingsView extends LitElement {
${this._renderVacation(L)}
${this._renderPrintQr(L)}
${this._renderImportExport(L)}
${this._renderHomeProfile(L)}
${this._renderTemplateToggles(L)}
${this._toast ? html`<div class="settings-toast">${this._toast}</div>` : nothing}
`;
@@ -705,6 +721,8 @@ export class MaintenanceSettingsView extends LitElement {
@state() private _allTemplates: Array<{ id: string; name: string; category: string; disabled?: boolean }> = [];
@state() private _templateCategories: Record<string, Record<string, string>> = {};
/** v2.93: served with the templates read (see helpers/home-profile.ts). */
@state() private _homeProfile: HomeProfile | null = null;
@state() private _tplOpenGroups: Set<string> = new Set();
// One-shot request guard: keyed on a plain flag, NOT on the result being
@@ -719,15 +737,102 @@ export class MaintenanceSettingsView extends LitElement {
const res = await this.hass.connection.sendMessagePromise<{
templates: Array<{ id: string; name: string; category: string; disabled?: boolean }>;
categories: Record<string, Record<string, string>>;
profile?: HomeProfile;
}>({
type: "maintenance_supporter/templates",
language: this._lang,
});
this._allTemplates = res.templates || [];
this._templateCategories = res.categories || {};
this._homeProfile = res.profile ?? null;
} catch { /* section renders empty; retried on next open */ }
}
// --- Section: Home profile (v2.93) ---
private _renderHomeProfile(L: string) {
this._loadTemplates();
const p = this._homeProfile;
const setting = this._settings!.home_type || "auto";
const c = p?.climate;
const climate = c && c.koppen
? t("home_climate_value", L)
.replace("{koppen}", c.koppen)
.replace("{cold}", String(c.coldest_c ?? "–"))
.replace("{warm}", String(c.warmest_c ?? "–"))
: t("home_climate_unknown", L);
const country = countryName(p?.country, L);
return html`
<div class="settings-section" data-section="home_profile">
<h3>${t("home_profile_title", L)}</h3>
<p class="section-desc">${t("home_profile_hint", L)}</p>
<label class="setting-row">
<span class="setting-label">${t("home_type_label", L)}</span>
<select class="home-type" .value=${live(setting)}
@change=${(e: Event) => void this._setHomeType((e.target as HTMLSelectElement).value)}>
<option value="auto" ?selected=${setting === "auto"}>
${t("home_type_auto", L).replace("{detected}", dwellingLabel(p?.dwelling_detected ?? "unknown", L))}
</option>
<option value="house" ?selected=${setting === "house"}>${t("home_dwelling_house", L)}</option>
<option value="apartment" ?selected=${setting === "apartment"}>${t("home_dwelling_apartment", L)}</option>
</select>
</label>
${p && p.dwelling_reasons.length
? html`<div class="setting-hint home-detected">
${t("home_detected_from", L).replace("{reasons}", detectionReasons(p.dwelling_reasons, L))}
</div>`
: nothing}
<div class="setting-row home-climate">
<span class="setting-label">${t("home_climate_label", L)}</span>
<span class="home-climate-value">${country ? `${country} · ` : ""}${climate}</span>
</div>
${p?.regions?.length ? this._renderHomeRegion(p, L) : nothing}
${p?.hemisphere === "south" ? html`<div class="setting-hint">${t("home_hemisphere_south", L)}</div>` : nothing}
${p && p.traits.length
? html`<div class="home-traits">
${p.traits.map((tr) => html`<span class="home-trait">${t(`home_trait_${tr}`, L)}</span>`)}
</div>`
: nothing}
</div>
`;
}
/** 2.94: the state / province whose rules the templates follow — shown
* only where the country has such rules (data/regions). */
private _renderHomeRegion(p: HomeProfile, L: string) {
const regions = p.regions || [];
const setting = this._settings!.home_region || "auto";
const value = regions.some((r) => r.code === setting) ? setting : "auto";
const detected = p.region_detected_name || t("home_region_none", L);
return html`
<label class="setting-row">
<span class="setting-label">${t("home_region_label", L)}</span>
<select class="home-region" .value=${live(value)}
@change=${(e: Event) => void this._setHomeRegion((e.target as HTMLSelectElement).value)}>
<option value="auto" ?selected=${value === "auto"}>${t("home_type_auto", L).replace("{detected}", detected)}</option>
${regions.map((r) => html`<option value=${r.code} ?selected=${value === r.code}>${r.name}</option>`)}
</select>
</label>
<div class="setting-hint">${t("home_region_hint", L)}</div>
`;
}
private async _setHomeRegion(value: string): Promise<void> {
if (await this._updateSetting("home_region", value)) {
// The region changes intervals and which checks exist — refetch.
this._templatesRequested = false;
await this._loadTemplates();
}
}
private async _setHomeType(value: string): Promise<void> {
if (await this._updateSetting("home_type", value)) {
// The dwelling drives the recommendations — refetch the profile.
this._templatesRequested = false;
await this._loadTemplates();
}
}
private _renderTemplateToggles(L: string) {
this._loadTemplates();
const hidden = new Set(this._settings!.disabled_template_ids || []);
@@ -916,7 +1021,7 @@ export class MaintenanceSettingsView extends LitElement {
<span class="setting-label">${t("settings_row_actions", L)}</span>
<select .value=${live(g.row_action_style || "buttons_compact")}
@change=${(e: Event) => this._updateSetting("row_action_style", (e.target as HTMLSelectElement).value)}>
${(["buttons_compact", "buttons", "icons"] as const).map((v) => html`
${ROW_ACTION_STYLES.map((v) => html`
<option value=${v} ?selected=${(g.row_action_style || "buttons_compact") === v}>${t(`row_actions_${v}`, L)}</option>`)}
</select>
</label>
@@ -1336,8 +1441,8 @@ export class MaintenanceSettingsView extends LitElement {
</div>
<label class="vac-field">
<span class="filter-label">${t("vacation_buffer", L)}</span>
<input type="number" min="0" max="14" .value=${String(this._vacBuffer)}
@change=${(e: Event) => this._setVacationBuffer(parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]} .value=${String(this._vacBuffer)}
@change=${(e: Event) => this._setVacationBuffer(e)} />
</label>
</div>
@@ -1499,9 +1604,21 @@ export class MaintenanceSettingsView extends LitElement {
this._saveVacation(patch);
}
private _setVacationBuffer(value: number): void {
if (value < 0 || value > 14) return;
this._saveVacation({ buffer_days: value });
/** Out of range used to be dropped SILENTLY here while the Lovelace
* vacation card sent it and showed the server's error — now both reject
* it up front with the same message (DRY audit 2026-09-26). */
private _setVacationBuffer(e: Event): void {
const input = e.target as HTMLInputElement;
const [min, max] = VACATION_BUFFER_DAYS_RANGE;
const v = Number(input.value);
if (input.value.trim() !== "" && Number.isInteger(v) && v >= min && v <= max) {
this._saveVacation({ buffer_days: v });
return;
}
this._showToast(
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
);
input.value = String(this._vacBuffer);
}
private _toggleVacationExempt(taskId: string, on: boolean): void {
@@ -2001,24 +2118,23 @@ export class MaintenanceSettingsView extends LitElement {
try {
const form = new FormData();
form.append("file", file, file.name);
const resp = await fetch("/api/maintenance_supporter/documents/archive", {
method: "POST",
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
body: form,
});
if (!resp.ok) {
this._showToast(t("action_error", this._lang));
} else {
const result = (await resp.json()) as { blobs_written: number; documents_created: number };
this._showToast(
t("settings_docs_import_success", this._lang)
.replace("{blobs}", String(result.blobs_written ?? 0))
.replace("{docs}", String(result.documents_created ?? 0))
);
this.dispatchEvent(new CustomEvent("settings-changed"));
}
} catch {
this._showToast(t("action_error", this._lang));
const result = await postMultipart<{ blobs_written: number; documents_created: number }>(
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large",
);
this._showToast(
t("settings_docs_import_success", this._lang)
.replace("{blobs}", String(result.blobs_written ?? 0))
.replace("{docs}", String(result.documents_created ?? 0))
);
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch (e) {
// A 413 names the limit; anything else stays the generic message.
this._showToast(
e instanceof Error && e.message === "docs_archive_too_large"
? t("docs_archive_too_large", this._lang)
.replace("{max}", `${formatNumber(DOCS_ARCHIVE_MAX_BYTES / (1024 * 1024), this._lang)} MB`)
: t("action_error", this._lang),
);
}
input.value = "";
this._docArchiveLoading = false;
@@ -2061,6 +2177,12 @@ export class MaintenanceSettingsView extends LitElement {
margin: 0 0 4px 0;
font-size: 16px;
}
.home-climate-value { font-size: 14px; color: var(--secondary-text-color); text-align: right; }
.home-traits { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.home-trait {
font-size: 12px; padding: 3px 8px; border-radius: 12px;
background: var(--secondary-background-color); color: var(--primary-text-color);
}
.section-desc {
font-size: 13px;
color: var(--secondary-text-color);
@@ -2173,7 +2295,12 @@ export class MaintenanceSettingsView extends LitElement {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 14px;
flex-shrink: 0;
/* May shrink: a select is as wide as its longest option, and German
"Buttons (auf dem Handy nur Symbole)" pushed the row 75 px past a
360 px screen (overflow sweep 2026-09-27). */
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
}
.settings-actions {
@@ -10,7 +10,7 @@ import { LitElement, html, css, nothing } from "lit";
import { docDisplayName } from "../helpers/document-categories";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { openSignedDocument } from "../helpers/document-url";
import { isSafeHttpUrl } from "../helpers/url";
import { formatBytes } from "../helpers/format-bytes";
@@ -58,6 +58,9 @@ export class MaintenanceStorageSectionCard extends LitElement {
private _initiallyLoaded = false;
private _searchTimer = 0;
/** Only the newest query's answer may land: a slow reply for "fil" used to
* overwrite the one for "filter" (bug audit 2026-09-26). */
private _searchSeq = 0;
private get _lang(): string {
return langOf(this.hass);
@@ -75,18 +78,14 @@ export class MaintenanceStorageSectionCard extends LitElement {
}
private async _load(): Promise<void> {
this._busy = true;
try {
this._summary = await this.hass.connection.sendMessagePromise<StorageSummary>({
type: "maintenance_supporter/documents/storage",
});
this._error = "";
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._loaded = true;
this._busy = false;
}
const r = await runWs<StorageSummary>(this, { type: "maintenance_supporter/documents/storage" }, {
busy: (b) => { this._busy = b; },
onError: (m) => { this._error = m; },
});
this._loaded = true;
if (r === undefined) return;
this._summary = r;
this._error = "";
}
private _nameFor(objectId: string): string {
@@ -121,20 +120,24 @@ export class MaintenanceStorageSectionCard extends LitElement {
private async _doSearch(): Promise<void> {
const q = this._query.trim();
const seq = ++this._searchSeq;
if (!q) {
this._results = [];
return;
}
try {
const r = await this.hass.connection.sendMessagePromise<{ results: SearchResult[] }>({
type: "maintenance_supporter/documents/search",
query: q,
});
this._results = r.results || [];
} catch (e) {
this._error = describeWsError(e, this._lang);
let failure = "";
const r = await runWs<{ results: SearchResult[] }>(
this,
{ type: "maintenance_supporter/documents/search", query: q },
{ onError: (m) => { failure = m; } },
);
if (seq !== this._searchSeq) return;
if (r === undefined) {
this._error = failure;
this._results = [];
return;
}
this._results = r?.results || [];
}
private async _openResult(doc: SearchResult): Promise<void> {
@@ -145,11 +148,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
return;
}
try {
await openSignedDocument(this.hass, doc.id);
} catch (e) {
this._error = describeWsError(e, this._lang);
}
await runWs(this, () => openSignedDocument(this.hass, doc.id), { onError: (m) => { this._error = m; } });
}
private _renderResult(doc: SearchResult, L: string) {
@@ -320,16 +319,21 @@ export class MaintenanceStorageSectionCard extends LitElement {
.result-open { color: var(--secondary-text-color, #888); --mdc-icon-size: 18px; flex: none; }
.search-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 2px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
/* Phone width (2026-09-27): the size moves to a line of its own before
it could slide under the refresh button; only the title shortens. */
.toggle {
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; flex: 1; min-width: 0;
background: none; border: none; padding: 4px 0; margin: 0; cursor: pointer;
font: inherit; color: var(--primary-text-color); text-align: left;
}
.toggle:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 6px; }
.chevron { --mdc-icon-size: 22px; color: var(--secondary-text-color, #888); flex: none; }
.title-text { font-size: 16px; font-weight: 500; }
.title-text {
font-size: 16px; font-weight: 500; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.header-summary {
margin-left: auto; display: flex; align-items: center; gap: 8px;
margin-left: auto; display: flex; align-items: center; gap: 8px; flex: none;
font-size: 14px; font-weight: 600; white-space: nowrap;
}
.header-summary .saved { color: var(--success-color, #4caf50); font-weight: 500; }
@@ -6,13 +6,21 @@
* every task arrives with its sensor threshold trigger PRE-WIRED (below N
* hours left / below N % remaining, auto-resolving on replacement). The wiring
* comes from the server-side source-verified catalog — never from this client.
*
* 2.94 (duplicates in a real home: the washer and dryer adopted as NEW objects
* next to the user's own, a second wallbox, lubrication tasks under other
* names): the target defaults to the existing object the device most likely
* is, every task has its own tick box, a task the target probably already has
* under another name starts unticked with that name, tasks it has by name are
* listed as already there, and a before/after line says what adopting does.
* Picking another target asks the server again (integration_setups/preview).
*/
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import type { HomeAssistant } from "../types";
interface SetupTask {
@@ -21,6 +29,7 @@ interface SetupTask {
entity_ids: string[];
threshold: number;
direction: string;
covered_by?: { task_id: string; name: string; reason: string } | null;
}
interface SuggestedSetup {
@@ -31,7 +40,11 @@ interface SuggestedSetup {
integration_name: string;
suggested_entry_id: string | null;
suggested_object_name: string;
candidate?: { entry_id: string; name: string; reasons: string[] } | null;
target_entry_id?: string | null;
target_task_count?: number;
tasks: SetupTask[];
already?: Array<{ task_name: string; task_name_localized: string; existing_name: string }>;
}
interface AdoptResponse {
@@ -41,6 +54,9 @@ interface AdoptResponse {
errors?: unknown[];
}
/** The target select's value for "a new object for this device". */
const NEW = "__new__";
export class MaintenanceSuggestedSetupsDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -53,9 +69,10 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
// #102: optional counting start values, keyed "deviceId taskName".
// Only usage_delta duties render the input; raw strings until adopt.
@state() private _baselines: Map<string, string> = new Map();
// #105: adopt target per device — entry_id of an existing object, or ""
// for the default (the suggested bound object, else a new object).
// Adopt target per device: an object's entry_id, or NEW.
@state() private _targets: Map<string, string> = new Map();
// Ticked tasks per device (task_name keys).
@state() private _tasks: Map<string, Set<string>> = new Map();
@state() private _objects: Array<{ entry_id: string; name: string }> = [];
private _localeReady = false;
@@ -71,35 +88,41 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
}
}
/** Ticked by default: everything the target does not probably have yet. */
private _defaultTicks(setup: SuggestedSetup): Set<string> {
return new Set(setup.tasks.filter((task) => !task.covered_by).map((task) => task.task_name));
}
public async open(): Promise<void> {
this._open = true;
this._loading = true;
this._error = "";
this._setups = [];
this._selected = new Set();
try {
const resp = await this.hass.connection.sendMessagePromise<{ setups: SuggestedSetup[] }>({
type: "maintenance_supporter/integration_setups/discover",
});
this._setups = resp.setups || [];
const resp = await runWs<{ setups: SuggestedSetup[] }>(
this,
{ type: "maintenance_supporter/integration_setups/discover" },
{ onError: (m) => { this._error = m; } },
);
if (resp !== undefined) {
this._setups = resp?.setups || [];
this._selected = new Set(this._setups.map((s) => s.device_id));
this._baselines = new Map();
this._targets = new Map();
this._targets = new Map(this._setups.map((s) => [s.device_id, s.target_entry_id || NEW]));
this._tasks = new Map(this._setups.map((s) => [s.device_id, this._defaultTicks(s)]));
try {
const objs = await this.hass.connection.sendMessagePromise<{
objects: Array<{ entry_id: string; object: { name: string } }>;
objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null } }>;
}>({ type: "maintenance_supporter/objects" });
this._objects = (objs.objects || [])
.filter((o) => !o.object?.archived_at)
.map((o) => ({ entry_id: o.entry_id, name: o.object?.name || o.entry_id }))
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
this._objects = []; // picker degrades to the default target only
}
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._loading = false;
}
this._loading = false;
}
private _close(): void {
@@ -113,43 +136,110 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
this._selected = next;
};
private _toggleTask(deviceId: string, taskName: string): void {
const ticks = new Set(this._tasks.get(deviceId) ?? []);
if (ticks.has(taskName)) ticks.delete(taskName);
else ticks.add(taskName);
this._tasks = new Map(this._tasks).set(deviceId, ticks);
}
/** Another target: the server judges the proposals against it again. */
private async _retarget(setup: SuggestedSetup, value: string): Promise<void> {
this._targets = new Map(this._targets).set(setup.device_id, value);
const fresh = await runWs<SuggestedSetup>(
this,
{
type: "maintenance_supporter/integration_setups/preview",
device_id: setup.device_id,
entry_id: value === NEW ? null : value,
},
{ onError: (m) => { this._error = m; } },
);
// An empty answer (an older server without preview) keeps the old ticks.
if (!fresh || !Array.isArray(fresh.tasks)) return;
this._setups = this._setups.map((s) => (s.device_id === setup.device_id ? { ...s, ...fresh } : s));
this._tasks = new Map(this._tasks).set(setup.device_id, this._defaultTicks(fresh));
}
private _adopt = async (): Promise<void> => {
if (this._selected.size === 0 || this._adopting) return;
this._adopting = true;
if (this._adopting) return;
this._error = "";
try {
const result = await this.hass.connection.sendMessagePromise<AdoptResponse>({
type: "maintenance_supporter/integration_setups/adopt",
selections: [...this._selected].map((device_id) => {
const sel: { device_id: string; entry_id?: string; baselines?: Record<string, number> } = {
device_id,
};
const target = this._targets.get(device_id);
if (target) sel.entry_id = target;
const setup = this._setups.find((s) => s.device_id === device_id);
for (const task of setup?.tasks ?? []) {
const raw = this._baselines.get(`${device_id} ${task.task_name}`);
const b = raw ? parseFloat(raw) : NaN;
if (!isNaN(b) && b >= 0) (sel.baselines ??= {})[task.task_name] = b;
}
return sel;
}),
});
this.dispatchEvent(
new CustomEvent("integration-setups-adopted", {
bubbles: true,
composed: true,
detail: result,
}),
);
this._open = false;
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._adopting = false;
}
const selections = [...this._selected]
.map((device_id) => {
const ticks = [...(this._tasks.get(device_id) ?? [])];
const sel: { device_id: string; entry_id?: string; task_names: string[]; baselines?: Record<string, number> } = {
device_id,
task_names: ticks,
};
const target = this._targets.get(device_id);
if (target && target !== NEW) sel.entry_id = target;
for (const name of ticks) {
const raw = this._baselines.get(`${device_id} ${name}`);
const b = raw ? parseFloat(raw) : NaN;
if (!isNaN(b) && b >= 0) (sel.baselines ??= {})[name] = b;
}
return sel;
})
.filter((sel) => sel.task_names.length > 0);
if (selections.length === 0) return;
const result = await runWs<AdoptResponse>(
this,
{ type: "maintenance_supporter/integration_setups/adopt", selections },
{ busy: (b) => { this._adopting = b; }, onError: (m) => { this._error = m; } },
);
if (result === undefined) return;
this.dispatchEvent(
new CustomEvent("integration-setups-adopted", {
bubbles: true,
composed: true,
detail: result,
}),
);
this._open = false;
};
private get _anythingTicked(): boolean {
return [...this._selected].some((id) => (this._tasks.get(id)?.size ?? 0) > 0);
}
private _targetOptions(s: SuggestedSetup, L: string) {
const current = this._targets.get(s.device_id) ?? NEW;
const options: Array<{ value: string; label: string }> = [];
if (s.suggested_entry_id) {
options.push({ value: s.suggested_entry_id, label: s.suggested_object_name });
} else {
if (s.candidate) {
const reasons = s.candidate.reasons.map((r) => t(`setups_reason_${r}`, L)).join(", ");
options.push({
value: s.candidate.entry_id,
label: t("setups_target_match", L).replace("{name}", s.candidate.name).replace("{reasons}", reasons),
});
}
options.push({ value: NEW, label: t("setups_target_new", L).replace("{name}", s.device_name) });
}
const listed = new Set(options.map((o) => o.value));
for (const o of this._objects) if (!listed.has(o.entry_id)) options.push({ value: o.entry_id, label: o.name });
return html`
<select
class="target-select"
@click=${(e: Event) => e.stopPropagation()}
@change=${(e: Event) => void this._retarget(s, (e.target as HTMLSelectElement).value)}
>
${options.map((o) => html`<option value=${o.value} ?selected=${o.value === current}>${o.label}</option>`)}
</select>
`;
}
private _beforeAfter(s: SuggestedSetup, L: string): string {
const ticked = this._tasks.get(s.device_id)?.size ?? 0;
const target = this._targets.get(s.device_id) ?? NEW;
if (target === NEW) {
return t("setups_new_object_count", L).replace("{name}", s.device_name).replace("{count}", String(ticked));
}
const before = s.target_task_count ?? 0;
return t("setups_before_after", L).replace("{before}", String(before)).replace("{after}", String(before + ticked));
}
render() {
if (!this._open) return html``;
const L = this._lang;
@@ -170,10 +260,12 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
${this._setups.map((s) => {
const checked = this._selected.has(s.device_id);
const sub = [s.integration_name, s.area_name].filter(Boolean).join(" · ");
const ticks = this._tasks.get(s.device_id) ?? new Set<string>();
return html`
<label class="row">
<div class="row ${checked ? "" : "off"}" data-device=${s.device_id}>
<input
type="checkbox"
class="device-check"
.checked=${checked}
@change=${() => this._toggle(s.device_id)}
/>
@@ -182,78 +274,60 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
<span class="row-name">${s.device_name}</span>
</div>
<div class="row-sub">${sub}</div>
<div class="row-target" @click=${(e: Event) => e.preventDefault()}>
→
${checked && this._objects.length > 0
? html`
<select
class="target-select"
@change=${(e: Event) => {
const next = new Map(this._targets);
const v = (e.target as HTMLSelectElement).value;
if (v) next.set(s.device_id, v);
else next.delete(s.device_id);
this._targets = next;
}}
>
<option value="" ?selected=${!this._targets.get(s.device_id)}>
${s.suggested_entry_id
? s.suggested_object_name
: t("setups_target_new", L).replace("{name}", s.suggested_object_name)}
</option>
${this._objects
.filter((o) => o.entry_id !== s.suggested_entry_id)
.map(
(o) => html`<option
value=${o.entry_id}
?selected=${this._targets.get(s.device_id) === o.entry_id}
>
${o.name}
</option>`,
)}
</select>
`
: html`${s.suggested_object_name}${s.suggested_entry_id
? nothing
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}`}
</div>
<div class="row-tasks">
${s.tasks.map(
(task) => html`<span class="chip" title=${task.entity_ids.join(", ")}>
<ha-icon icon="mdi:link-variant"></ha-icon>${task.task_name_localized ||
task.task_name}
</span>`,
)}
</div>
<div class="row-target">→ ${this._targetOptions(s, L)}</div>
${checked
? s.tasks
.filter((task) => task.direction === "usage_delta")
.map((task) => {
const key = `${s.device_id} ${task.task_name}`;
return html`
<div class="baseline-field" @click=${(e: Event) => e.preventDefault()}>
<span class="baseline-label"
>${task.task_name_localized || task.task_name} —
${t("setups_baseline_hint", L)}</span
>
<input
type="number"
step="any"
min="0"
.value=${this._baselines.get(key) ?? ""}
@click=${(e: Event) => e.preventDefault()}
@input=${(e: Event) => {
const next = new Map(this._baselines);
next.set(key, (e.target as HTMLInputElement).value);
this._baselines = next;
}}
/>
</div>
`;
})
? html`
<div class="task-list">
${s.tasks.map(
(task) => html`
<label class="task" title=${task.entity_ids.join(", ")}>
<input
type="checkbox"
class="task-check"
.checked=${ticks.has(task.task_name)}
@change=${() => this._toggleTask(s.device_id, task.task_name)}
/>
<span class="task-text">
<span>${task.task_name_localized || task.task_name}</span>
${task.covered_by
? html`<span class="maybe">${t("setups_maybe_covered", L).replace("{name}", task.covered_by.name)}</span>`
: nothing}
</span>
</label>
${task.direction === "usage_delta" && ticks.has(task.task_name)
? html`
<div class="baseline-field">
<span class="baseline-label">${t("setups_baseline_hint", L)}</span>
<input
type="number"
step="any"
min="0"
.value=${this._baselines.get(`${s.device_id} ${task.task_name}`) ?? ""}
@input=${(e: Event) => {
const next = new Map(this._baselines);
next.set(`${s.device_id} ${task.task_name}`, (e.target as HTMLInputElement).value);
this._baselines = next;
}}
/>
</div>
`
: nothing}
`,
)}
${s.already?.length
? html`<div class="already">
${t("setups_already", L).replace(
"{names}",
s.already.map((a) => a.existing_name).join(", "),
)}
</div>`
: nothing}
</div>
<div class="before-after">${this._beforeAfter(s, L)}</div>
`
: nothing}
</div>
</label>
</div>
`;
})}
</div>
@@ -263,10 +337,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button
@click=${this._adopt}
.disabled=${this._selected.size === 0 || this._adopting}
>
<ha-button @click=${this._adopt} .disabled=${!this._anythingTicked || this._adopting}>
${t("setups_adopt", L)}
</ha-button>
</div>
@@ -304,33 +375,35 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
.hint { color: var(--secondary-text-color); font-size: 13px; }
.error { color: var(--error-color, #f44336); font-size: 13px; }
.loading, .empty { color: var(--secondary-text-color); font-size: 14px; padding: 12px 0; }
.list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 50vh; }
.list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 55vh; }
.row {
display: flex; align-items: flex-start; gap: 10px; padding: 8px;
border: 1px solid var(--divider-color); border-radius: 6px; cursor: pointer;
border: 1px solid var(--divider-color); border-radius: 6px;
}
.row.off .row-main { opacity: 0.6; }
.row input { margin-top: 2px; cursor: pointer; }
.row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.row-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.row-name { font-weight: 500; font-size: 13px; }
.row-sub, .row-target { color: var(--secondary-text-color); font-size: 12px; }
.new-tag { font-style: italic; }
.row-tasks { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
color: var(--primary-text-color); white-space: nowrap;
}
.chip ha-icon { --mdc-icon-size: 12px; color: var(--primary-color); }
.row-target { display: flex; align-items: center; gap: 6px; min-width: 0; }
.target-select {
font-size: 12px; padding: 2px 4px; max-width: 100%;
font-size: 12px; padding: 2px 4px; min-width: 0; max-width: 100%; flex: 1 1 auto;
border: 1px solid var(--divider-color); border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
}
.task-list { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; }
.task { display: flex; align-items: flex-start; gap: 6px; font-size: 13px; cursor: pointer; }
.task-text { display: flex; flex-direction: column; min-width: 0; }
.maybe { font-size: 11px; color: var(--warning-color, #ff9800); }
.already { font-size: 11px; color: var(--secondary-text-color); margin-top: 2px; }
.before-after {
font-size: 12px; font-weight: 500; color: var(--primary-color);
border-top: 1px dashed var(--divider-color); padding-top: 4px; margin-top: 2px;
}
.baseline-field {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
margin-top: 4px; font-size: 12px; color: var(--secondary-text-color);
margin: 0 0 2px 24px; font-size: 12px; color: var(--secondary-text-color);
}
.baseline-field input {
width: 110px; padding: 3px 6px; font-size: 12px;
@@ -13,8 +13,16 @@ import {
TRIGGER_PICKER_DOMAINS,
} from "../helpers/trigger-domains";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import {
EWA_ALPHA_RANGE,
PART_QTY_RANGE,
SCHEDULE_OFFSET_MAX_DAYS,
TRIGGER_FOR_MINUTES_RANGE,
TRIGGER_TARGET_CHANGES_RANGE,
WARNING_DAYS_RANGE,
} from "../helpers/setting-ranges";
import { parseDurationMinutes } from "../helpers/duration";
import type { TriggerType } from "../types";
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import "./ms-textfield";
@@ -37,6 +45,10 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
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;
// Bounds of a consumes_parts quantity (the field's min/max, the same range
// the completion's used_parts accept) — validated on save with a message,
// never silently replaced. One TS source, pinned to Python.
const CONSUMES_QTY_RANGE = PART_QTY_RANGE;
/** 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. */
@@ -80,11 +92,17 @@ const MANAGED_CONDITION_KEYS = new Set([
"trigger_runtime_hours", "trigger_on_states",
]);
/** Map a persisted compound condition (storage shape) to an editable draft. */
function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []);
/** The per-type trigger fields BOTH editors own — the flat trigger form and
* every compound condition. The flat form used to hydrate and serialize
* them with its own copy of the rules below, and the copies had drifted:
* a compound state_change condition dropped its #136 hold time
* (`trigger_for_minutes`, a managed key, so not carried either) on every
* save (DRY audit 2026-09-26). */
export type TriggerTypeFields = Omit<CompoundConditionDraft, "entityIds" | "carry">;
/** Storage → form for the shared per-type fields. */
export function typeFieldsFromConfig(c: TriggerConfig): TriggerTypeFields {
return {
entityIds: ids.join(", "),
type: c.type || "threshold",
attribute: c.attribute || "",
above: c.trigger_above?.toString() ?? "",
@@ -99,6 +117,42 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
targetChanges: c.trigger_target_changes?.toString() ?? "",
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
onStates: (c.trigger_on_states || []).join(", "),
};
}
/** Form → storage: writes the shared per-type fields onto `c` (only the
* ones the type uses; unparsable numbers are left out). */
export function applyTypeFields(c: TriggerConfig, d: TriggerTypeFields): void {
if (d.attribute) c.attribute = d.attribute;
const forMinutes = parseInt(d.forMinutes, 10);
if (d.type === "threshold") {
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
const eq = parseFloat(d.equals); if (!isNaN(eq)) c.trigger_equals = eq;
const ne = parseFloat(d.notEquals); if (!isNaN(ne)) c.trigger_not_equals = ne;
if (!isNaN(forMinutes)) c.trigger_for_minutes = forMinutes;
} else if (d.type === "counter") {
const v = parseFloat(d.targetValue); if (!isNaN(v)) c.trigger_target_value = v;
c.trigger_delta_mode = d.deltaMode;
} else if (d.type === "state_change") {
if (d.fromState) c.trigger_from_state = d.fromState;
if (d.toState) c.trigger_to_state = d.toState;
const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n;
// #136: the new state must hold this long before a change counts.
if (!isNaN(forMinutes)) c.trigger_for_minutes = forMinutes;
} else if (d.type === "runtime") {
const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h;
const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean);
if (on.length > 0) c.trigger_on_states = on;
}
}
/** Map a persisted compound condition (storage shape) to an editable draft. */
function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []);
return {
entityIds: ids.join(", "),
...typeFieldsFromConfig(c),
carry: Object.fromEntries(
Object.entries(c).filter(([k]) => !MANAGED_CONDITION_KEYS.has(k) && !k.startsWith("_")),
) as Partial<TriggerConfig>,
@@ -111,25 +165,7 @@ function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null {
const ids = d.entityIds.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) return null;
const c: TriggerConfig = { ...(d.carry || {}), entity_id: ids[0], entity_ids: ids, type: d.type };
if (d.attribute) c.attribute = d.attribute;
if (d.type === "threshold") {
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
const eq = parseFloat(d.equals); if (!isNaN(eq)) c.trigger_equals = eq;
const ne = parseFloat(d.notEquals); if (!isNaN(ne)) c.trigger_not_equals = ne;
const f = parseInt(d.forMinutes, 10); if (!isNaN(f)) c.trigger_for_minutes = f;
} else if (d.type === "counter") {
const v = parseFloat(d.targetValue); if (!isNaN(v)) c.trigger_target_value = v;
c.trigger_delta_mode = d.deltaMode;
} else if (d.type === "state_change") {
if (d.fromState) c.trigger_from_state = d.fromState;
if (d.toState) c.trigger_to_state = d.toState;
const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n;
} else if (d.type === "runtime") {
const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h;
const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean);
if (on.length > 0) c.trigger_on_states = on;
}
applyTypeFields(c, d);
return c;
}
@@ -277,11 +313,22 @@ export class MaintenanceTaskDialog extends LitElement {
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
* since the same part id can exist on two objects (battery fleet). */
@state() private _consumesParts: Record<string, TaskPartLink> = {};
/** The TYPED quantity per picked part (same key), parsed on save. The
* field wrote back a parsed number on every keystroke — clearing "2" to
* type "3" snapped to "1" first, giving "13" (bug audit 2026-09-26 #2;
* the complete dialog's `_usedQtyText` got the same fix). */
@state() private _consumesQtyText: Record<string, string> = {};
@state() private _partsLoadFailed = false;
@state() private _availableTags: Array<{id: string; name: string}> = [];
// User assignment
@state() private _responsibleUserId: string | null = null;
// What an edit loaded for the two LIVE fields other surfaces change while
// the dialog is open — a completion (NFC, voice, another user) moves the
// anchor and rotates the assignee. Sent back only when edited here, else
// Save rolled that completion back (bug audit 2026-09-26).
private _loadedLastPerformed = "";
private _loadedResponsibleUserId: string | null = null;
@state() private _assigneePool: string[] = [];
@state() private _rotationStrategy = "";
@state() private _availableUsers: HAUser[] = [];
@@ -435,6 +482,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._mirrorTodoEntities = [...(task.mirror_todo_entities || [])];
this._enabled = task.enabled !== false;
this._lastPerformed = task.last_performed || "";
this._loadedLastPerformed = this._lastPerformed;
this._nfcTagId = task.nfc_tag_id || "";
this._requireTagScan = !!task.require_tag_scan;
this._allowSkip = task.allow_skip !== false;
@@ -446,7 +494,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._consumesParts = Object.fromEntries(
(task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]),
);
this._consumesQtyText = {};
this._responsibleUserId = task.responsible_user_id || null;
this._loadedResponsibleUserId = this._responsibleUserId;
this._assigneePool = [...(task.assignee_pool || [])];
this._rotationStrategy = task.rotation_strategy || "";
@@ -524,25 +574,13 @@ export class MaintenanceTaskDialog extends LitElement {
this._triggerEntityId = tc.entity_id || (tc.entity_ids && tc.entity_ids[0]) || "";
this._triggerEntityIds = tc.entity_ids || (tc.entity_id ? [tc.entity_id] : []);
this._triggerEntityLogic = tc.entity_logic || "any";
this._triggerAttribute = tc.attribute || "";
this._triggerType = tc.type || "threshold";
this._triggerAbove = tc.trigger_above?.toString() || "";
this._triggerBelow = tc.trigger_below?.toString() || "";
this._triggerEquals = tc.trigger_equals?.toString() || "";
this._triggerNotEquals = tc.trigger_not_equals?.toString() || "";
this._triggerForMinutes = tc.trigger_for_minutes?.toString() || "0";
this._setTypeFields(typeFieldsFromConfig(tc));
this._triggerCombinator = tc.trigger_combinator === "all" ? "all" : "any";
this._triggerTargetValue = tc.trigger_target_value?.toString() || "";
this._triggerDeltaMode = tc.trigger_delta_mode || false;
// Flat-form-only fields (a compound condition carries them via `carry`).
this._triggerBaselineValue = tc.trigger_baseline_value?.toString() || "";
this._liveBaselineValue = task.trigger_baseline_value ?? null;
this._autoCompleteOnRecovery = tc.auto_complete_on_recovery || false;
this._triggerFromState = tc.trigger_from_state || "";
this._triggerToState = tc.trigger_to_state || "";
this._triggerTargetChanges = tc.trigger_target_changes?.toString() || "";
this._triggerRuntimeHours = tc.trigger_runtime_hours?.toString() || "";
this._triggerRuntimeMaxSession = tc.trigger_runtime_max_session_seconds?.toString() || "";
this._triggerOnStates = (tc.trigger_on_states || []).join(", ");
if (tc.type === "compound") {
this._compoundLogic = tc.compound_logic === "OR" ? "OR" : "AND";
this._compoundConditions = (tc.conditions || []).map(conditionToDraft);
@@ -599,6 +637,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._readingUnit = "";
this._readings = [];
this._consumesParts = {};
this._consumesQtyText = {};
this._responsibleUserId = null;
this._assigneePool = [];
this._rotationStrategy = "";
@@ -633,32 +672,58 @@ export class MaintenanceTaskDialog extends LitElement {
this._resetTriggerFields();
}
/** The flat trigger form's shared per-type fields as one struct — the
* same shape a compound condition draft edits (typeFieldsFromConfig /
* applyTypeFields are the one rule set for both). */
private _typeFields(): TriggerTypeFields {
return {
type: this._triggerType,
attribute: this._triggerAttribute,
above: this._triggerAbove,
below: this._triggerBelow,
equals: this._triggerEquals,
notEquals: this._triggerNotEquals,
forMinutes: this._triggerForMinutes,
targetValue: this._triggerTargetValue,
deltaMode: this._triggerDeltaMode,
fromState: this._triggerFromState,
toState: this._triggerToState,
targetChanges: this._triggerTargetChanges,
runtimeHours: this._triggerRuntimeHours,
onStates: this._triggerOnStates,
};
}
private _setTypeFields(f: TriggerTypeFields): void {
this._triggerType = f.type;
this._triggerAttribute = f.attribute;
this._triggerAbove = f.above;
this._triggerBelow = f.below;
this._triggerEquals = f.equals;
this._triggerNotEquals = f.notEquals;
this._triggerForMinutes = f.forMinutes;
this._triggerTargetValue = f.targetValue;
this._triggerDeltaMode = f.deltaMode;
this._triggerFromState = f.fromState;
this._triggerToState = f.toState;
this._triggerTargetChanges = f.targetChanges;
this._triggerRuntimeHours = f.runtimeHours;
this._triggerOnStates = f.onStates;
}
private _resetTriggerFields(): void {
this._triggerEntityId = "";
this._triggerEntityIds = [];
this._triggerEntityLogic = "any";
this._triggerAttribute = "";
this._suggestedAttributes = [];
this._availableAttributes = [];
this._entityDomain = "";
this._triggerType = "threshold";
this._triggerAbove = "";
this._triggerBelow = "";
this._triggerEquals = "";
this._triggerNotEquals = "";
this._triggerForMinutes = "0";
this._setTypeFields(emptyCondition());
this._triggerCombinator = "any";
this._triggerTargetValue = "";
this._triggerDeltaMode = false;
this._triggerBaselineValue = "";
this._liveBaselineValue = null;
this._autoCompleteOnRecovery = false;
this._triggerFromState = "";
this._triggerToState = "";
this._triggerTargetChanges = "";
this._triggerRuntimeHours = "";
this._triggerRuntimeMaxSession = "";
this._triggerOnStates = "";
this._compoundLogic = "AND";
this._compoundConditions = [];
}
@@ -1060,7 +1125,12 @@ export class MaintenanceTaskDialog extends LitElement {
@change=${(e: Event) => {
const next = { ...this._consumesParts };
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
else {
delete next[key];
const text = { ...this._consumesQtyText };
delete text[key];
this._consumesQtyText = text;
}
this._consumesParts = next;
}}
/>
@@ -1070,16 +1140,19 @@ export class MaintenanceTaskDialog extends LitElement {
? html`<input
class="consumes-qty"
type="number"
min="0.01"
max="999"
min=${CONSUMES_QTY_RANGE[0]}
max=${CONSUMES_QTY_RANGE[1]}
step="0.01"
.value=${String(link.quantity)}
.value=${this._consumesQtyText[key] ?? String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._consumesParts = {
...this._consumesParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
// Keep the text as typed; a valid number also updates the
// link, an empty / partial one is judged on save.
const raw = (e.target as HTMLInputElement).value;
this._consumesQtyText = { ...this._consumesQtyText, [key]: raw };
const v = parseFloat(raw.replace(",", "."));
if (Number.isFinite(v) && v >= CONSUMES_QTY_RANGE[0]) {
this._consumesParts = { ...this._consumesParts, [key]: { ...base, quantity: v } };
}
}}
/>`
: nothing}
@@ -1087,6 +1160,28 @@ export class MaintenanceTaskDialog extends LitElement {
`;
}
/** Parse the typed part quantities (see _consumesQtyText) into the links
* before a save. Out of range = a message and false (nothing is sent),
* never a silent 1. */
private _applyConsumesQtyText(): boolean {
const next = { ...this._consumesParts };
for (const key of Object.keys(next)) {
const raw = this._consumesQtyText[key];
if (raw === undefined) continue;
const qty = parseFloat(raw.replace(",", "."));
if (!Number.isFinite(qty) || qty < CONSUMES_QTY_RANGE[0] || qty > CONSUMES_QTY_RANGE[1]) {
const fmt = (n: number) => formatNumber(n, this._lang, { maximumFractionDigits: 2 });
this._error = t("settings_value_out_of_range", this._lang)
.replace("{min}", fmt(CONSUMES_QTY_RANGE[0]))
.replace("{max}", fmt(CONSUMES_QTY_RANGE[1]));
return false;
}
next[key] = { ...next[key], quantity: qty };
}
this._consumesParts = next;
return true;
}
private _toggleRequired(field: string, on: boolean): void {
const next = new Set(this._requiredCompletion);
if (on) next.add(field);
@@ -1179,13 +1274,19 @@ export class MaintenanceTaskDialog extends LitElement {
<select
class="phase-part"
.value=${d.partId}
@change=${(e: Event) => this._patchPhaseDef(d.id, { partId: (e.target as HTMLSelectElement).value })}
@change=${(e: Event) => {
const partId = (e.target as HTMLSelectElement).value;
this._patchPhaseDef(d.id, d.partQty ? { partId } : { partId, partQty: "1" });
}}
>
<option value="">—</option>
${this.parts.map((p) => html`<option value=${p.id} ?selected=${p.id === d.partId}>${p.name}</option>`)}
</select>
${d.partId ? html`
<input class="phase-qty" type="number" min="0.01" step="0.01" .value=${d.partQty || "1"}
<!-- The typed text as-is: a "|| '1'" here rewrote a cleared
field to 1 and the next digit gave "13" (bug audit
2026-09-26 #2); an empty field saves as 1. -->
<input class="phase-qty" type="number" min=${PART_QTY_RANGE[0]} step="0.01" placeholder="1" .value=${d.partQty}
@input=${(e: Event) => this._patchPhaseDef(d.id, { partQty: (e.target as HTMLInputElement).value })} />
` : nothing}
` : nothing}
@@ -1282,253 +1383,18 @@ export class MaintenanceTaskDialog extends LitElement {
this._error = t("calendar_entity_required", this._lang);
return;
}
if (!this._applyConsumesQtyText()) return;
this._loading = true;
this._error = "";
try {
const data: Record<string, unknown> = {
type: this._taskId
? "maintenance_supporter/task/update"
: "maintenance_supporter/task/create",
entry_id: this._entryId,
name: this._name,
task_type: this._type,
schedule_type: this._scheduleType,
// `0` is a legal, meaningful value — "no due-soon window, go straight
// from ok to overdue" (backend range is 0–365). The old
// `parseInt(...) || 7` treated it as falsy and silently rewrote a
// stored 0 to 7 on EVERY save, even when the user never touched the
// field. Same class as bug #42, but worse: it needed no user action.
// Only a genuinely unparseable field falls back, and to the
// configured default rather than a hardcoded 7.
warning_days: Number.isNaN(parseInt(this._warningDays, 10))
? this.defaultWarningDays
: Math.max(0, parseInt(this._warningDays, 10)),
};
const ecd = this._earliestCompletionDays.trim();
data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0);
if (this._taskId) data.task_id = this._taskId;
if (this._scheduleType === "one_time") {
data.due_date = this._dueDate || null;
data.interval_days = null;
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
// Calendar kinds are sent as the nested schedule; the backend prefers
// it over the flat fields and clears any stale interval/due_date.
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
data.interval_days = null;
if (this._taskId) data.due_date = null;
} else {
// Switching away from one-time clears the stale due_date on edit.
if (this._taskId) data.due_date = null;
if (this._scheduleType !== "manual" && this._intervalDays) {
data.interval_days = parseInt(this._intervalDays, 10);
data.interval_unit = this._intervalUnit;
data.interval_anchor = this._intervalAnchor;
// Season / finite-series ride a minimal nested schedule alongside the
// flat interval fields; the backend carries them onto the interval it
// derives from the flat fields (they can't be expressed flatly).
// Always send an authoritative nested schedule for interval tasks so
// an edit that clears season/ends actually clears them.
if (this._scheduleType === "time_based") {
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
}
} else if (this._taskId) {
data.interval_days = null;
data.interval_anchor = "completion";
}
}
data.notes = this._notes || null;
data.documentation_url = this._documentationUrl || null;
data.custom_icon = this._customIcon || null;
// #185: null clears the override (the update path pops the key).
data.notify_icon = this._notifyIcon.trim() || null;
data.priority = this._priority;
data.labels = this._labels
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// D#183: always sent — [] clears the mirror targets.
data.mirror_todo_entities = this._mirrorTodoEntities.filter(Boolean);
data.enabled = this._enabled;
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.require_tag_scan = this._requireTagScan;
data.allow_skip = this._allowSkip;
data.notify_enabled = this._notifyEnabled;
data.reading_unit = this._readingUnit.trim() || null;
// #161 phase 2: always sent — [] clears the slots (single-value task).
// Sent as hydrated even when the editor is hidden (type != reading), so
// a no-op edit never wipes stored slots (the #42/#50 wipe class).
data.readings = cleanReadingSlots(this._readings);
// Task phases (#139): always sent — null clears a removed cycle.
{
const defs: Record<string, unknown> = {};
for (const d of this._phaseDefs) {
if (!d.name.trim()) continue;
const def: Record<string, unknown> = { ...d.carry, name: d.name.trim() };
const items = d.checklistText.split("\n").map((s) => s.trim()).filter(Boolean);
if (items.length) def.checklist = items;
const links: TaskPartLink[] = [];
if (d.partId) {
const qty = parseFloat(d.partQty);
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
}
// entry_id travels only when present (a shared pool, #111) — an
// own-part link stays byte-identical to what shipped before.
for (const l of d.extraParts) {
links.push(
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
if (links.length) def.consumes_parts = links;
// Override on → send the list, EMPTY included ("demands nothing");
// off → omit the key so the task-level list falls through.
if (d.reqOverride) def.required_completion_fields = [...d.reqFields];
defs[d.id] = def;
}
const seq = this._phaseSeq.filter((id) => id in defs);
data.phases = Object.keys(defs).length && seq.length ? defs : null;
data.phase_sequence = data.phases ? seq : null;
}
// Only send when a picker was actually rendered. A failed parts load
// leaves both lists empty, and sending [] then would silently wipe links
// the user never saw. entry_id is written ONLY for a foreign pick, so an
// own-parts task saves byte-identically to before (#111).
if (this.parts.length || this._foreignOwners.length) {
data.consumes_parts = Object.values(this._consumesParts).map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
data.responsible_user_id = this._responsibleUserId;
data.assignee_pool = this._assigneePool;
data.required_completion_fields = this._requiredCompletion;
data.rotation_strategy =
this._assigneePool.length >= 2 && this._rotationStrategy
? this._rotationStrategy
: null;
if (this._scheduleType === "sensor_based" && this._triggerType === "compound") {
// Compound: a group of conditions joined by AND/OR. Each condition
// carries its own entity + type + params; the top-level entity picker
// does not apply here.
const conditions = this._compoundConditions
.map(draftToCondition)
.filter((c): c is TriggerConfig => c !== null);
if (conditions.length > 0) {
const triggerConfig: TriggerConfig = {
type: "compound",
compound_logic: this._compoundLogic,
conditions,
};
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
data.trigger_config = triggerConfig;
} else if (this._taskId) {
data.trigger_config = null;
}
} else if (this._scheduleType === "sensor_based" && this._triggerEntityId) {
const entityIds = this._triggerEntityIds.length > 0
? this._triggerEntityIds
: [this._triggerEntityId];
const triggerConfig: TriggerConfig = {
entity_id: entityIds[0],
entity_ids: entityIds,
type: this._triggerType,
};
if (this._triggerAttribute) triggerConfig.attribute = this._triggerAttribute;
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
// Multi-entity: store entity_logic for all trigger types
if (entityIds.length > 1) {
triggerConfig.entity_logic = this._triggerEntityLogic;
}
if (this._triggerType === "threshold") {
if (this._triggerAbove) { const v = parseFloat(this._triggerAbove); if (!isNaN(v)) triggerConfig.trigger_above = v; }
if (this._triggerBelow) { const v = parseFloat(this._triggerBelow); if (!isNaN(v)) triggerConfig.trigger_below = v; }
if (this._triggerEquals) { const v = parseFloat(this._triggerEquals); if (!isNaN(v)) triggerConfig.trigger_equals = v; }
if (this._triggerNotEquals) { const v = parseFloat(this._triggerNotEquals); if (!isNaN(v)) triggerConfig.trigger_not_equals = v; }
if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; }
} else if (this._triggerType === "counter") {
if (this._triggerTargetValue) { const v = parseFloat(this._triggerTargetValue); if (!isNaN(v)) triggerConfig.trigger_target_value = v; }
triggerConfig.trigger_delta_mode = this._triggerDeltaMode;
// #102: optional counting start value ("last service was at X").
// Empty = count from the reading at creation / keep the live
// baseline; the backend clears stale Store state when it changes.
if (this._triggerDeltaMode && this._triggerBaselineValue) {
const b = parseFloat(this._triggerBaselineValue);
if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b;
}
} else if (this._triggerType === "state_change") {
if (this._triggerFromState) triggerConfig.trigger_from_state = this._triggerFromState;
if (this._triggerToState) triggerConfig.trigger_to_state = this._triggerToState;
if (this._triggerTargetChanges) { const v = parseInt(this._triggerTargetChanges, 10); if (!isNaN(v)) triggerConfig.trigger_target_changes = v; }
// #136: the new state must hold this long before a change counts.
if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; }
} else if (this._triggerType === "runtime") {
if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = v; }
// #149: per-session cap (seconds) against sensors stuck ON.
if (this._triggerRuntimeMaxSession) { const v = parseInt(this._triggerRuntimeMaxSession, 10); if (!isNaN(v) && v > 0) triggerConfig.trigger_runtime_max_session_seconds = v; }
const onStates = this._triggerOnStates.split(",").map((s) => s.trim()).filter(Boolean);
if (onStates.length > 0) triggerConfig.trigger_on_states = onStates;
}
data.trigger_config = triggerConfig;
} else if (this._taskId) {
data.trigger_config = null;
}
// Schedule time only sent when feature is enabled — null clears.
// Every date-driven kind takes one (#168), not only the interval; an
// unticked checkbox (or a ticked one with an empty picker) clears it.
if (this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType)) {
const t = this._scheduleTimeOn ? this._scheduleTime.trim() : "";
data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
}
if (this.checklistsEnabled) {
const items = this._checklistText
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.slice(0, 100);
data.checklist = items.length ? items : null;
}
// v1.3.0: on_complete_action + quick_complete_defaults (gated)
if (this.completionActionsEnabled) {
const svc = this._actionService.trim();
if (svc && /^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
const action: Record<string, unknown> = { service: svc };
const tgt = this._actionTargetEntity.trim();
if (tgt) action.target = { entity_id: tgt };
const dataDict = this._buildActionData();
if (Object.keys(dataDict).length > 0) {
action.data = dataDict;
}
data.on_complete_action = action;
} else {
data.on_complete_action = null;
}
const qcd: Record<string, unknown> = {};
if (this._qcNotes.trim()) qcd.notes = this._qcNotes.trim();
const cost = parseFloat(this._qcCost);
if (!isNaN(cost) && cost >= 0) qcd.cost = cost;
const dur = parseInt(this._qcDuration, 10);
if (!isNaN(dur) && dur >= 0) qcd.duration = dur;
if (this._qcFeedback) qcd.feedback = this._qcFeedback;
data.quick_complete_defaults = Object.keys(qcd).length ? qcd : null;
}
const result = await this.hass.connection.sendMessagePromise(data) as { task_id?: string };
// helpers/ws-run runWs: a refusal (or a payload that failed to build)
// lands on the error line as the server's reason / "Save failed".
const result = await runWs<{ task_id?: string }>(
this,
() => this.hass.connection.sendMessagePromise<{ task_id?: string }>(this._savePayload()),
{ fallbackKey: "save_error", onError: (m) => { this._error = m; } },
);
if (result === undefined) return;
const savedTaskId = this._taskId || result?.task_id;
// Environmental entity lives in adaptive_config (Store-managed),
@@ -1573,7 +1439,7 @@ export class MaintenanceTaskDialog extends LitElement {
entry_id: this._entryId,
task_id: savedTaskId,
enabled: this._adaptiveEnabled,
...(alpha >= 0.1 && alpha <= 0.9 ? { ewa_alpha: alpha } : {}),
...(alpha >= EWA_ALPHA_RANGE[0] && alpha <= EWA_ALPHA_RANGE[1] ? { ewa_alpha: alpha } : {}),
...(!isNaN(minIv) && minIv >= 1 ? { min_interval_days: minIv } : {}),
...(!isNaN(maxIv) && maxIv >= 1 ? { max_interval_days: maxIv } : {}),
seasonal_enabled: this._adaptiveSeasonal,
@@ -1590,13 +1456,247 @@ export class MaintenanceTaskDialog extends LitElement {
this._open = false;
}
this.dispatchEvent(new CustomEvent("task-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
}
/** The task/create | task/update message from the form (validated above). */
private _savePayload(): Record<string, unknown> {
const data: Record<string, unknown> = {
type: this._taskId
? "maintenance_supporter/task/update"
: "maintenance_supporter/task/create",
entry_id: this._entryId,
name: this._name,
task_type: this._type,
schedule_type: this._scheduleType,
// `0` is a legal, meaningful value — "no due-soon window, go straight
// from ok to overdue" (backend range is 0–365). The old
// `parseInt(...) || 7` treated it as falsy and silently rewrote a
// stored 0 to 7 on EVERY save, even when the user never touched the
// field. Same class as bug #42, but worse: it needed no user action.
// Only a genuinely unparseable field falls back, and to the
// configured default rather than a hardcoded 7.
warning_days: Number.isNaN(parseInt(this._warningDays, 10))
? this.defaultWarningDays
: Math.max(0, parseInt(this._warningDays, 10)),
};
const ecd = this._earliestCompletionDays.trim();
data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0);
if (this._taskId) data.task_id = this._taskId;
if (this._scheduleType === "one_time") {
data.due_date = this._dueDate || null;
data.interval_days = null;
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
// Calendar kinds are sent as the nested schedule; the backend prefers
// it over the flat fields and clears any stale interval/due_date.
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
data.interval_days = null;
if (this._taskId) data.due_date = null;
} else {
// Switching away from one-time clears the stale due_date on edit.
if (this._taskId) data.due_date = null;
if (this._scheduleType !== "manual" && this._intervalDays) {
data.interval_days = parseInt(this._intervalDays, 10);
data.interval_unit = this._intervalUnit;
data.interval_anchor = this._intervalAnchor;
// Season / finite-series ride a minimal nested schedule alongside the
// flat interval fields; the backend carries them onto the interval it
// derives from the flat fields (they can't be expressed flatly).
// Always send an authoritative nested schedule for interval tasks so
// an edit that clears season/ends actually clears them.
if (this._scheduleType === "time_based") {
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
}
} else if (this._taskId) {
data.interval_days = null;
data.interval_anchor = "completion";
}
}
data.notes = this._notes || null;
data.documentation_url = this._documentationUrl || null;
data.custom_icon = this._customIcon || null;
// #185: null clears the override (the update path pops the key).
data.notify_icon = this._notifyIcon.trim() || null;
data.priority = this._priority;
data.labels = this._labels
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// D#183: always sent — [] clears the mirror targets.
data.mirror_todo_entities = this._mirrorTodoEntities.filter(Boolean);
data.enabled = this._enabled;
if (!this._taskId || this._lastPerformed !== this._loadedLastPerformed) {
data.last_performed = this._lastPerformed || null;
}
data.nfc_tag_id = this._nfcTagId || null;
data.require_tag_scan = this._requireTagScan;
data.allow_skip = this._allowSkip;
data.notify_enabled = this._notifyEnabled;
data.reading_unit = this._readingUnit.trim() || null;
// #161 phase 2: always sent — [] clears the slots (single-value task).
// Sent as hydrated even when the editor is hidden (type != reading), so
// a no-op edit never wipes stored slots (the #42/#50 wipe class).
data.readings = cleanReadingSlots(this._readings);
// Task phases (#139): always sent — null clears a removed cycle.
{
const defs: Record<string, unknown> = {};
for (const d of this._phaseDefs) {
if (!d.name.trim()) continue;
const def: Record<string, unknown> = { ...d.carry, name: d.name.trim() };
const items = d.checklistText.split("\n").map((s) => s.trim()).filter(Boolean);
if (items.length) def.checklist = items;
const links: TaskPartLink[] = [];
if (d.partId) {
const qty = parseFloat(d.partQty);
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
}
// entry_id travels only when present (a shared pool, #111) — an
// own-part link stays byte-identical to what shipped before.
for (const l of d.extraParts) {
links.push(
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
if (links.length) def.consumes_parts = links;
// Override on → send the list, EMPTY included ("demands nothing");
// off → omit the key so the task-level list falls through.
if (d.reqOverride) def.required_completion_fields = [...d.reqFields];
defs[d.id] = def;
}
const seq = this._phaseSeq.filter((id) => id in defs);
data.phases = Object.keys(defs).length && seq.length ? defs : null;
data.phase_sequence = data.phases ? seq : null;
}
// Only send when a picker was actually rendered. A failed parts load
// leaves both lists empty, and sending [] then would silently wipe links
// the user never saw. entry_id is written ONLY for a foreign pick, so an
// own-parts task saves byte-identically to before (#111).
if (this.parts.length || this._foreignOwners.length) {
data.consumes_parts = Object.values(this._consumesParts).map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
if (!this._taskId || this._responsibleUserId !== this._loadedResponsibleUserId) {
data.responsible_user_id = this._responsibleUserId;
}
data.assignee_pool = this._assigneePool;
data.required_completion_fields = this._requiredCompletion;
data.rotation_strategy =
this._assigneePool.length >= 2 && this._rotationStrategy
? this._rotationStrategy
: null;
if (this._scheduleType === "sensor_based" && this._triggerType === "compound") {
// Compound: a group of conditions joined by AND/OR. Each condition
// carries its own entity + type + params; the top-level entity picker
// does not apply here.
const conditions = this._compoundConditions
.map(draftToCondition)
.filter((c): c is TriggerConfig => c !== null);
if (conditions.length > 0) {
const triggerConfig: TriggerConfig = {
type: "compound",
compound_logic: this._compoundLogic,
conditions,
};
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
data.trigger_config = triggerConfig;
} else if (this._taskId) {
data.trigger_config = null;
}
} else if (this._scheduleType === "sensor_based" && this._triggerEntityId) {
const entityIds = this._triggerEntityIds.length > 0
? this._triggerEntityIds
: [this._triggerEntityId];
const triggerConfig: TriggerConfig = {
entity_id: entityIds[0],
entity_ids: entityIds,
type: this._triggerType,
};
// The per-type fields: the same rules a compound condition uses.
applyTypeFields(triggerConfig, this._typeFields());
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
// Multi-entity: store entity_logic for all trigger types
if (entityIds.length > 1) {
triggerConfig.entity_logic = this._triggerEntityLogic;
}
// Flat-form-only fields.
if (this._triggerType === "counter" && this._triggerDeltaMode && this._triggerBaselineValue) {
// #102: optional counting start value ("last service was at X").
// Empty = count from the reading at creation / keep the live
// baseline; the backend clears stale Store state when it changes.
const b = parseFloat(this._triggerBaselineValue);
if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b;
} else if (this._triggerType === "runtime" && this._triggerRuntimeMaxSession) {
// #149: per-session cap (seconds) against sensors stuck ON.
const v = parseInt(this._triggerRuntimeMaxSession, 10);
if (!isNaN(v) && v > 0) triggerConfig.trigger_runtime_max_session_seconds = v;
}
data.trigger_config = triggerConfig;
} else if (this._taskId) {
data.trigger_config = null;
}
// Schedule time only sent when feature is enabled — null clears.
// Every date-driven kind takes one (#168), not only the interval; an
// unticked checkbox (or a ticked one with an empty picker) clears it.
if (this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType)) {
const t = this._scheduleTimeOn ? this._scheduleTime.trim() : "";
data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
}
if (this.checklistsEnabled) {
const items = this._checklistText
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.slice(0, 100);
data.checklist = items.length ? items : null;
}
// v1.3.0: on_complete_action + quick_complete_defaults (gated)
if (this.completionActionsEnabled) {
const svc = this._actionService.trim();
if (svc && /^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
const action: Record<string, unknown> = { service: svc };
const tgt = this._actionTargetEntity.trim();
if (tgt) action.target = { entity_id: tgt };
const dataDict = this._buildActionData();
if (Object.keys(dataDict).length > 0) {
action.data = dataDict;
}
data.on_complete_action = action;
} else {
data.on_complete_action = null;
}
const qcd: Record<string, unknown> = {};
if (this._qcNotes.trim()) qcd.notes = this._qcNotes.trim();
const cost = parseFloat(this._qcCost);
if (!isNaN(cost) && cost >= 0) qcd.cost = cost;
const dur = parseDurationMinutes(this._qcDuration);
if (dur !== null) qcd.duration = dur;
if (this._qcFeedback) qcd.feedback = this._qcFeedback;
data.quick_complete_defaults = Object.keys(qcd).length ? qcd : null;
}
return data;
}
private _close(): void {
this._open = false;
this._warning = "";
@@ -1899,8 +1999,8 @@ export class MaintenanceTaskDialog extends LitElement {
<ms-textfield
label="${t("adaptive_ewa_alpha", L)}"
type="number"
min="0.1"
max="0.9"
min=${EWA_ALPHA_RANGE[0]}
max=${EWA_ALPHA_RANGE[1]}
step="0.1"
.value=${this._adaptiveAlpha}
@input=${(e: Event) => (this._adaptiveAlpha = (e.target as HTMLInputElement).value)}
@@ -2041,6 +2141,8 @@ export class MaintenanceTaskDialog extends LitElement {
})}
<ms-textfield label="${t("target_changes", L)}" type="number" .value=${c.targetChanges}
@input=${(e: Event) => this._patchCondition(i, { targetChanges: (e.target as HTMLInputElement).value })}></ms-textfield>
<ms-textfield label="${t("for_minutes", L)}" type="number" .value=${c.forMinutes}
@input=${(e: Event) => this._patchCondition(i, { forMinutes: (e.target as HTMLInputElement).value })}></ms-textfield>
`;
}
if (c.type === "runtime") {
@@ -2237,7 +2339,7 @@ export class MaintenanceTaskDialog extends LitElement {
private _buildSchedule(): Record<string, unknown> {
const withOffset = (schedule: Record<string, unknown>) => {
const off = parseInt(this._calOffset, 10) || 0;
if (off) schedule.offset = Math.max(-15, Math.min(off, 15));
if (off) schedule.offset = Math.max(-SCHEDULE_OFFSET_MAX_DAYS, Math.min(off, SCHEDULE_OFFSET_MAX_DAYS));
return schedule;
};
if (this._scheduleType === "weekdays") {
@@ -2429,8 +2531,8 @@ export class MaintenanceTaskDialog extends LitElement {
label="${t("recurrence_offset", L)}"
helper="${t("recurrence_offset_help", L)}"
type="number"
min="-15"
max="15"
min=${-SCHEDULE_OFFSET_MAX_DAYS}
max=${SCHEDULE_OFFSET_MAX_DAYS}
.value=${this._calOffset}
@input=${(e: Event) => (this._calOffset = (e.target as HTMLInputElement).value)}
></ms-textfield>`;
@@ -2623,7 +2725,7 @@ export class MaintenanceTaskDialog extends LitElement {
<ms-textfield
label="${t("target_changes", L)}"
type="number"
min="1"
min=${TRIGGER_TARGET_CHANGES_RANGE[0]}
.value=${this._triggerTargetChanges}
@input=${(e: Event) => (this._triggerTargetChanges = (e.target as HTMLInputElement).value)}
></ms-textfield>
@@ -2634,7 +2736,7 @@ export class MaintenanceTaskDialog extends LitElement {
<ms-textfield
label="${t("for_at_least_minutes", L)}"
type="number"
min="0"
min=${TRIGGER_FOR_MINUTES_RANGE[0]}
.value=${this._triggerForMinutes}
@input=${(e: Event) => (this._triggerForMinutes = (e.target as HTMLInputElement).value)}
></ms-textfield>
@@ -2687,6 +2789,7 @@ export class MaintenanceTaskDialog extends LitElement {
@change=${(e: Event) => {
this._entryId = (e.target as HTMLSelectElement).value;
this._consumesParts = {};
this._consumesQtyText = {};
this._loadParts();
// The new owner drops out of the shared-pool list and the old
// one joins it, so this has to be recomputed too (#111).
@@ -2872,8 +2975,8 @@ export class MaintenanceTaskDialog extends LitElement {
<ms-textfield
label="${t("warning_days", L)}"
type="number"
min="0"
max="365"
min=${WARNING_DAYS_RANGE[0]}
max=${WARNING_DAYS_RANGE[1]}
.value=${this._warningDays}
@input=${(e: Event) => (this._warningDays = (e.target as HTMLInputElement).value)}
></ms-textfield>
@@ -12,12 +12,12 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
import { isSafeHttpUrl } from "../helpers/url";
import { formatBytes } from "../helpers/format-bytes";
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import { docCategory, docDisplayName, CATEGORY_ICONS } from "../helpers/document-categories";
import { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
import type { HomeAssistant } from "../types";
@@ -82,18 +82,31 @@ export class MaintenanceTaskDocuments extends LitElement {
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<{ documents: Doc[] }>({
type: "maintenance_supporter/documents/list",
entry_id: this.entryId,
});
this._docs = r.documents || [];
this._loaded = true;
this._error = "";
} catch (e) {
this._error = describeWsError(e, this._lang);
this._loaded = true;
}
const r = await runWs<{ documents: Doc[] }>(
this,
{ type: "maintenance_supporter/documents/list", entry_id: this.entryId },
{ onError: (m) => { this._error = m; } },
);
this._loaded = true;
if (r === undefined) return;
this._docs = r?.documents || [];
this._error = "";
}
/** A document write — helpers/ws-run runWs with the busy flag, the error
* line and the list reload. */
private async _update(payload: Record<string, unknown>): Promise<void> {
this._error = "";
await runWs(this, { type: "maintenance_supporter/documents/update", ...payload }, {
busy: (b) => { this._busy = b; },
reload: () => this._load(),
onError: (m) => { this._error = m; },
});
}
/** The signed open / download helpers, their failure on the error line. */
private async _signed(call: () => Promise<unknown>): Promise<void> {
await runWs(this, call, { onError: (m) => { this._error = m; } });
}
private _links(doc: Doc): string[] {
@@ -109,20 +122,7 @@ export class MaintenanceTaskDocuments extends LitElement {
}
private async _setLinks(doc: Doc, ids: string[]): Promise<void> {
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/update",
doc_id: doc.id,
[this._linkField]: ids,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
await this._update({ doc_id: doc.id, [this._linkField]: ids });
}
private _link(): void {
@@ -158,38 +158,17 @@ 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);
try {
await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : "");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
await this._signed(() => openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : ""));
}
/** Set (page >= 1) or clear (0) the jump-to page for this doc's task link. */
private async _setPage(doc: Doc, page: number): Promise<void> {
if (!this.taskId) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/update",
doc_id: doc.id,
task_pages: { [this.taskId]: page },
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
await this._update({ doc_id: doc.id, task_pages: { [this.taskId]: page } });
}
private async _download(doc: Doc): Promise<void> {
try {
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
await this._signed(() => downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"));
}
render() {
@@ -242,7 +221,7 @@ export class MaintenanceTaskDocuments extends LitElement {
const isFile = doc.kind === "file";
const isPdf = this._isPdf(doc);
const page = this._pageFor(doc);
const cat = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)) || "other";
const cat = docCategory(doc);
const meta = isFile ? formatBytes(doc.size, L) : t("doc_link_badge", L);
return html`
<div class="tdoc-row">
@@ -14,15 +14,21 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
import { describeWsError } from "../ws-errors";
import { sharedStyles, t, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
import { runWs } from "../helpers/ws-run";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
import { phaseLabel } from "../helpers/phases";
import { buildHistoryEntryDraft } from "../helpers/history-draft";
import { readingSlotDelta } from "../helpers/reading-slots";
import { taskRef } from "../helpers/reference";
import { renderHistoryEntry } from "../renderers/history";
import { newestFirst, renderHistoryEntry } from "../renderers/history";
import { renderStatusBadge, statusColor } from "../renderers/status";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { confirmAction } from "../helpers/confirm";
import { ToastTimer } from "../helpers/toast";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
@@ -69,7 +75,10 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
completion_actions: false,
};
@state() private _toast = "";
private _featuresLoaded = false;
private readonly _toastTimer = new ToastTimer();
/** Operator-write delegation (helpers/permissions): Edit / Archive /
* Delete follow canWrite() like the panel, not `is_admin` alone. */
@state() private _access: WriteAccess = NO_DELEGATION;
/** Currency symbol for the complete dialog's cost suggestion — read off
* the same settings response as the feature flags. */
private _currencySymbol = "";
@@ -81,6 +90,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
* sync even if the underlying card has stale data. */
public async openFor(entryId: string, taskId: string): Promise<void> {
// Reopened for ANOTHER task while open: never show the previous one
// under the new ids until the fresh load lands.
if (entryId !== this._entryId || taskId !== this._taskId) this._task = null;
this._entryId = entryId;
this._taskId = taskId;
this._error = "";
@@ -97,60 +109,70 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
/** Pull the active feature flags so adaptive sections only render when
* Adaptive / Seasonal / Environmental are actually enabled (matches the
* panel's behaviour). Cached after first load. */
* panel's behaviour), plus the write delegation and currency. Through
* the page-wide settings cache (one fetch per page, invalidated by
* global/update) — the private fetch here bypassed it and never
* re-read after a settings change (DRY audit 2026-09-26). A failed
* fetch serves the all-off fallback and asks again next open. */
private async _loadFeatures(): Promise<void> {
if (this._featuresLoaded) return;
try {
const r = await this.hass.connection.sendMessagePromise<{
features?: Partial<AdvancedFeatures>;
budget?: { currency_symbol?: string; currency_decimals?: number };
}>({ type: "maintenance_supporter/settings" });
if (r?.features) {
this._features = { ...this._features, ...r.features };
}
this._currencySymbol = currencySymbolOf(r?.budget);
syncCurrencyDecimals(r?.budget);
this._featuresLoaded = true;
} catch {
// Settings endpoint unavailable — leave defaults (all false), the
// adaptive panel will then stay hidden.
}
const settings = await fetchSettingsOnce(this.hass);
this._features = { ...this._features, ...settings.features };
this._access = settings.access;
this._currencySymbol = currencySymbolOf(settings.budget ?? undefined);
syncCurrencyDecimals(settings.budget ?? undefined);
}
public close(): void {
this._loadSeq++; // a load still in flight belongs to the closed dialog
this._open = false;
this._task = null;
this._error = "";
this._toastTimer.clear();
this._toast = "";
}
private _showToast(msg: string, ms?: number): void {
this._toast = msg;
this._toastTimer.schedule(() => { this._toast = ""; }, ms);
}
/** Only the newest `_loadTask` may land: the answer for a task the dialog
* was opened for BEFORE (or after it closed) looked up the CURRENT task
* id in the wrong object, found nothing, and left the dialog stuck on
* "Loading" (bug audit 2026-09-26 #2). */
private _loadSeq = 0;
private async _loadTask(): Promise<void> {
if (!this._entryId || !this._taskId) return;
try {
const r = await this.hass.connection.sendMessagePromise<MaintenanceObjectFull>({
type: "maintenance_supporter/object",
entry_id: this._entryId,
});
this._objectName = r.object?.name || "";
const found = (r.tasks || []).find((t) => t.id === this._taskId);
this._task = found ?? null;
this._taskRef = taskRef(r.object, found);
} catch (e) {
this._error = describeWsError(e, this._lang);
const entryId = this._entryId;
const taskId = this._taskId;
if (!entryId || !taskId) return;
const seq = ++this._loadSeq;
let failure = "";
const r = await runWs<MaintenanceObjectFull>(
this,
{ type: "maintenance_supporter/object", entry_id: entryId },
{ onError: (m) => { failure = m; } },
);
if (seq !== this._loadSeq) return;
if (r === undefined) {
this._error = failure;
return;
}
this._objectName = r?.object?.name || "";
const found = (r?.tasks || []).find((t) => t.id === taskId);
this._task = found ?? null;
this._taskRef = taskRef(r?.object, found);
// A task deleted meanwhile: say so instead of "Loading" forever.
if (!found) this._error = t("ws_err_not_found", this._lang);
}
private async _runWs(payload: Record<string, unknown>): Promise<boolean> {
this._busy = true;
/** helpers/ws-run runWs bound to this dialog's busy flag and error line. */
private _runWs<T = unknown>(payload: Record<string, unknown>): Promise<T | undefined> {
this._error = "";
try {
await this.hass.connection.sendMessagePromise(payload);
this._busy = false;
return true;
} catch (e) {
this._error = describeWsError(e, this._lang);
this._busy = false;
return false;
}
return runWs<T>(this, payload, {
busy: (b) => { this._busy = b; },
onError: (m) => { this._error = m; },
});
}
private _notifyChanged(action: string): void {
@@ -195,8 +217,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task,
objects,
lang: this._lang,
checklist: task.checklist || [],
adaptiveEnabled: !!task.adaptive_config?.enabled,
// The feature switches gate the checklist and the adaptive
// feedback exactly like the panel (DRY audit 2026-09-26).
features: this._features,
currencySymbol: this._currencySymbol,
}),
);
@@ -215,7 +238,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task_id: this._taskId,
reason: this._skipReason.trim() || null,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("skip");
this.close();
}
@@ -229,7 +252,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task_id: this._taskId,
date: this._resetDate || undefined,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("reset");
this.close();
}
@@ -258,15 +281,19 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
private async _onDelete(): Promise<void> {
if (!this._entryId || !this._taskId) return;
const confirmText = t("delete_task_confirm", this._lang)
|| `Delete "${this._task?.name}"?`;
if (!window.confirm(confirmText)) return;
const confirmed = await confirmAction(this.hass, {
title: t("delete", this._lang),
message: t("delete_task_confirm", this._lang),
confirmText: t("delete", this._lang),
danger: true,
});
if (!confirmed) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/delete",
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("delete");
this.close();
}
@@ -279,7 +306,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("archive");
this.close();
}
@@ -292,7 +319,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("unarchive");
this.close();
}
@@ -315,41 +342,32 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task_id: this._taskId,
interval: this._task.suggested_interval,
});
if (ok) {
this._toast = t("suggestion_applied", this._lang);
if (ok !== undefined) {
this._showToast(t("suggestion_applied", this._lang));
this._notifyChanged("apply_suggestion");
// Refresh local task so the recommendation card hides
await this._loadTask();
setTimeout(() => { this._toast = ""; }, 2500);
}
}
private async _reanalyzeInterval(): Promise<void> {
if (!this._entryId || !this._taskId) return;
this._busy = true;
this._error = "";
try {
const r = await this.hass.connection.sendMessagePromise<{
recommended_interval: number | null;
confidence: string;
data_points: number;
}>({
type: "maintenance_supporter/task/analyze_interval",
entry_id: this._entryId,
task_id: this._taskId,
});
this._toast = r.recommended_interval
// The analyzer always works in DAYS (helpers/interval_analyzer.py), so
// the unit is pinned here rather than taken from the task's own unit.
? `${t("reanalyze_result", this._lang)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
: t("reanalyze_insufficient_data", this._lang);
await this._loadTask();
setTimeout(() => { this._toast = ""; }, 3500);
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
const r = await this._runWs<{
recommended_interval: number | null;
confidence: string;
data_points: number;
}>({
type: "maintenance_supporter/task/analyze_interval",
entry_id: this._entryId,
task_id: this._taskId,
});
if (r === undefined) return;
this._showToast(r?.recommended_interval
// The analyzer always works in DAYS (helpers/interval_analyzer.py), so
// the unit is pinned here rather than taken from the task's own unit.
? `${t("reanalyze_result", this._lang)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
: t("reanalyze_insufficient_data", this._lang));
await this._loadTask();
}
private _onEditHistoryEntry(entry: HistoryEntry): void {
@@ -378,11 +396,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task.interval_confidence || "medium", L,
)}
<div class="recommendation-actions">
<button class="btn primary"
@click=${this._applySuggestion} ?disabled=${this._busy}>
<ha-icon icon="mdi:check"></ha-icon>
${t("apply_suggestion", L)}
</button>
${canWrite(this.hass?.user, this._access)
? html`<button class="btn primary qa-apply-suggestion"
@click=${this._applySuggestion} ?disabled=${this._busy}>
<ha-icon icon="mdi:check"></ha-icon>
${t("apply_suggestion", L)}
</button>`
: nothing /* task/apply_suggestion is write tier */}
<button class="btn"
@click=${this._reanalyzeInterval} ?disabled=${this._busy}>
<ha-icon icon="mdi:refresh"></ha-icon>
@@ -440,55 +460,51 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
* existing history-edit dialog (which lives in the same dialog-mount). */
private _renderDetails(task: MaintenanceTask) {
const L = this._lang;
// The list payload carries only the most recent entries (payload diet,
// 20) — the stats come from the server's totals over the WHOLE history,
// not from that window: a task done 30 times read "20" here (DRY audit
// 2026-09-26). The list itself stays the recent window, newest first by
// timestamp (a backdated #133 completion is appended last).
const history = (task.history || []) as HistoryEntry[];
const completed = history.filter((h) => h.type === "completed");
const totalCost = completed.reduce(
(s, h) => s + (typeof h.cost === "number" ? h.cost : 0),
0,
);
const avgDuration = (() => {
const durs = completed
.map((h) => (typeof h.duration === "number" ? h.duration : null))
.filter((d): d is number => d != null);
if (!durs.length) return null;
return Math.round(durs.reduce((s, d) => s + d, 0) / durs.length);
})();
const totalEntries = task.history_count ?? history.length;
const recent = newestFirst(history).slice(0, 20);
return html`
<div class="details">
<div class="stats-grid">
<div class="stat">
<span class="stat-label">${t("times_performed", L)}</span>
<span class="stat-value">${completed.length}</span>
<span class="stat-value">${task.times_performed ?? 0}</span>
</div>
<div class="stat">
<span class="stat-label">${t("total_cost", L)}</span>
<span class="stat-value">${formatCost(totalCost, this._currencySymbol, L)}</span>
<span class="stat-value">${formatCost(task.total_cost ?? 0, this._currencySymbol, L)}</span>
</div>
<div class="stat">
<span class="stat-label">${t("avg_duration", L)}</span>
<span class="stat-value">${formatDuration(avgDuration, L)}</span>
<span class="stat-value">${formatDuration(task.average_duration != null ? Math.round(task.average_duration) : null, L)}</span>
</div>
</div>
<div class="history-header">
<strong>${t("history", L)}</strong>
<span class="history-count">${history.length}</span>
<span class="history-count">${totalEntries}</span>
</div>
${history.length === 0
? html`<div class="history-empty">${t("history_empty", L)}</div>`
: html`
<div class="history-list">
${[...history].reverse().slice(0, 20).map((entry) => renderHistoryEntry(entry, {
${recent.map((entry) => renderHistoryEntry(entry, {
lang: L,
hass: this.hass,
currencySymbol: this._currencySymbol,
openEdit: (e) => this._onEditHistoryEntry(e),
// task/history/update is write tier: no pencil otherwise.
openEdit: canWrite(this.hass?.user, this._access) ? (e) => this._onEditHistoryEntry(e) : undefined,
readingUnit: task.reading_unit,
readingSlotDelta: (e, slotId) => readingSlotDelta(history, e, slotId),
taskRef: this._taskRef,
}, { compact: true }))}
${history.length > 20
? html`<div class="history-more">… +${history.length - 20} ${t("older_entries", L)}</div>`
${totalEntries > recent.length
? html`<div class="history-more">… +${totalEntries - recent.length} ${t("older_entries", L)}</div>`
: nothing}
</div>
`}
@@ -500,17 +516,16 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
if (!this._open) return nothing;
const L = this._lang;
const task = this._task;
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
const writer = canWrite(this.hass?.user, this._access);
return html`
<div class="backdrop" @click=${this.close}></div>
<div class="dialog" role="dialog" aria-modal="true">
return renderModalShell(() => this.close(), html`
${task
? html`
<div class="header">
<div class="title">
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
<span class="status-dot" style="background: ${statusColor(task)}"></span>
<span class="task-name">${task.name}</span>
${renderStatusBadge(task, L)}
</div>
<div class="object">
<button class="link-inline" @click=${() => {
@@ -597,30 +612,33 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
${t("reset", L)}
</ha-button>
</div>
${isAdmin
? html`
<div class="actions secondary-row">
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onEdit} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
${t("edit", L)}
</ha-button>
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onQr} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
${t("qr_code", L)}
</ha-button>
<ha-button size="small" appearance="outlined" variant="neutral"
@click=${task.archived ? this._onUnarchive : this._onArchive}
.disabled=${this._busy}>
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${task.archived ? t("unarchive", L) : t("archive", L)}
</ha-button>
<ha-button size="small" appearance="outlined" variant="danger" class="danger" @click=${this._onDelete} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
${t("delete", L)}
</ha-button>
</div>
`
: nothing}
<!-- QR is read tier (the panel offers it to operators too);
Edit / Archive / Delete follow canWrite() — the panel's
operator delegation, not is_admin alone. -->
<div class="actions secondary-row">
${writer
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-edit" @click=${this._onEdit} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
${t("edit", L)}
</ha-button>`
: nothing}
<ha-button size="small" appearance="outlined" variant="neutral" class="qa-qr" @click=${this._onQr} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
${t("qr_code", L)}
</ha-button>
${writer
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-archive"
@click=${task.archived ? this._onUnarchive : this._onArchive}
.disabled=${this._busy}>
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${task.archived ? t("unarchive", L) : t("archive", L)}
</ha-button>
<ha-button size="small" appearance="outlined" variant="danger" class="danger qa-delete" @click=${this._onDelete} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
${t("delete", L)}
</ha-button>`
: nothing}
</div>
<div class="details-toggle">
<button class="link" @click=${() => { this._showDetails = !this._showDetails; }}>
<ha-icon icon="${this._showDetails ? 'mdi:chevron-up' : 'mdi:chevron-down'}"></ha-icon>
@@ -649,30 +667,18 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
</div>
`}
`
: html`<div class="loading">${t("loading", L)}</div>`}
</div>
`;
: this._error
? html`<div class="error">${this._error}</div>`
: html`<div class="loading">${t("loading", L)}</div>`}
`);
}
static styles = [sharedStyles, css`
:host { display: contents; }
.backdrop {
position: fixed; inset: 0; z-index: 100;
background: rgba(0,0,0,0.5);
}
.dialog {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 95vw; max-width: 460px;
max-height: 92vh; overflow: auto;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
color: var(--primary-text-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
padding: 20px;
display: flex; flex-direction: column; gap: 14px;
z-index: 101;
}
protected updated(changed: Map<string, unknown>): void {
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
}
static styles = [sharedStyles, modalShellStyles, css`
:host { display: contents; --ms-modal-max-width: 460px; }
.header { display: flex; flex-direction: column; gap: 6px; }
.title { display: flex; align-items: center; gap: 10px; }
.status-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
@@ -15,7 +15,9 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf, syncLocaleFromHass } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { VACATION_BUFFER_DAYS_RANGE } from "../helpers/setting-ranges";
import { confirmAction } from "../helpers/confirm";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import "./ms-date-field";
import type { HomeAssistant } from "../types";
@@ -43,7 +45,8 @@ export class MaintenanceVacationSectionCard extends LitElement {
@state() private _error = "";
@state() private _localStart = "";
@state() private _localEnd = "";
@state() private _localBuffer = 7;
/** Typed text — validated against VACATION_BUFFER_DAYS_RANGE on Save. */
@state() private _localBuffer = "3";
@state() private _dirty = false;
private _loaded = false;
@@ -76,72 +79,68 @@ export class MaintenanceVacationSectionCard extends LitElement {
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
type: "maintenance_supporter/vacation/state",
});
this._state = r;
this._localStart = r.start || "";
this._localEnd = r.end || "";
this._localBuffer = r.buffer_days ?? 7;
this._dirty = false;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
const r = await runWs<VacationState>(this, { type: "maintenance_supporter/vacation/state" }, {
onError: (m) => { this._error = m; },
});
if (!r) return;
this._state = r;
this._localStart = r.start || "";
this._localEnd = r.end || "";
this._localBuffer = String(r.buffer_days ?? 3);
this._dirty = false;
}
/** One vacation write — helpers/ws-run runWs with the busy flag and the
* error line; resolves the new state (undefined when refused). */
private _act(payload: Record<string, unknown>): Promise<VacationState | null | undefined> {
this._error = "";
return runWs<VacationState>(this, payload, {
busy: (b) => { this._busy = b; },
onError: (m) => { this._error = m; },
});
}
private async _toggleEnabled(on: boolean): Promise<void> {
this._busy = true;
this._error = "";
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
type: "maintenance_supporter/vacation/update",
enabled: on,
});
this._state = r;
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
const r = await this._act({ type: "maintenance_supporter/vacation/update", enabled: on });
if (r) this._state = r;
}
private async _save(): Promise<void> {
if (!this._isAdmin) return;
this._busy = true;
this._error = "";
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
type: "maintenance_supporter/vacation/update",
start: this._localStart || null,
end: this._localEnd || null,
buffer_days: this._localBuffer,
});
this._state = r;
this._dirty = false;
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
// Same range + message as the settings view (DRY audit 2026-09-26): an
// out-of-range buffer is named up front instead of round-tripping into
// a voluptuous error.
const [min, max] = VACATION_BUFFER_DAYS_RANGE;
const buffer = Number(this._localBuffer);
if (this._localBuffer.trim() === "" || !Number.isInteger(buffer) || buffer < min || buffer > max) {
this._error = t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max));
return;
}
const r = await this._act({
type: "maintenance_supporter/vacation/update",
start: this._localStart || null,
end: this._localEnd || null,
buffer_days: buffer,
});
if (!r) return;
this._state = r;
this._dirty = false;
}
private async _endNow(): Promise<void> {
if (!this._isAdmin) return;
if (!window.confirm(t("vacation_end_now_confirm", this._lang))) return;
this._busy = true;
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
type: "maintenance_supporter/vacation/end_now",
});
this._state = r;
this._localStart = r.start || "";
this._localEnd = r.end || "";
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
// The confirm dialog, not the browser's native modal (DRY audit 2026-09-26).
const confirmed = await confirmAction(this.hass, {
title: t("vacation_end_now", this._lang),
message: t("vacation_end_now_confirm", this._lang),
confirmText: t("vacation_end_now", this._lang),
});
if (!confirmed) return;
const r = await this._act({ type: "maintenance_supporter/vacation/end_now" });
if (!r) return;
this._state = r;
this._localStart = r.start || "";
this._localEnd = r.end || "";
}
private _onDeepLink(): void {
@@ -224,12 +223,11 @@ export class MaintenanceVacationSectionCard extends LitElement {
</div>
<div class="date-field buffer">
<label>${t("vacation_buffer", L)}</label>
<input type="number" min="0" max="14"
.value=${String(this._localBuffer)}
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]}
.value=${this._localBuffer}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localBuffer = parseInt(
(e.target as HTMLInputElement).value, 10) || 0;
this._localBuffer = (e.target as HTMLInputElement).value;
this._dirty = true;
}} />
</div>