278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
@@ -41,12 +41,19 @@ interface BatteryRow {
no_sensor?: boolean;
/** D#162 follow-up: the note has a replaced button → per-row Replaced action. */
can_mark_replaced?: boolean;
/** D#162 follow-up: which type lifetime the typical forecast used. */
lifetime_months?: number | null;
lifetime_source?: "override" | "learned_device" | "learned_model" | "table" | "default" | null;
lifetime_samples?: number;
/** ISO date the forecast anchors on (Battery Notes' last replaced). */
last_replaced?: string | null;
}
interface RosterRow extends BatteryRow {
status: "low" | "soon" | "ok";
}
/** What the shared row template takes: a low/soon row, or a roster row
* (which adds the status). */
type FleetRow = BatteryRow & { status?: RosterRow["status"] };
/** 30 d downsampled level history per battery, for the roster sparklines.
* threshold = the same low threshold the trend forecast regresses toward,
* so the dotted projection ends exactly where the ~date comes from.
@@ -252,8 +259,37 @@ export class MaintenanceBatteryFleetSection extends LitElement {
/** Lazy: the recorder-backed history is fetched once, when the roster is
* first expanded — most panel visits never open it. */
/** D#162: the roster is open unless this browser collapsed it before. */
private _rosterOpen(): boolean {
return lsGet(LS_KEYS.batteryRosterOpen) !== "0";
}
/** The ~date tooltip: trend (with confidence) or the type lifetime with
* where it came from — "18 months typical for CR2032 (learned from 5
* replacements)" (D#162 follow-up). */
private _predictedTitle(b: BatteryRow, L: string): string {
if (b.forecast_overdue) return t("battery_fleet_forecast_overdue", L);
const date = this._predictedDate(b.days_until ?? 0);
if (b.predicted_source === "trend") {
return t("battery_fleet_predicted_trend", L)
.replace("{date}", date)
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L));
}
if (b.lifetime_months != null && b.lifetime_source) {
const source = t("lifetime_source_" + b.lifetime_source, L).replace("{n}", String(b.lifetime_samples ?? 0));
return t("battery_fleet_predicted_typical", L)
.replace("{date}", date)
.replace("{months}", String(b.lifetime_months))
.replace("{type}", b.battery_type)
.replace("{source}", source);
}
return t("battery_fleet_predicted_on", L).replace("{date}", date);
}
private _loadHistory = async (e: Event): Promise<void> => {
if (!(e.target as HTMLDetailsElement).open || this._historyRequested) return;
const open = (e.target as HTMLDetailsElement).open;
lsSet(LS_KEYS.batteryRosterOpen, open ? "1" : "0");
if (!open || this._historyRequested) return;
this._historyRequested = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ series: HistorySeries }>({
@@ -269,7 +305,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* where the ~date comes from the discharge trend — a dotted projection
* from the last reading down to the threshold, so the date is visible
* instead of merely stated. */
private _sparkline(b: RosterRow) {
private _sparkline(b: FleetRow) {
const h = this._history?.[b.entity_id];
if (!h || h.points.length < 2) return nothing;
const W = 110, H = 24, P = 2;
@@ -406,6 +442,106 @@ export class MaintenanceBatteryFleetSection extends LitElement {
></span>`;
}
/** The "record this replacement" chip: the level history shows an upward
* jump nobody recorded in Battery Notes (roster only). */
private _jumpButton(b: BatteryRow, L: string) {
const jump = this._history?.[b.entity_id]?.jump;
if (!jump || this._recorded.includes(b.entity_id)) return nothing;
return html`<button
class="bf-mark bf-jump"
title=${t("battery_fleet_record_replacement", L).replace("{date}", this._fmtDate(jump.at * 1000))}
.disabled=${this._marking}
@click=${() => this._recordJump(b.entity_id, jump)}
>
<ha-icon icon="mdi:calendar-sync"></ha-icon>
</button>`;
}
/** ONE row template for the three lists (low / needed soon / roster).
* They were hand-copied and had drifted: only the low list said
* "recharged" on a rechargeable's mark action and only it showed the
* offline hint (DRY audit 2026-09-12). `o` names the per-list extras.
*
* DOM order is load-bearing for the grid CSS: `.bf-status + .bf-type +
* .bf-offline` (the roster's chip sits AFTER the type, the low/soon
* lists' BEFORE it), `.bf-level + .bf-mark.bf-replaced`, and the phone
* layout's column assignments — keep it. */
private _renderRow(
b: FleetRow,
L: string,
o: {
/** Roster: status chip in column 2, the sensor chip after the type. */
status?: boolean;
/** Rechargeable badge (low list + roster). */
recharge?: boolean;
/** 30 d level sparkline (roster). */
sparkline?: boolean;
/** "always": every row gets the mark action (low list); "replaced":
* only a sensorless row or a note with a replaced button. */
mark: "always" | "replaced";
jump?: boolean;
predicted?: boolean;
exclude?: boolean;
},
) {
const chip =
b.available === false
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
: b.no_sensor
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
: nothing;
const type = html`<span class="bf-type">${b.quantity}× ${b.battery_type}</span>`;
const showMark = o.mark === "always" || b.no_sensor || b.can_mark_replaced;
return html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
${o.status
? html`<span class="bf-status bf-${b.status}"
>${b.no_sensor && b.status === "low"
? t("battery_fleet_status_due", L)
: t("battery_fleet_status_" + b.status, L)}</span
>${type}${chip}`
: html`${chip}${type}`}
${o.recharge && b.rechargeable
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
></span>`
: nothing}
${o.sparkline ? this._sparkline(b) : nothing}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
${showMark
? html`<button
class="bf-mark${o.mark === "replaced" ? " bf-replaced" : ""}"
title=${b.rechargeable ? t("battery_fleet_mark_recharged", L) : t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>`
: nothing}
${o.jump ? this._jumpButton(b, L) : nothing}
${o.predicted && b.days_until != null
? html`<span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
title=${this._predictedTitle(b, L)}
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
>`
: nothing}
${o.exclude
? html`<button
class="bf-mark bf-exclude"
title=${t("battery_fleet_exclude", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(b.entity_id, true)}
>
<ha-icon icon="mdi:eye-off-outline"></ha-icon>
</button>`
: nothing}
</div>
`;
}
render() {
const L = this._lang;
if (this._loading && this._ov === null) return html`<div class="bf-card"><div class="bf-loading">…</div></div>`;
@@ -442,42 +578,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
<span class="bf-list">${this._shoppingLine(ov.needs_now)}</span>
</div>
<div class="bf-rows">
${ov.low.map(
(b) => html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
${b.available === false
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
: b.no_sensor
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
: nothing}
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
${b.rechargeable
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
></span>`
: nothing}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
<button
class="bf-mark"
title=${b.rechargeable ? t("battery_fleet_mark_recharged", L) : t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>
<button
class="bf-mark bf-exclude"
title=${t("battery_fleet_exclude", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(b.entity_id, true)}
>
<ha-icon icon="mdi:eye-off-outline"></ha-icon>
</button>
</div>
`,
)}
${ov.low.map((b) => this._renderRow(b, L, { recharge: true, mark: "always", exclude: true }))}
</div>
<div class="bf-actions">
<ha-button .disabled=${this._marking} @click=${this._markAll}>
@@ -493,11 +594,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
<span class="bf-list">${this._shoppingLine(ov.needs_soon)}</span>
<div class="bf-soon-hint">${t("battery_fleet_soon_hint", L)}</div>
</div>
<div class="bf-rows bf-soon-rows">
${ov.soon.map((b) => this._renderRow(b, L, { mark: "replaced", predicted: true }))}
</div>
`
: nothing}
${ov.all?.length
? html`
<details class="bf-roster" @toggle=${this._loadHistory}>
<details class="bf-roster" ?open=${this._rosterOpen()} @toggle=${this._loadHistory}>
<summary>${t("battery_fleet_all", L)} (${ov.all.length})</summary>
<div class="bf-roster-tools">
<button
@@ -514,72 +618,8 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</button>
</div>
<div class="bf-rows">
${this._sortedRoster(ov.all).map(
(b) => html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
<span class="bf-status bf-${b.status}"
>${b.no_sensor && b.status === "low"
? t("battery_fleet_status_due", L)
: t("battery_fleet_status_" + b.status, L)}</span
>
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
${b.no_sensor
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
: nothing}
${b.rechargeable
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
></span>`
: nothing}
${this._sparkline(b)}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
${b.no_sensor || b.can_mark_replaced
? html`<button
class="bf-mark bf-replaced"
title=${t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>`
: nothing}
${(() => {
const jump = this._history?.[b.entity_id]?.jump;
if (!jump || this._recorded.includes(b.entity_id)) return nothing;
return html`<button
class="bf-mark bf-jump"
title=${t("battery_fleet_record_replacement", L).replace("{date}", this._fmtDate(jump.at * 1000))}
.disabled=${this._marking}
@click=${() => this._recordJump(b.entity_id, jump)}
>
<ha-icon icon="mdi:calendar-sync"></ha-icon>
</button>`;
})()}
${b.days_until != null
? html`<span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
title=${b.forecast_overdue
? t("battery_fleet_forecast_overdue", L)
: b.predicted_source === "trend"
? t("battery_fleet_predicted_trend", L)
.replace("{date}", this._predictedDate(b.days_until))
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
>`
: nothing}
<button
class="bf-mark bf-exclude"
title=${t("battery_fleet_exclude", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(b.entity_id, true)}
>
<ha-icon icon="mdi:eye-off-outline"></ha-icon>
</button>
</div>
`,
${this._sortedRoster(ov.all).map((b) =>
this._renderRow(b, L, { status: true, recharge: true, sparkline: true, mark: "replaced", jump: true, predicted: true, exclude: true }),
)}
</div>
<div class="bf-roster-hint">${t("battery_fleet_all_hint", L)}</div>
@@ -761,9 +801,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-bar {
grid-column: 6;
}
/* D#162: the roster's "No sensor" chip takes the level bar's slot
* (such a row has no bar); column 2 stays the status chip's. */
.bf-status + .bf-type + .bf-nosensor {
/* D#162: the roster's "No sensor" / offline chip takes the level bar's
* slot (such a row has no bar); column 2 stays the status chip's. */
.bf-status + .bf-type + .bf-offline {
grid-column: 6;
justify-self: start;
white-space: nowrap;
@@ -783,6 +823,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
grid-column: 7;
justify-self: end;
}
/* D#162 (maisun's iPhone): since 2.79 rows WITH a level offer Replaced
* too — level and button shared column 7 and overlapped. With a
* percentage present the button takes the row-action column instead. */
.bf-row .bf-level + .bf-mark.bf-replaced {
grid-column: 8;
justify-self: start;
}
.bf-predicted {
grid-column: 9;
justify-self: end;
@@ -855,11 +902,38 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-row .bf-mark {
grid-row: 2;
}
/* Phone (D#162, maisun): the percentage belongs to the name line - it
* is what you scan for - and the Replaced action to line 2 with type
* and date. A row WITH a percentage therefore lifts it to line 1 (the
* name yields column 8; in the "Needed soon" list, which has no status
* chip, it takes the chip's place at the right edge) and the action
* takes the percentage's slot on line 2 - the same slot a sensorless
* row uses, so every row's action lines up. Before, the action shared
* the slot with the percentage and painted over it. */
.bf-row .bf-level {
grid-row: 1;
grid-column: 8;
justify-self: end;
}
.bf-soon-rows .bf-row .bf-level {
grid-column: 9 / 11;
}
.bf-row:has(.bf-level) .bf-dev {
grid-column: 1 / 8;
}
.bf-soon-rows .bf-row:has(.bf-level) .bf-dev {
grid-column: 1 / 9;
}
.bf-row .bf-level + .bf-mark.bf-replaced {
grid-row: 2;
grid-column: 7;
justify-self: end;
}
/* The roster's "No sensor" chip would widen the shared bar track
* for EVERY row (subgrid) - on phones the missing percentage and
* the "Due" status already tell the story, so it yields like the
* bar and the sparkline do. */
.bf-status + .bf-type + .bf-nosensor {
.bf-status + .bf-type + .bf-offline {
display: none;
}
}
@@ -8,7 +8,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, syncLocaleFromHass, DEFAULT_CURRENCY_SYMBOL, langOf, formatNumber, formatCost } from "../styles";
import { t, syncLocaleFromHass, currencySymbolOf, langOf, formatCost, syncCurrencyDecimals} from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
@@ -64,6 +64,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
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;
@@ -107,20 +108,20 @@ export class MaintenanceBudgetSectionCard extends LitElement {
const L = this._lang;
const s = this._status;
if (!s) {
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
return html`<ha-card><div class="loading">${t("loading", L)}</div></ha-card>`;
}
const sym = s.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
const sym = currencySymbolOf(s);
// The amber step is the CONFIGURED budget_alert_threshold, not a literal 80
// — same rule the panel's budget bar uses (maintenance-panel._renderBudgetBar).
const threshold = s.alert_threshold_pct ?? DEFAULT_ALERT_THRESHOLD_PCT;
const tracks = [
{
label: t("budget_monthly", L) || "Monthly",
label: t("budget_monthly", L),
spent: s.monthly_spent || 0,
budget: s.monthly_budget || 0,
},
{
label: t("budget_yearly", L) || "Yearly",
label: t("budget_yearly", L),
spent: s.yearly_spent || 0,
budget: s.yearly_budget || 0,
},
@@ -132,7 +133,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
<div class="header">
<div class="title">
<span class="emoji">💰</span>
<span>${this._config.title || t("settings_budget", L) || "Budget"}</span>
<span>${this._config.title || t("settings_budget", L)}</span>
</div>
<span class="currency">${sym}</span>
</div>
@@ -148,7 +149,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
<div class="track spent-only">
<div class="track-label-row">
<label>${track.label}</label>
<span class="track-numbers ok">${formatCost(track.spent, sym, L, 0)}</span>
<span class="track-numbers ok">${formatCost(track.spent, sym, L)}</span>
</div>
</div>
`;
@@ -160,7 +161,14 @@ export class MaintenanceBudgetSectionCard extends LitElement {
<div class="track-label-row">
<label>${track.label}</label>
<span class="track-numbers ${warn}">
${formatNumber(track.spent, L, 0)} / ${formatCost(track.budget, sym, L, 0)}
${
// Spent and budget must round alike: the spent half was
// pinned to 0 decimals while the budget followed the
// "Decimal places for amounts" setting ("65 / 100.00 €";
// bug audit 2026-09-12). formatCost without a symbol =
// the setting-driven decimals, symbol once at the end.
formatCost(track.spent, undefined, L)
} / ${formatCost(track.budget, sym, L)}
</span>
</div>
<div class="bar"><div class="bar-fill ${warn}" style="width:${pct}%"></div></div>
@@ -172,7 +180,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
? html`
<div class="inputs-row">
<div class="input-field">
<label>${t("budget_monthly_set", L) || "Set monthly"}</label>
<label>${t("budget_monthly_set", L)}</label>
<div class="input-wrap">
<input type="number" min="0" step="1"
.value=${this._localMonthly}
@@ -185,7 +193,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
</div>
</div>
<div class="input-field">
<label>${t("budget_yearly_set", L) || "Set yearly"}</label>
<label>${t("budget_yearly_set", L)}</label>
<div class="input-wrap">
<input type="number" min="0" step="1"
.value=${this._localYearly}
@@ -204,17 +212,17 @@ export class MaintenanceBudgetSectionCard extends LitElement {
?disabled=${this._busy || !this._dirty}>
<ha-icon icon="${this._dirty ? "mdi:content-save" : "mdi:check"}"></ha-icon>
${this._dirty
? (t("save", L) || "Save")
: (t("saved", L) || "Saved")}
? t("save", L)
: t("saved", L)}
</button>
<button class="btn link" @click=${this._onDeepLink}>
${t("budget_advanced", L) || "Currency, alerts…"}
${t("budget_advanced", L)}
</button>
</div>
`
: html`
<button class="btn link" @click=${this._onDeepLink}>
${t("budget_open_panel", L) || "Open in panel"}
${t("budget_open_panel", L)}
</button>
`}
</div>
@@ -7,14 +7,11 @@ import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-
import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles";
import { describeWsError } from "../ws-errors";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { isAndroidCompanion } from "../helpers/companion";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import {
MAX_COMPLETION_PHOTOS,
discardUploadedPhotos,
uploadCompletionPhoto,
} from "../helpers/photo-upload";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import "./ms-date-field";
import "./ms-photo-picker";
import { photoPickerStyles } from "./ms-photo-picker";
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -76,12 +73,12 @@ export class MaintenanceCompleteDialog extends LitElement {
@state() private _error = "";
@state() private _checklistState: Record<string, boolean> = {};
@state() private _feedback: string = "needed";
/** #161: the photos attached so far, in pick order (preview = object URL). */
@state() private _photos: Array<{ id: string; preview: string }> = [];
/** Docs uploaded by THIS dialog session; dropped again on Cancel so an
* abandoned completion leaves no orphan files behind. */
private _uploadedIds: string[] = [];
@state() private _photoUploading = false;
/** #161: the photos attached so far (uploads of this session are dropped
* again on Cancel so an abandoned completion leaves no orphans). */
private readonly _photos = new PhotoUploadController(this, {
entryId: () => this.entryId,
hass: () => this.hass,
});
@state() private _readingValue = "";
/** #161 phase 2: typed text per slot id (parsed on save; "" = unread). */
@state() private _readingValues: Record<string, string> = {};
@@ -114,10 +111,7 @@ export class MaintenanceCompleteDialog extends LitElement {
.filter(([, done]) => done),
);
this._feedback = "needed";
this._photos.forEach((p) => URL.revokeObjectURL(p.preview));
this._photos = [];
this._uploadedIds = [];
this._photoUploading = false;
this._photos.reset();
this._readingValue = "";
this._readingValues = {};
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
@@ -140,57 +134,10 @@ export class MaintenanceCompleteDialog extends LitElement {
this._feedback = value;
}
/** The Android Companion app answers a multi-select with an empty file
* list (its chooser ignores the intent's ClipData — #161 follow-up), so
* the gallery picker is single-select there and a hint says each pick
* is added. Evaluated once per dialog; the host does not change. */
private readonly _singlePick = isAndroidCompanion();
/** #161: both pickers (camera = one shot, gallery = multiple) land here.
* Files upload one after another so a slow connection still shows
* progress tile by tile; anything beyond the cap is dropped with a
* note rather than silently. */
private async _onPhotoInput(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = ""; // allow re-picking the same file
if (files.length === 0) return;
const room = MAX_COMPLETION_PHOTOS - this._photos.length;
const accepted = files.slice(0, Math.max(room, 0));
this._photoUploading = true;
this._error = "";
try {
for (const file of accepted) {
const id = await uploadCompletionPhoto(this.hass, this.entryId, file);
this._uploadedIds = [...this._uploadedIds, id];
this._photos = [...this._photos, { id, preview: URL.createObjectURL(file) }];
}
if (files.length > accepted.length) {
this._error = t("photos_limit", this.lang).replace("{max}", String(MAX_COMPLETION_PHOTOS));
}
} catch (e) {
const key = e instanceof Error && e.message === "doc_too_large" ? "doc_too_large" : "doc_upload_failed";
this._error = t(key, this.lang);
} finally {
this._photoUploading = false;
}
}
/** ✕ on a tile: drop it from the completion AND delete the upload —
* the file only ever existed for this dialog session. */
private _removePhoto(id: string): void {
const gone = this._photos.find((p) => p.id === id);
if (gone) URL.revokeObjectURL(gone.preview);
this._photos = this._photos.filter((p) => p.id !== id);
if (this._uploadedIds.includes(id)) {
this._uploadedIds = this._uploadedIds.filter((x) => x !== id);
void discardUploadedPhotos(this.hass, [id]);
}
}
private async _complete(): Promise<void> {
this._loading = true;
this._error = "";
this._photos.clearError();
try {
const data: Record<string, unknown> = {
type: "maintenance_supporter/task/complete",
@@ -212,8 +159,8 @@ export class MaintenanceCompleteDialog extends LitElement {
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photos.length > 0) {
data.photo_doc_ids = this._photos.map((p) => p.id);
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
@@ -266,7 +213,7 @@ export class MaintenanceCompleteDialog extends LitElement {
);
}
await this.hass.connection.sendMessagePromise(data);
this._uploadedIds = []; // attached now — Cancel cleanup must not touch them
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
} catch (e) {
@@ -306,7 +253,7 @@ export class MaintenanceCompleteDialog extends LitElement {
notes: this._notes.trim() !== "",
cost: this._cost.trim() !== "",
duration: this._duration.trim() !== "",
photo: this._photos.length > 0,
photo: this._photos.photos.length > 0,
// "Who did it" is filled in server-side from the authenticated
// connection (websocket/tasks_actions.py), so the dialog satisfies it
// as long as we ARE a logged-in user. Claiming it is always satisfied
@@ -365,11 +312,7 @@ export class MaintenanceCompleteDialog extends LitElement {
private _close(): void {
this._open = false;
if (this._uploadedIds.length > 0) {
const orphans = this._uploadedIds;
this._uploadedIds = [];
void discardUploadedPhotos(this.hass, orphans);
}
this._photos.discardOrphans();
}
/** Seed the backdate field with the current minute (local, seconds zeroed). */
@@ -382,13 +325,14 @@ export class MaintenanceCompleteDialog extends LitElement {
render() {
if (!this._open) return html``;
const L = this.lang || this.hass?.language || "en";
const error = this._error || this._photos.errorText(L);
return html`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${t("complete_title", L)}${this.taskName}</div>
${this.phaseLabel ? html`<div class="phase-line">${t("phase_current", L)}: ${this.phaseLabel}</div>` : nothing}
${this.requireTagScan && !this.viaTagScan ? html`<div class="scan-required-note">${t("require_tag_scan_hint", L)}</div>` : nothing}
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${error ? html`<div class="error">${error}</div>` : nothing}
${this.checklist.length > 0 ? html`
<div class="checklist-section">
<label class="checklist-label">${t("checklist", L)}</label>
@@ -509,35 +453,21 @@ export class MaintenanceCompleteDialog extends LitElement {
</div>
<div class="field">
<span class="field-label">${t("completion_photos_optional", L)}${this._req("photo")}</span>
${this._photos.length > 0
${this._photos.photos.length > 0
? html`<div class="photo-strip">
${this._photos.map((p) => html`
${this._photos.photos.map((p) => html`
<div class="photo-preview">
<img src=${p.preview} alt="" />
<button type="button" class="photo-remove" @click=${() => this._removePhoto(p.id)}
<button type="button" class="photo-remove" @click=${() => this._photos.remove(p.id)}
title="${t("remove", L)}">✕</button>
</div>`)}
</div>`
: nothing}
${this._photos.length < MAX_COMPLETION_PHOTOS
? html`<div class="photo-pickers">
<label class="photo-pick photo-pick-camera">
<ha-icon icon="mdi:camera"></ha-icon>
<span>${this._photoUploading ? t("uploading", L) : t("doc_camera", L)}</span>
<input type="file" accept="image/*" capture="environment"
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>
<label class="photo-pick photo-pick-gallery">
<ha-icon icon="mdi:image-multiple"></ha-icon>
<span>${t(this._singlePick ? "choose_photo" : "choose_photos", L)}</span>
<input type="file" accept="image/*" ?multiple=${!this._singlePick}
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>
</div>
${this._singlePick ? html`<div class="photo-limit photo-android-hint">${t("photos_android_hint", L)}</div>` : nothing}`
: html`<div class="photo-limit">${t("photos_limit", L).replace("{max}", String(MAX_COMPLETION_PHOTOS))}</div>`}
${!this._photos.full
? html`<ms-photo-picker .lang=${L} .busy=${this._photos.uploading} .remaining=${this._photos.remaining}
@files-picked=${(e: CustomEvent<{ files: File[] }>) => this._photos.addFiles(e.detail.files)}
></ms-photo-picker>`
: html`<div class="photo-limit">${t("photos_limit", L).replace("{max}", String(this._photos.max))}</div>`}
</div>
${this.adaptiveEnabled ? html`
<div class="feedback-section">
@@ -577,7 +507,7 @@ export class MaintenanceCompleteDialog extends LitElement {
`;
}
static styles = [nativeFieldStyles, css`
static styles = [nativeFieldStyles, photoPickerStyles, css`
.req-mark {
color: var(--error-color, #f44336);
margin-left: 2px;
@@ -663,19 +593,7 @@ export class MaintenanceCompleteDialog extends LitElement {
font-size: 12px;
color: var(--warning-color, #ff9800);
}
.photo-pick {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px dashed var(--divider-color);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--secondary-text-color);
width: fit-content;
}
.photo-pick:hover { border-color: var(--primary-color); }
/* .photo-pick / .photo-pickers / .photo-android-hint come from photoPickerStyles */
/* #163: the backdate moment starts EMPTY (= now); the button seeds the
HA date+time picker with the current minute instead of the picker's
own 00:00 default, so a backdated completion never lands at midnight
@@ -696,15 +614,9 @@ export class MaintenanceCompleteDialog extends LitElement {
--mdc-icon-size: 18px;
}
.backdate-pick:hover { border-color: var(--primary-color); }
.photo-pick input[type="file"] { display: none; }
/* #161: several photos per completion — tiles wrap into a strip,
the two pickers (camera / gallery) sit underneath while there is
room left under the cap. */
.photo-pickers {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.photo-strip {
display: flex;
flex-wrap: wrap;
@@ -715,7 +627,6 @@ export class MaintenanceCompleteDialog extends LitElement {
font-size: 12px;
color: var(--secondary-text-color);
}
.photo-android-hint { margin-top: 4px; }
.photo-preview {
position: relative;
width: fit-content;
@@ -16,8 +16,13 @@ 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 { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
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";
import { isAndroidCompanion } from "../helpers/companion";
import { uploadDocument } from "../helpers/photo-upload";
import "./ms-photo-picker";
import { photoPickerStyles } from "./ms-photo-picker";
interface MaintenanceDocument {
id: string;
@@ -28,6 +33,8 @@ interface MaintenanceDocument {
mime?: string;
size?: number;
tags?: string[];
/** #164: a free-text note per document. */
description?: string;
added_at?: string;
}
@@ -52,6 +59,10 @@ export class MaintenanceDocumentsSection extends LitElement {
@state() private _editingId = "";
@state() private _editTitle = "";
@state() private _editCategory = "manual";
@state() private _editDescription = "";
@state() private _linkDescription = "";
/** #164: list order, remembered per browser. */
@state() private _sort: DocSortMode = asDocSortMode(lsGet(LS_KEYS.docSort));
@state() private _dragOver = false;
private _loadedFor: string | null = null;
@@ -134,12 +145,10 @@ export class MaintenanceDocumentsSection extends LitElement {
input.value = ""; // let the same file be re-picked
}
private _onCameraInput(e: Event): void {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length) void this._uploadFiles(files, "photo");
input.value = "";
}
/** The Android Companion app answers a multi-select with an EMPTY file
* list (helpers/companion.ts) — "Upload file" is single-select there.
* Evaluated once; the host app does not change. */
private readonly _singlePick = isAndroidCompanion();
private _onDrop(e: DragEvent): void {
e.preventDefault();
@@ -170,21 +179,18 @@ export class MaintenanceDocumentsSection extends LitElement {
let deduped = 0;
let dupInObject = 0;
try {
// Bulk upload: one refused file (too large, rejected) is noted and
// the rest still go up — unlike the photo dialogs, which stop.
for (const file of files) {
const form = new FormData();
form.append("entry_id", this.entryId);
form.append("tags", cat);
form.append("file", file, file.name);
const resp = await fetch("/api/maintenance_supporter/document/upload", {
method: "POST",
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
body: form,
});
if (!resp.ok) {
this._error = resp.status === 413 ? t("doc_too_large", this._lang) : t("doc_upload_failed", this._lang);
let doc;
try {
doc = await uploadDocument(this.hass, this.entryId, file, [cat]);
} catch (e) {
const key = e instanceof Error ? e.message : "";
if (key !== "doc_too_large" && key !== "doc_upload_failed") throw e;
this._error = t(key, this._lang);
continue;
}
const doc = (await resp.json()) as { deduped?: boolean; duplicate_in_object?: string | null };
if (doc.duplicate_in_object) dupInObject++;
else if (doc.deduped) deduped++;
}
@@ -233,6 +239,7 @@ export class MaintenanceDocumentsSection extends LitElement {
this._editingId = doc.id;
this._editTitle = doc.title || "";
this._editCategory = this._category_of(doc);
this._editDescription = doc.description || "";
this._addingLink = false;
this._error = "";
}
@@ -241,6 +248,11 @@ export class MaintenanceDocumentsSection extends LitElement {
this._editingId = "";
}
private _setSort(raw: string): void {
this._sort = asDocSortMode(raw);
lsSet(LS_KEYS.docSort, this._sort);
}
private async _saveEdit(doc: MaintenanceDocument): Promise<void> {
// Keep any free (non-category) tags; swap in the chosen category.
const freeTags = (doc.tags || []).filter(
@@ -255,6 +267,7 @@ export class MaintenanceDocumentsSection extends LitElement {
doc_id: doc.id,
title: this._editTitle.trim() || doc.filename || doc.url || "",
tags,
description: this._editDescription.trim(),
});
this._editingId = "";
await this._load();
@@ -294,9 +307,11 @@ export class MaintenanceDocumentsSection extends LitElement {
entry_id: this.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) {
@@ -341,19 +356,11 @@ export class MaintenanceDocumentsSection extends LitElement {
>
<ha-icon icon="mdi:upload"></ha-icon>
${this._busy ? t("doc_uploading", L) : t("doc_upload", L)}
<input type="file" multiple hidden ?disabled=${this._busy} @change=${this._onFileInput} />
</label>
<label
class="btn camera-btn ${this._busy ? "disabled" : ""}"
role="button"
tabindex="0"
aria-label=${t("doc_camera", L)}
title=${t("doc_camera", L)}
@keydown=${this._labelKeydown}
>
<ha-icon icon="mdi:camera"></ha-icon>
<input type="file" accept="image/*" capture="environment" hidden ?disabled=${this._busy} @change=${this._onCameraInput} />
<input type="file" ?multiple=${!this._singlePick} hidden ?disabled=${this._busy} @change=${this._onFileInput} />
</label>
<ms-photo-picker compact .showGallery=${false} .lang=${L} .disabled=${this._busy}
@files-picked=${(e: CustomEvent<{ files: File[] }>) => this._uploadFiles(e.detail.files, "photo")}
></ms-photo-picker>
<button class="btn" ?disabled=${this._busy} @click=${() => (this._addingLink = !this._addingLink)}>
<ha-icon icon="mdi:link-variant"></ha-icon> ${t("doc_add_link", L)}
</button>
@@ -382,6 +389,14 @@ export class MaintenanceDocumentsSection extends LitElement {
?disabled=${this._busy}
@input=${(e: Event) => (this._linkTitle = (e.target as HTMLInputElement).value)}
/>
<input
type="text"
class="link-desc"
placeholder=${t("doc_description", L)}
.value=${this._linkDescription}
?disabled=${this._busy}
@input=${(e: Event) => (this._linkDescription = (e.target as HTMLInputElement).value)}
/>
<button class="btn primary" ?disabled=${this._busy || !this._linkUrl.trim()} @click=${this._addLink}>
${t("add", L)}
</button>
@@ -392,6 +407,15 @@ export class MaintenanceDocumentsSection extends LitElement {
`
: nothing}
${this._loaded && this._docs.length >= 2
? html`<div class="doc-tools">
<ha-icon icon="mdi:sort"></ha-icon>
<select class="sort-select" aria-label=${t("doc_sort", L)} .value=${this._sort}
@change=${(e: Event) => this._setSort((e.target as HTMLSelectElement).value)}>
${DOC_SORT_MODES.map((m) => html`<option value=${m} ?selected=${m === this._sort}>${t(`doc_sort_${m}`, L)}</option>`)}
</select>
</div>`
: nothing}
${this._loaded && this._docs.length >= DOC_FILTER_MIN
? html`<div class="doc-filter">
<ha-icon icon="mdi:magnify"></ha-icon>
@@ -404,7 +428,8 @@ export class MaintenanceDocumentsSection extends LitElement {
: this._docs.length === 0
? html`<div class="doc-empty">${t("documents_empty", L)}</div>`
: (() => {
const shown = filterDocuments(this._docs, this._filter);
// A query ranks by match; otherwise the remembered order applies.
const shown = this._filter.trim() ? filterDocuments(this._docs, this._filter) : sortDocuments(this._docs, this._sort);
return shown.length === 0
? html`<div class="doc-empty">${t("doc_search_none", L)}</div>`
: html`<div class="doc-list">${shown.map((doc) => this._renderDoc(doc, L))}</div>`;
@@ -460,6 +485,7 @@ export class MaintenanceDocumentsSection extends LitElement {
>
<div class="doc-title">${docDisplayName(doc)}</div>
<div class="doc-meta">${meta}</div>
${doc.description ? html`<div class="doc-desc">${doc.description}</div>` : nothing}
</div>
<div class="doc-row-actions">
${isFile
@@ -514,6 +540,14 @@ export class MaintenanceDocumentsSection extends LitElement {
)}
</select>`
: nothing}
<input
class="edit-desc"
type="text"
placeholder=${t("doc_description", L)}
.value=${this._editDescription}
?disabled=${this._busy}
@input=${(e: Event) => (this._editDescription = (e.target as HTMLInputElement).value)}
/>
<button class="icon-btn" title=${t("save", L)} ?disabled=${this._busy || !this._editTitle.trim()} @click=${() => this._saveEdit(doc)}>
<ha-icon icon="mdi:check"></ha-icon>
</button>
@@ -524,7 +558,7 @@ export class MaintenanceDocumentsSection extends LitElement {
`;
}
static styles = css`
static styles = [photoPickerStyles, css`
:host { display: block; margin: 8px 0 4px; }
.doc-zone { position: relative; }
.doc-zone.drag-over {
@@ -538,7 +572,15 @@ export class MaintenanceDocumentsSection extends LitElement {
background: var(--card-background-color, rgba(255, 255, 255, 0.85));
}
.drop-overlay ha-icon { --mdc-icon-size: 24px; }
.camera-btn { padding: 6px 10px; }
/* The camera picker (ms-photo-picker, light DOM) wears the toolbar's
.btn look instead of the dialogs' dashed tile. */
.doc-actions .photo-pick {
padding: 6px 10px; gap: 6px; border-style: solid; border-radius: 6px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color); border-color: var(--divider-color);
}
.doc-actions .photo-pick ha-icon { --mdc-icon-size: 18px; }
.doc-actions .photo-pick.disabled { opacity: 0.5; pointer-events: none; }
.doc-header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
@@ -620,6 +662,16 @@ export class MaintenanceDocumentsSection extends LitElement {
.doc-info:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.doc-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-meta { font-size: 12px; color: var(--secondary-text-color, #888); }
.doc-desc { font-size: 12.5px; color: var(--secondary-text-color, #888); margin-top: 2px; white-space: pre-wrap; overflow-wrap: anywhere; }
.doc-tools { display: flex; align-items: center; gap: 6px; margin: 6px 0 2px; color: var(--secondary-text-color, #888); }
.doc-tools ha-icon { --mdc-icon-size: 18px; }
.sort-select {
padding: 4px 6px; border-radius: 6px; font: inherit; font-size: 13px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.doc-row.editing { flex-wrap: wrap; }
.edit-desc, .link-desc { flex: 1 1 100%; min-width: 0; }
.doc-row-actions { display: flex; gap: 4px; flex: none; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
@@ -631,7 +683,7 @@ export class MaintenanceDocumentsSection extends LitElement {
.icon-btn.danger { color: var(--error-color, #f44336); }
.icon-btn[disabled] { opacity: 0.4; pointer-events: none; }
.icon-btn ha-icon { --mdc-icon-size: 20px; }
`;
`];
}
if (!customElements.get("maintenance-documents-section")) {
@@ -125,8 +125,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
private async _deleteGroup(id: string, name: string): Promise<void> {
if (!this._isAdmin) return;
const confirmText = (t("group_delete_confirm", this._lang)
|| "Delete group \"{name}\"?").replace("{name}", name);
const confirmText = t("group_delete_confirm", this._lang).replace("{name}", name);
if (!window.confirm(confirmText)) return;
this._busy = true;
try {
@@ -162,7 +161,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
render() {
const L = this._lang;
if (!this._loaded) {
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
return html`<ha-card><div class="loading">${t("loading", L)}</div></ha-card>`;
}
const ids = Object.keys(this._groups);
@@ -172,7 +171,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
<div class="header">
<div class="title">
<span class="emoji">🏷️</span>
<span>${this._config.title || (t("groups", L) || "Groups")}</span>
<span>${this._config.title || t("groups", L)}</span>
<span class="count">${ids.length}</span>
</div>
</div>
@@ -180,7 +179,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${ids.length === 0
? html`<div class="empty">${t("groups_empty", L) || "No groups yet."}</div>`
? html`<div class="empty">${t("groups_empty", L)}</div>`
: html`
<div class="group-list">
${ids.map((id) => {
@@ -201,11 +200,11 @@ export class MaintenanceGroupsSectionCard extends LitElement {
<button class="btn small primary"
@click=${this._saveEdit}
?disabled=${this._busy || !this._editingName.trim()}>
${t("save", L) || "Save"}
${t("save", L)}
</button>
<button class="btn small"
@click=${() => { this._editingId = null; }}>
${t("cancel", L) || "Cancel"}
${t("cancel", L)}
</button>
`
: html`
@@ -214,13 +213,13 @@ export class MaintenanceGroupsSectionCard extends LitElement {
${this._isAdmin
? html`
<button class="icon-btn"
title="${t("edit", L) || "Edit"}"
title="${t("edit", L)}"
@click=${() => this._startEdit(id)}
?disabled=${this._busy}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
<button class="icon-btn danger"
title="${t("delete", L) || "Delete"}"
title="${t("delete", L)}"
@click=${() => this._deleteGroup(id, g.name || "Unnamed")}
?disabled=${this._busy}>
<ha-icon icon="mdi:delete"></ha-icon>
@@ -238,7 +237,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
? html`
<div class="add-row">
<input type="text"
placeholder="${t("group_new_placeholder", L) || "Add group…"}"
placeholder="${t("group_new_placeholder", L)}"
.value=${this._newName}
?disabled=${this._busy}
@input=${(e: Event) => {
@@ -249,16 +248,16 @@ export class MaintenanceGroupsSectionCard extends LitElement {
@click=${this._addGroup}
?disabled=${this._busy || !this._newName.trim()}>
<ha-icon icon="mdi:plus"></ha-icon>
${t("add", L) || "Add"}
${t("add", L)}
</button>
</div>
<button class="btn link" @click=${this._onDeepLink}>
${t("groups_manage_tasks", L) || "Manage task assignments…"}
${t("groups_manage_tasks", L)}
</button>
`
: html`
<button class="btn link" @click=${this._onDeepLink}>
${t("groups_open_panel", L) || "Open in panel"}
${t("groups_open_panel", L)}
</button>
`}
</div>
@@ -12,13 +12,11 @@ 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 {
MAX_COMPLETION_PHOTOS,
discardUploadedPhotos,
uploadCompletionPhoto,
} from "../helpers/photo-upload";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import "./ms-date-field";
import "./history-photo";
import "./ms-photo-picker";
import { photoPickerStyles } from "./ms-photo-picker";
export interface HistoryEntryDraft {
entry_id: string;
@@ -79,11 +77,14 @@ export class MaintenanceHistoryEditDialog extends LitElement {
@state() private _partQty: Record<string, number> = {};
private _partQtyOriginal = "";
// #161: the edited photo list; the original as a JSON key for the
// "did it change" check, plus the docs THIS session uploaded so an
// abandoned edit can drop them again (a removed pre-existing photo is
// only detached — the file stays in the object's documents).
@state() private _photos: string[] = [];
// #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
// pre-existing photo is only detached — the file stays in the object's
// documents); the original as a JSON key for the "did it change" check.
private readonly _photos = new PhotoUploadController(this, {
entryId: () => this._draft?.entry_id ?? "",
hass: () => this.hass,
});
private _photosOriginal = "";
// #161 phase 2: the reading rows (entry snapshot ∪ task slots) with the
@@ -91,8 +92,6 @@ export class MaintenanceHistoryEditDialog extends LitElement {
@state() private _readingRows: ReadingSlot[] = [];
@state() private _readingText: Record<string, string> = {};
private _readingsOriginal = "";
private _uploadedIds: string[] = [];
@state() private _photoUploading = false;
/** Open the dialog with the given history-entry data. The caller must
* pass `original_timestamp` (the entry's current timestamp before edit)
@@ -105,10 +104,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
this._partOptions = null;
this._partQty = {};
this._partQtyOriginal = "";
this._photos = [...(draft.photo_doc_ids ?? [])];
this._photosOriginal = JSON.stringify(this._photos);
this._uploadedIds = [];
this._photoUploading = false;
this._photos.reset(draft.photo_doc_ids ?? []);
this._photosOriginal = JSON.stringify(this._photos.ids);
this._seedReadings(draft);
void this._loadPartOptions();
}
@@ -147,41 +144,6 @@ export class MaintenanceHistoryEditDialog extends LitElement {
return out;
}
private async _onPhotoInput(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = "";
const draft = this._draft;
if (files.length === 0 || !draft) return;
const room = MAX_COMPLETION_PHOTOS - this._photos.length;
const accepted = files.slice(0, Math.max(room, 0));
this._photoUploading = true;
this._error = "";
try {
for (const file of accepted) {
const id = await uploadCompletionPhoto(this.hass, draft.entry_id, file);
this._uploadedIds = [...this._uploadedIds, id];
this._photos = [...this._photos, id];
}
if (files.length > accepted.length) {
this._error = t("photos_limit", this._lang).replace("{max}", String(MAX_COMPLETION_PHOTOS));
}
} catch (err) {
const key = err instanceof Error && err.message === "doc_too_large" ? "doc_too_large" : "doc_upload_failed";
this._error = t(key, this._lang);
} finally {
this._photoUploading = false;
}
}
private _removePhoto(id: string): void {
this._photos = this._photos.filter((x) => x !== id);
if (this._uploadedIds.includes(id)) {
this._uploadedIds = this._uploadedIds.filter((x) => x !== id);
void discardUploadedPhotos(this.hass, [id]);
}
}
/** 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> {
@@ -248,12 +210,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
this._error = "";
this._draft = null;
this._originalSnapshot = null;
if (this._uploadedIds.length > 0) {
// Cancelled after uploading: nothing references those files.
const orphans = this._uploadedIds;
this._uploadedIds = [];
void discardUploadedPhotos(this.hass, orphans);
}
// Cancelled after uploading: nothing references those files.
this._photos.discardOrphans();
}
private _set<K extends keyof HistoryEntryDraft>(
@@ -263,10 +221,42 @@ export class MaintenanceHistoryEditDialog extends LitElement {
this._draft = { ...this._draft, [key]: value };
}
/** #170: remove the entry altogether (after a confirm). The backend
* re-anchors last_performed on what remains; photos stay in the object's
* documents, consumed parts are not restocked. */
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;
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;
}
}
private async _save(): Promise<void> {
if (!this._draft || !this._originalSnapshot) return;
this._saving = true;
this._error = "";
this._photos.clearError();
try {
const patch: Record<string, unknown> = {
type: "maintenance_supporter/task/history/update",
@@ -315,8 +305,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
patch.reading_values = map;
}
// #161: the photo list, only when it differs from what we opened with.
if (JSON.stringify(this._photos) !== this._photosOriginal) {
patch.photo_doc_ids = [...this._photos];
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(
@@ -327,7 +317,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
return;
}
await this.hass.connection.sendMessagePromise(patch);
this._uploadedIds = []; // saved — they belong to the entry now
this._photos.markAttached(); // saved — they belong to the entry now
// Notify upstream so they can refresh
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
@@ -352,10 +342,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
if (!this._open || !this._draft) return nothing;
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">
<h2>${t("history_edit_title", L) || "Edit history entry"}</h2>
<h2>${t("history_edit_title", L)}</h2>
<div class="entry-type">
<ha-icon icon="mdi:tag-outline"></ha-icon>
<span>${t(d.type, L) || d.type}</span>
@@ -365,7 +356,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
required
.hass=${this.hass}
.lang=${L}
.label=${t("history_edit_timestamp", L) || "Timestamp"}
.label=${t("history_edit_timestamp", L)}
.value=${d.timestamp.slice(0, 19)}
@value-changed=${(e: CustomEvent) => {
// Always "YYYY-MM-DDTHH:MM:SS" from the field; the naive value
@@ -386,7 +377,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
</label>
<div class="row">
<label>
<span>${t("cost", L) || "Cost"}</span>
<span>${t("cost", L)}</span>
<input type="number" min="0" step="0.01"
.value=${d.cost != null ? String(d.cost) : ""}
@input=${(e: Event) => {
@@ -395,7 +386,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
}} />
</label>
<label>
<span>${t("duration", L) || "Duration (min)"}</span>
<span>${t("duration", L)}</span>
<input type="number" min="0"
.value=${d.duration != null ? String(d.duration) : ""}
@input=${(e: Event) => {
@@ -434,32 +425,32 @@ export class MaintenanceHistoryEditDialog extends LitElement {
` : nothing}
<div class="photos-block">
<span class="parts-title">${t("completion_photos", L)}</span>
${this._photos.length > 0 ? html`
${this._photos.photos.length > 0 ? html`
<div class="photo-strip">
${this._photos.map((id) => html`
${this._photos.photos.map((p) => html`
<div class="photo-tile">
<maintenance-history-photo .hass=${this.hass} .docId=${id}></maintenance-history-photo>
<maintenance-history-photo .hass=${this.hass} .docId=${p.id}></maintenance-history-photo>
<button type="button" class="photo-remove" title=${t("remove", L)}
@click=${() => this._removePhoto(id)}>✕</button>
@click=${() => this._photos.remove(p.id)}>✕</button>
</div>`)}
</div>` : nothing}
${this._photos.length < MAX_COMPLETION_PHOTOS ? html`
<label class="photo-add">
<ha-icon icon="mdi:image-plus"></ha-icon>
<span>${this._photoUploading ? t("uploading", L) : t("add_photos", L)}</span>
<input type="file" accept="image/*" multiple
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>` : html`<span class="photos-hint">${t("photos_limit", L).replace("{max}", String(MAX_COMPLETION_PHOTOS))}</span>`}
${!this._photos.full
? html`<ms-photo-picker .lang=${L} .busy=${this._photos.uploading} .remaining=${this._photos.remaining}
@files-picked=${(e: CustomEvent<{ files: File[] }>) => this._photos.addFiles(e.detail.files)}
></ms-photo-picker>`
: html`<span class="photos-hint">${t("photos_limit", L).replace("{max}", String(this._photos.max))}</span>`}
<span class="photos-hint">${t("history_edit_photos_hint", L)}</span>
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${error ? html`<div class="error">${error}</div>` : nothing}
<div class="actions">
<button class="delete-entry" @click=${this._delete} ?disabled=${this._saving} title=${t("history_delete_entry", L)}>
<ha-icon icon="mdi:delete-outline"></ha-icon> ${t("history_delete_entry", L)}
</button>
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
${t("cancel", L) || "Cancel"}
${t("cancel", L)}
</button>
<button class="save" @click=${this._save} ?disabled=${this._saving}>
${this._saving ? (t("saving", L) || "Saving…") : (t("save", L) || "Save")}
${this._saving ? t("saving", L) : t("save", L)}
</button>
</div>
</div>
@@ -499,7 +490,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
</label>`;
}
static styles = css`
static styles = [photoPickerStyles, css`
:host { display: contents; }
.backdrop {
position: fixed; inset: 0;
@@ -536,6 +527,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
width: 100%; box-sizing: border-box;
font-family: inherit;
}
.delete-entry { margin-right: auto; color: var(--error-color, #d32f2f); background: transparent; border: 1px solid var(--error-color, #d32f2f); border-radius: 6px; padding: 6px 10px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
.delete-entry ha-icon { --mdc-icon-size: 18px; }
.actions {
display: flex; gap: 8px; justify-content: flex-end;
margin-top: 8px;
@@ -601,17 +594,9 @@ export class MaintenanceHistoryEditDialog extends LitElement {
background: var(--error-color, #db4437); color: #fff;
cursor: pointer; font-size: 11px; line-height: 1; padding: 0;
}
.photo-add {
display: inline-flex; flex-direction: row; align-items: center; gap: 8px;
width: fit-content; padding: 6px 10px;
border: 1px dashed var(--divider-color, #444); border-radius: 8px;
cursor: pointer; font-size: 13px; color: var(--secondary-text-color);
--mdc-icon-size: 18px;
}
.photo-add:hover { border-color: var(--primary-color); }
.photo-add input[type="file"] { display: none; }
/* the camera / gallery pickers come from photoPickerStyles (ms-photo-picker) */
.photos-hint { font-size: 12px; color: var(--secondary-text-color); }
`;
`];
}
if (!customElements.get("maintenance-history-edit-dialog")) {
@@ -107,8 +107,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
if (!this._entryId || !this._data) return;
const archived = !!this._data.object.archived;
if (!archived) {
const confirmText = t("confirm_archive_object", this._lang)
|| "Archive this object and its tasks?";
const confirmText = t("confirm_archive_object", this._lang);
if (!window.confirm(confirmText)) return;
}
this._busy = true;
@@ -165,11 +164,11 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
<div class="tasks-section">
<div class="section-header">
<strong>${t("tasks", L) || "Tasks"}</strong>
<strong>${t("tasks", L)}</strong>
<span class="count">${tasks.length}</span>
</div>
${tasks.length === 0
? html`<div class="empty">${t("no_tasks", L) || "No tasks yet."}</div>`
? html`<div class="empty">${t("no_tasks", L)}</div>`
: html`
<div class="task-list">
${tasks.map((task) => html`
@@ -197,25 +196,25 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
<div class="actions">
<button class="btn primary" @click=${this._onAddTask} ?disabled=${this._busy}>
<ha-icon icon="mdi:plus"></ha-icon>
${t("add_task", L) || "Add task"}
${t("add_task", L)}
</button>
<button class="btn" @click=${this._onEditObject} ?disabled=${this._busy}>
<ha-icon icon="mdi:pencil"></ha-icon>
${t("edit", L) || "Edit"}
${t("edit", L)}
</button>
<button class="btn" @click=${this._onArchiveObject} ?disabled=${this._busy}>
<ha-icon icon="${obj.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${obj.archived ? (t("unarchive_object", L) || "Unarchive object") : (t("archive_object", L) || "Archive object")}
${obj.archived ? t("unarchive_object", L) : t("archive_object", L)}
</button>
<button class="btn danger" @click=${this._onDelete} ?disabled=${this._busy}>
<ha-icon icon="mdi:delete"></ha-icon>
${t("delete", L) || "Delete"}
${t("delete", L)}
</button>
</div>
`
: nothing}
`
: html`<div class="loading">${t("loading", L) || "Loading…"}</div>`}
: html`<div class="loading">${t("loading", L)}</div>`}
</div>
`;
}
@@ -509,4 +509,6 @@ export class MaintenancePartsSection extends LitElement {
`;
}
customElements.define("maintenance-parts-section", MaintenancePartsSection);
if (!customElements.get("maintenance-parts-section")) {
customElements.define("maintenance-parts-section", MaintenancePartsSection);
}
@@ -5,7 +5,7 @@ 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 } from "../styles";
import { t, langOf, personStyles, syncCurrencyDecimals} from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
@@ -13,6 +13,9 @@ 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 { isoDateLocal } from "../helpers/calendar-bucket";
import { runWs } from "../helpers/ws-run";
import "./ms-date-field";
/** One household member and the notify services they actually resolve to.
@@ -51,6 +54,18 @@ interface SettingsResponse {
/** #146: household "low" floors (percent) for discovery + battery fleet. */
default_consumable_threshold?: number;
battery_low_percent?: number;
/** D#162 follow-up: per-type lifetime overrides + the computed catalog. */
battery_lifetime_months?: Record<string, number>;
battery_lifetimes?: {
type: string;
months: number;
source: "override" | "table" | "default";
default_months: number;
override_months: number | null;
/** What the fleet learned for this type, per device model (never applied type-wide). */
learned_models: { model: string; model_key: string; months: number; samples: number }[];
in_fleet: boolean;
}[];
/** Computed: what Battery Notes reports (null when not installed). */
battery_notes?: {
default: number;
@@ -60,6 +75,8 @@ interface SettingsResponse {
} | null;
/** #145: how task rows show Complete / Skip. */
row_action_style?: "buttons_compact" | "buttons" | "icons";
/** #170: reference numbers in front of names in every list. */
ref_numbers_in_lists?: boolean;
row_action_notice_pending?: boolean;
};
notifications: {
@@ -79,6 +96,8 @@ interface SettingsResponse {
scope_view_id: string;
/** #165: your own notification rule — event-only delivery + extra-data template. */
event_only?: boolean;
/** #173 follow-up: completion notifications — off | automatic | all. */
completed?: string;
extra_data?: string;
};
actions: {
@@ -97,6 +116,8 @@ interface SettingsResponse {
alert_threshold_pct: number;
currency: string;
currency_symbol: string;
/** Decimal places for every displayed amount (0 = whole numbers). */
currency_decimals?: number;
};
// v2.10.0 archive automation (panel-managed). Optional for forward-compat
// with a backend that predates the feature.
@@ -135,6 +156,11 @@ const CURRENCIES = [
"CZK", "PLN", "RUB", "SEK", "NOK", "DKK", "UAH",
];
/** The currency_decimals <select> options — derived from the registry range
* (0..3), not a literal list. */
const CURRENCY_DECIMAL_OPTIONS: readonly number[] = (([lo, hi]) =>
Array.from({ length: hi - lo + 1 }, (_, i) => lo + i))(SETTING_INT_RANGES.currency_decimals);
export class MaintenanceSettingsView extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public features!: AdvancedFeatures;
@@ -163,6 +189,9 @@ export class MaintenanceSettingsView extends LitElement {
@state() private _vacAllTasks: Array<{ entry_id: string; object_name: string; task_id: string; task_name: string }> = [];
@state() private _vacPreview: VacationPreviewRow[] = [];
@state() private _vacPreviewLoading = false;
/** A preview row's Complete/Skip is in flight — the buttons disable so a
* double-tap cannot send the completion twice (DRY audit 2026-09-12). */
@state() private _previewBusy = false;
@state() private _vacSaving = false;
// Print QR codes section state
@@ -236,6 +265,7 @@ export class MaintenanceSettingsView extends LitElement {
type: "maintenance_supporter/settings",
});
this._settings = result as SettingsResponse;
syncCurrencyDecimals(this._settings.budget);
this._hydrateVacationFromSettings();
} catch {
/* ignore */
@@ -267,6 +297,64 @@ export class MaintenanceSettingsView extends LitElement {
/** #146 follow-up: what Battery Notes currently reports, with the rule
* spelled out — warn-toned when its default sits above our floor (then a
* lowered floor would silently change nothing for noted batteries). */
/** D#162 follow-up: the typical-lifetime table — fleet types first, each
* with its effective months and where they come from; editing a value
* writes the household override, "Use default" removes it. Only batteries
* WITHOUT a percentage use these (the hint says so). */
private _renderBatteryLifetimes(L: string) {
const rows = this._settings?.general?.battery_lifetimes;
if (!rows || !rows.length) return nothing;
const overrides = this._settings?.general?.battery_lifetime_months ?? {};
const sourceLabel = (r: NonNullable<typeof rows>[number]) => t("lifetime_source_" + r.source, L);
const unit = t("settings_battery_lifetime_months", L);
const learnedLine = (r: NonNullable<typeof rows>[number]) =>
r.learned_models.length
? html`<div class="bl-learned">${t("settings_battery_lifetime_learned_models", L).replace(
"{list}",
r.learned_models.map((m) => `${m.model} ${m.months} ${unit} (${m.samples})`).join(" · "),
)}</div>`
: nothing;
// No live() on the month inputs (bug audit 2026-09-12, #176 class): the
// view re-renders on every hass assignment and live() reset a half-typed
// value to the stored months. An out-of-range or rejected entry snaps
// back by writing the input directly instead.
const commit = (type: string, input: HTMLInputElement, stored: number) => {
const months = Number.parseInt(input.value, 10);
if (!Number.isFinite(months) || months < 1 || months > 240) { input.value = String(stored); return; }
void this._updateSetting("battery_lifetime_months", { ...overrides, [type]: months })
.then((ok) => { if (!ok) input.value = String(stored); });
};
const reset = (type: string) => {
const next = { ...overrides };
delete next[type];
this._updateSetting("battery_lifetime_months", next);
};
const fleetRows = rows.filter((r) => r.in_fleet);
const otherRows = rows.filter((r) => !r.in_fleet);
const row = (r: NonNullable<typeof rows>[number]) => html`
<div class="bl-row ${r.source === "override" ? "bl-override" : ""}">
<span class="bl-type">${r.type}${r.in_fleet ? html` <span class="bl-fleet">${t("settings_battery_lifetime_in_fleet", L)}</span>` : nothing}</span>
<input class="bl-months" type="number" min="1" max="240" .value=${String(r.months)}
@change=${(e: Event) => commit(r.type, e.target as HTMLInputElement, r.months)} />
<span class="bl-unit">${t("settings_battery_lifetime_months", L)}</span>
<span class="bl-source">${sourceLabel(r)}</span>
${r.source === "override"
? html`<button type="button" class="bl-reset" @click=${() => reset(r.type)}>${t("settings_battery_lifetime_reset", L)}</button>`
: nothing}
${learnedLine(r)}
</div>`;
return html`
<h4 class="bl-title">${t("settings_battery_lifetimes", L)}</h4>
<div class="setting-hint">${t("settings_battery_lifetimes_hint", L)}</div>
<div class="bl-table">
${fleetRows.map(row)}
${otherRows.length
? html`<details class="bl-more"><summary>${otherRows.length} ×</summary>${otherRows.map(row)}</details>`
: nothing}
</div>
`;
}
private _renderBatteryNotesHint(L: string) {
const bn = this._settings?.general?.battery_notes;
if (!bn || !bn.devices) return nothing;
@@ -358,25 +446,33 @@ export class MaintenanceSettingsView extends LitElement {
}
}
private async _updateSetting(key: string, value: unknown): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/global/update",
settings: { [key]: value },
});
this._settings = result as SettingsResponse;
/** Resolves true when the server accepted the value, false on a reject
* (the caller decides how to snap its control back). */
private async _updateSetting(key: string, value: unknown): Promise<boolean> {
const result = await this._ws<SettingsResponse>({
type: "maintenance_supporter/global/update",
settings: { [key]: value },
});
if (result) {
this._settings = result;
syncCurrencyDecimals(this._settings.budget);
// The card / Lovelace dialogs read their copy through a page-wide
// cache — drop it so they follow the change without a reload.
invalidateSettingsCache();
this._showToast(t("settings_saved", this._lang));
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch {
this._showToast(t("action_error", this._lang));
return true;
} else {
// The control already shows the rejected value (the browser changed it
// before we asked the server). `_settings` is unchanged, so a plain
// re-render would not touch the DOM — the selects bind their value
// through `live()` precisely so this re-render snaps them back.
// through `live()` precisely so this re-render snaps them back. Typed
// inputs do NOT use live() (bug audit 2026-09-12: the view re-renders
// on every hass assignment, and live() reset a half-typed number to
// the stored value mid-typing — the #176 class); their callers snap
// `input.value` back themselves when this resolves false.
this.requestUpdate();
return false;
}
}
@@ -404,15 +500,29 @@ export class MaintenanceSettingsView extends LitElement {
setTimeout(() => { this._toast = ""; }, 3000);
}
/** One WS round-trip; a rejection becomes a toast carrying the server's
* localized reason ("Limit reached", "Invalid date", …) and resolves
* undefined. The silent best-effort loaders above deliberately do NOT
* use this — they want a fallback value, not a toast. */
private _ws<T = unknown>(
payload: Record<string, unknown>,
fallbackKey?: string,
busy?: (busy: boolean) => void,
): Promise<T | undefined> {
return runWs<T>(this, payload, { fallbackKey, busy, onError: (msg) => this._showToast(msg) });
}
/** Bounded integer settings: an out-of-range entry used to be dropped
* silently while the field kept showing it (bug review 2026-09-04). Now
* it is named in a toast and the field snaps back to the stored value;
* a server reject snaps back through `live()` like the selects. */
* a server reject snaps back the same way — by writing `input.value`
* directly, since these inputs no longer bind through `live()` (bug
* audit 2026-09-12). */
private _onBoundedIntChange(e: Event, key: string, min: number, max: number, current: number): void {
const input = e.target as HTMLInputElement;
const v = parseInt(input.value, 10);
if (Number.isInteger(v) && v >= min && v <= max) {
void this._updateSetting(key, v);
void this._updateSetting(key, v).then((ok) => { if (!ok) input.value = String(current); });
return;
}
this._showToast(
@@ -421,6 +531,17 @@ export class MaintenanceSettingsView extends LitElement {
input.value = String(current);
}
/** A bounded-integer setting input. min/max come from the registry mirror
* (helpers/setting-ranges.ts — the backend's exact bounds, which the WS
* sanitiser enforces by silently DROPPING an out-of-range value), and an
* out-of-range or cleared entry snaps back with a toast instead of
* sending a default. */
private _intSetting(key: string, current: number) {
const [lo, hi] = settingIntRange(key);
return html`<input type="number" min=${lo} max=${hi} .value=${String(current)}
@change=${(e: Event) => this._onBoundedIntChange(e, key, lo, hi, current)} />`;
}
private _downloadFile(content: string, filename: string, mime: string): void {
// Companion-app safe download (target=_blank + DOM + deferred revoke).
downloadTextFile(content, filename, mime);
@@ -725,24 +846,22 @@ export class MaintenanceSettingsView extends LitElement {
<h3>${t("settings_general", L)}</h3>
<label class="setting-row">
<span class="setting-label">${t("settings_default_warning", L)}</span>
<input type="number" min="0" max="365" .value=${live(String(g.default_warning_days))}
@change=${(e: Event) =>
// 0 = no warning window (due soon only on the due date) — #145.
this._onBoundedIntChange(e, "default_warning_days", 0, 365, g.default_warning_days)} />
<!-- No live() on the typed number inputs below (bug audit
2026-09-12, #176 class): a hass re-render must not reset a
half-typed value; rejects snap back via _onBoundedIntChange. -->
<!-- 0 = no warning window (due soon only on the due date) — #145. -->
${this._intSetting("default_warning_days", g.default_warning_days)}
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_consumable_threshold", L)}</span>
<input type="number" min="1" max="90" .value=${live(String(g.default_consumable_threshold ?? 10))}
@change=${(e: Event) =>
this._onBoundedIntChange(e, "default_consumable_threshold", 1, 90, g.default_consumable_threshold ?? 10)} />
${this._intSetting("default_consumable_threshold", g.default_consumable_threshold ?? 10)}
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_battery_low_percent", L)}</span>
<input type="number" min="1" max="90" .value=${live(String(g.battery_low_percent ?? 20))}
@change=${(e: Event) =>
this._onBoundedIntChange(e, "battery_low_percent", 1, 90, g.battery_low_percent ?? 20)} />
${this._intSetting("battery_low_percent", g.battery_low_percent ?? 20)}
</label>
${this._renderBatteryNotesHint(L)}
${this._renderBatteryLifetimes(L)}
<div class="setting-hint">${t("settings_thresholds_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_row_actions", L)}</span>
@@ -752,12 +871,26 @@ export class MaintenanceSettingsView extends LitElement {
<option value=${v} ?selected=${(g.row_action_style || "buttons_compact") === v}>${t(`row_actions_${v}`, L)}</option>`)}
</select>
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_ref_numbers_in_lists", L)}</span>
<input type="checkbox" class="refs-in-lists" .checked=${g.ref_numbers_in_lists === true}
@change=${(e: Event) => this._updateSetting("ref_numbers_in_lists", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_ref_numbers_in_lists_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_currency", L)}</span>
<select .value=${live(b.currency)} @change=${(e: Event) => this._updateSetting("budget_currency", (e.target as HTMLSelectElement).value)}>
${CURRENCIES.map((c) => html`<option value=${c} ?selected=${b.currency === c}>${c}</option>`)}
</select>
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_currency_decimals", L)}</span>
<select class="currency-decimals" .value=${live(String(b.currency_decimals ?? 0))}
@change=${(e: Event) => this._updateSetting("currency_decimals", Number((e.target as HTMLSelectElement).value))}>
${CURRENCY_DECIMAL_OPTIONS.map((d) => html`<option value=${String(d)} ?selected=${(b.currency_decimals ?? 0) === d}>${d}</option>`)}
</select>
</label>
<div class="setting-hint">${t("settings_currency_decimals_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_panel_enabled", L)}</span>
<input type="checkbox" .checked=${g.panel_enabled}
@@ -861,8 +994,7 @@ export class MaintenanceSettingsView extends LitElement {
${n.due_soon_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
<input type="number" min="0" max="720" .value=${String(n.due_soon_interval_hours)}
@change=${(e: Event) => this._updateSetting("notify_due_soon_interval_hours", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
${this._intSetting("notify_due_soon_interval_hours", n.due_soon_interval_hours)}
</label>
` : nothing}
@@ -876,8 +1008,7 @@ export class MaintenanceSettingsView extends LitElement {
${n.overdue_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
<input type="number" min="0" max="720" .value=${String(n.overdue_interval_hours)}
@change=${(e: Event) => this._updateSetting("notify_overdue_interval_hours", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
${this._intSetting("notify_overdue_interval_hours", n.overdue_interval_hours)}
</label>
` : nothing}
@@ -891,8 +1022,7 @@ export class MaintenanceSettingsView extends LitElement {
${n.triggered_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
<input type="number" min="0" max="720" .value=${String(n.triggered_interval_hours)}
@change=${(e: Event) => this._updateSetting("notify_triggered_interval_hours", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
${this._intSetting("notify_triggered_interval_hours", n.triggered_interval_hours)}
</label>
` : nothing}
@@ -928,8 +1058,7 @@ export class MaintenanceSettingsView extends LitElement {
<label class="setting-row">
<span class="setting-label">${t("settings_max_per_day", L)}</span>
<input type="number" min="0" max="100" .value=${String(n.max_per_day)}
@change=${(e: Event) => this._updateSetting("max_notifications_per_day", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
${this._intSetting("max_notifications_per_day", n.max_per_day)}
</label>
<label class="setting-row">
@@ -940,8 +1069,7 @@ export class MaintenanceSettingsView extends LitElement {
${n.bundling_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_bundle_threshold", L)}</span>
<input type="number" min="2" max="20" .value=${String(n.bundle_threshold)}
@change=${(e: Event) => this._updateSetting("notification_bundle_threshold", parseInt((e.target as HTMLInputElement).value, 10) || 2)} />
${this._intSetting("notification_bundle_threshold", n.bundle_threshold)}
</label>
` : nothing}
<label class="setting-row">
@@ -970,6 +1098,16 @@ export class MaintenanceSettingsView extends LitElement {
</select>
</label>
<div class="setting-hint">${t("settings_notify_scope_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notify_completed", L)}</span>
<select class="notify-completed" .value=${live(n.completed || "off")}
@change=${(e: Event) => this._updateSetting("notify_completed", (e.target as HTMLSelectElement).value)}>
<option value="off">${t("notify_completed_off", L)}</option>
<option value="automatic">${t("notify_completed_automatic", L)}</option>
<option value="all">${t("notify_completed_all", L)}</option>
</select>
</label>
<div class="setting-hint">${t("settings_notify_completed_hint", L)}</div>
<div class="notify-rule">
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_notify_rule", L)}</h4>
@@ -1011,8 +1149,7 @@ export class MaintenanceSettingsView extends LitElement {
${a.snooze_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_snooze_hours", L)}</span>
<input type="number" min="1" max="168" .value=${String(a.snooze_duration_hours)}
@change=${(e: Event) => this._updateSetting("snooze_duration_hours", parseInt((e.target as HTMLInputElement).value, 10) || 4)} />
${this._intSetting("snooze_duration_hours", a.snooze_duration_hours)}
</label>
` : nothing}
<label class="setting-row">
@@ -1029,8 +1166,7 @@ export class MaintenanceSettingsView extends LitElement {
${a.warranty_reminder_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_warranty_reminder_days", L)}</span>
<input type="number" min="1" max="365" .value=${String(a.warranty_reminder_days)}
@change=${(e: Event) => this._updateSetting("warranty_reminder_days", parseInt((e.target as HTMLInputElement).value, 10) || 30)} />
${this._intSetting("warranty_reminder_days", a.warranty_reminder_days)}
</label>
` : nothing}
<div class="setting-hint">${t("settings_warranty_reminder_hint", L)}</div>
@@ -1063,8 +1199,7 @@ export class MaintenanceSettingsView extends LitElement {
${b.alerts_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_budget_threshold", L)}</span>
<input type="number" min="1" max="100" .value=${String(b.alert_threshold_pct)}
@change=${(e: Event) => this._updateSetting("budget_alert_threshold", parseInt((e.target as HTMLInputElement).value, 10) || 80)} />
${this._intSetting("budget_alert_threshold", b.alert_threshold_pct)}
</label>
` : nothing}
</div>
@@ -1081,13 +1216,11 @@ export class MaintenanceSettingsView extends LitElement {
<p class="section-desc">${t("settings_archive_desc", L)}</p>
<label class="setting-row">
<span class="setting-label">${t("settings_archive_oneoff_days", L)}</span>
<input type="number" min="0" max="3650" step="1" .value=${String(a.oneoff_days)}
@change=${(e: Event) => this._updateSetting("archive_oneoff_days", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
${this._intSetting("archive_oneoff_days", a.oneoff_days)}
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_delete_archived_oneoff_days", L)}</span>
<input type="number" min="0" max="3650" step="1" .value=${String(a.delete_archived_oneoff_days)}
@change=${(e: Event) => this._updateSetting("delete_archived_oneoff_days", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
${this._intSetting("delete_archived_oneoff_days", a.delete_archived_oneoff_days)}
</label>
</div>
`;
@@ -1235,9 +1368,9 @@ export class MaintenanceSettingsView extends LitElement {
<div class="vac-preview-events">${eventLabel}</div>
</div>
<div class="vac-preview-actions">
<button @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
<button .disabled=${this._previewBusy} @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
${row.kind === "time_based" && row.allow_skip !== false
? html`<button @click=${() => this._previewActionSkip(row)}>${t("qr_action_skip", L)}</button>`
? html`<button .disabled=${this._previewBusy} @click=${() => this._previewActionSkip(row)}>${t("qr_action_skip", L)}</button>`
: nothing}
<button class=${isExempt ? "vac-notify-on" : ""}
@click=${() => this._toggleVacationExempt(row.task_id, !isExempt)}>
@@ -1254,39 +1387,35 @@ export class MaintenanceSettingsView extends LitElement {
// --- Vacation actions ---
private async _loadAllTasksForVacation(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/objects",
}) as { objects: Array<{
entry_id: string;
object: { name: string };
tasks: Array<{ id: string; name: string }>;
}> };
const flat: typeof this._vacAllTasks = [];
for (const obj of result.objects || []) {
for (const t of obj.tasks || []) {
flat.push({
entry_id: obj.entry_id,
object_name: obj.object.name || "",
task_id: t.id,
task_name: t.name || "",
});
}
const result = await this._ws<{ objects: Array<{
entry_id: string;
object: { name: string };
tasks: Array<{ id: string; name: string }>;
}> }>({ type: "maintenance_supporter/objects" });
if (!result) return;
const flat: typeof this._vacAllTasks = [];
for (const obj of result.objects || []) {
for (const t of obj.tasks || []) {
flat.push({
entry_id: obj.entry_id,
object_name: obj.object.name || "",
task_id: t.id,
task_name: t.name || "",
});
}
this._vacAllTasks = flat;
} catch {
this._showToast(t("action_error", this._lang));
}
this._vacAllTasks = flat;
}
private async _saveVacation(patch: Record<string, unknown>): Promise<void> {
if (this._vacSaving) return;
this._vacSaving = true;
try {
const result = await this.hass.connection.sendMessagePromise({
const result = await this._ws<SettingsResponse["vacation"]>({
type: "maintenance_supporter/vacation/update",
...patch,
}) as SettingsResponse["vacation"];
});
if (!result) return;
// Server is the source of truth — re-hydrate from response.
this._vacEnabled = result.enabled;
this._vacStart = result.start || "";
@@ -1297,9 +1426,6 @@ export class MaintenanceSettingsView extends LitElement {
this._vacWindowEnd = result.window_end;
// Notify the panel (so the Vacation tab can appear/disappear)
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch (e: unknown) {
const msg = (e as { message?: string })?.message || t("action_error", this._lang);
this._showToast(msg);
} finally {
this._vacSaving = false;
}
@@ -1327,62 +1453,58 @@ export class MaintenanceSettingsView extends LitElement {
}
private async _loadVacationPreview(): Promise<void> {
this._vacPreviewLoading = true;
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/vacation/preview",
}) as { rows: VacationPreviewRow[] };
this._vacPreview = result.rows || [];
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._vacPreviewLoading = false;
}
const result = await this._ws<{ rows: VacationPreviewRow[] }>(
{ type: "maintenance_supporter/vacation/preview" },
undefined,
(busy) => { this._vacPreviewLoading = busy; },
);
if (result) this._vacPreview = result.rows || [];
}
private async _previewActionComplete(row: VacationPreviewRow): Promise<void> {
if (this._previewBusy) return;
this._previewBusy = true;
try {
await this.hass.connection.sendMessagePromise({
const ok = await this._ws({
type: "maintenance_supporter/task/complete",
entry_id: row.entry_id,
task_id: row.task_id,
});
if (ok === undefined) return;
this._showToast(t("vacation_marked_complete", this._lang));
await this._loadVacationPreview();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._previewBusy = false;
}
}
private async _previewActionSkip(row: VacationPreviewRow): Promise<void> {
if (this._previewBusy) return;
this._previewBusy = true;
try {
await this.hass.connection.sendMessagePromise({
const ok = await this._ws({
type: "maintenance_supporter/task/skip",
entry_id: row.entry_id,
task_id: row.task_id,
reason: "Skipped before vacation",
});
if (ok === undefined) return;
this._showToast(t("vacation_marked_skip", this._lang));
await this._loadVacationPreview();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._previewBusy = false;
}
}
private async _endVacationNow(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/vacation/end_now",
}) as SettingsResponse["vacation"];
this._vacEnabled = result.enabled;
this._vacEnd = result.end || "";
this._vacIsActive = result.is_active;
this._vacWindowEnd = result.window_end;
this.dispatchEvent(new CustomEvent("settings-changed"));
this._showToast(t("vacation_ended", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
}
const result = await this._ws<SettingsResponse["vacation"]>({ type: "maintenance_supporter/vacation/end_now" });
if (!result) return;
this._vacEnabled = result.enabled;
this._vacEnd = result.end || "";
this._vacIsActive = result.is_active;
this._vacWindowEnd = result.window_end;
this.dispatchEvent(new CustomEvent("settings-changed"));
this._showToast(t("vacation_ended", this._lang));
}
// --- Section: Print QR codes (v1.1.0) ---
@@ -1484,19 +1606,24 @@ export class MaintenanceSettingsView extends LitElement {
}
private async _loadQrObjects(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/objects",
}) as { objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> };
this._qrObjects = (result.objects || []).map((o) => ({
entry_id: o.entry_id,
name: o.object.name,
task_count: (o.tasks || []).length,
})).sort((a, b) => a.name.localeCompare(b.name));
this._qrObjectsLoaded = true;
} catch {
this._showToast(t("action_error", this._lang));
}
const rows = await this._loadObjectRows();
if (!rows) return;
this._qrObjects = rows;
this._qrObjectsLoaded = true;
}
/** {entry_id, name, task_count} of every object, name-sorted — the QR
* batch and the export section both pick objects from this list. */
private async _loadObjectRows(): Promise<Array<{ entry_id: string; name: string; task_count: number }> | undefined> {
const result = await this._ws<{ objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> }>({
type: "maintenance_supporter/objects",
});
if (!result) return undefined;
return (result.objects || []).map((o) => ({
entry_id: o.entry_id,
name: o.object.name,
task_count: (o.tasks || []).length,
})).sort((a, b) => a.name.localeCompare(b.name));
}
private _toggleQrObject(entryId: string, on: boolean): void {
@@ -1529,20 +1656,18 @@ export class MaintenanceSettingsView extends LitElement {
if (this._qrSelectedEntries.size > 0) {
msg.entry_ids = [...this._qrSelectedEntries];
}
const result = await this.hass.connection.sendMessagePromise<{
const result = await this._ws<{
qrs: Array<{
entry_id: string; task_id: string;
object_name: string; task_name: string;
action: string; svg: string;
}>;
}>(msg);
if (!result) return;
this._qrBatchResults = result.qrs || [];
if (this._qrBatchResults.length === 0) {
this._showToast(t("qr_print_empty", this._lang));
}
} catch (e: unknown) {
const msg = (e as { message?: string })?.message || t("action_error", this._lang);
this._showToast(msg);
} finally {
this._qrBatchLoading = false;
}
@@ -1702,19 +1827,10 @@ export class MaintenanceSettingsView extends LitElement {
}
private async _loadExportObjects(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/objects",
}) as { objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> };
this._exportObjects = (result.objects || []).map((o) => ({
entry_id: o.entry_id,
name: o.object.name,
task_count: (o.tasks || []).length,
})).sort((a, b) => a.name.localeCompare(b.name));
this._exportObjectsLoaded = true;
} catch {
this._showToast(t("action_error", this._lang));
}
const rows = await this._loadObjectRows();
if (!rows) return;
this._exportObjects = rows;
this._exportObjectsLoaded = true;
}
private _toggleExportObject(entryId: string, on: boolean): void {
@@ -1728,92 +1844,75 @@ export class MaintenanceSettingsView extends LitElement {
}
private async _exportJson(): Promise<void> {
try {
const ids = this._selectedEntryIds;
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/export",
format: "json",
include_history: this._includeHistory,
...(ids ? { entry_ids: ids } : {}),
}) as { data: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.data, `maintenance_export_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
}
const ids = this._selectedEntryIds;
const result = await this._ws<{ data: string }>({
type: "maintenance_supporter/export",
format: "json",
include_history: this._includeHistory,
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
this._downloadFile(result.data, `maintenance_export_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
}
/** The SECOND export: the global entry's settings (groups, saved views,
* vacation, notification/budget settings, feature toggles) — the objects
* export deliberately excludes them. Re-import via the regular import. */
private async _exportSettings(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/settings/export",
}) as { data: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
}
const result = await this._ws<{ data: string }>({ type: "maintenance_supporter/settings/export" });
if (!result) return;
const ts = isoDateLocal(new Date());
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
}
private async _exportYaml(): Promise<void> {
try {
const ids = this._selectedEntryIds;
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/export",
format: "yaml",
include_history: this._includeHistory,
...(ids ? { entry_ids: ids } : {}),
}) as { data: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.data, `maintenance_export_${ts}.yaml`, "application/yaml");
this._showToast(t("settings_export_success", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
}
const ids = this._selectedEntryIds;
const result = await this._ws<{ data: string }>({
type: "maintenance_supporter/export",
format: "yaml",
include_history: this._includeHistory,
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
this._downloadFile(result.data, `maintenance_export_${ts}.yaml`, "application/yaml");
this._showToast(t("settings_export_success", this._lang));
}
private async _exportCsv(): Promise<void> {
try {
const ids = this._selectedEntryIds;
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/csv/export",
...(ids ? { entry_ids: ids } : {}),
}) as { csv: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.csv, `maintenance_export_${ts}.csv`, "text/csv");
this._showToast(t("settings_export_success", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
}
const ids = this._selectedEntryIds;
const result = await this._ws<{ csv: string }>({
type: "maintenance_supporter/csv/export",
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
this._downloadFile(result.csv, `maintenance_export_${ts}.csv`, "text/csv");
this._showToast(t("settings_export_success", this._lang));
}
private async _importCsvAction(): Promise<void> {
const content = this._importCsv.trim();
if (!content) return;
this._importLoading = true;
try {
// CSV exports start with the "object_name" header; anything else
// (JSON `{`/`[` or YAML `version:`) goes to the structured importer,
// which parses JSON and YAML alike.
const isCsv = content.startsWith("object_name");
const result = await this.hass.connection.sendMessagePromise(
isCsv
? { type: "maintenance_supporter/csv/import", csv_content: content }
: { type: "maintenance_supporter/json/import", json_content: content }
) as { created: number };
const count = result.created ?? 0;
this._showToast(t("settings_import_success", this._lang).replace("{count}", String(count)));
this._importCsv = "";
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch {
this._showToast(t("action_error", this._lang));
}
this._importLoading = false;
// CSV exports start with the "object_name" header; anything else
// (JSON `{`/`[` or YAML `version:`) goes to the structured importer,
// which parses JSON and YAML alike.
const isCsv = content.startsWith("object_name");
const result = await this._ws<{ created: number }>(
isCsv
? { type: "maintenance_supporter/csv/import", csv_content: content }
: { type: "maintenance_supporter/json/import", json_content: content },
undefined,
(busy) => { this._importLoading = busy; },
);
if (!result) return;
const count = result.created ?? 0;
this._showToast(t("settings_import_success", this._lang).replace("{count}", String(count)));
this._importCsv = "";
this.dispatchEvent(new CustomEvent("settings-changed"));
}
// --- Documents archive (ZIP with file contents) ---
@@ -2051,6 +2150,23 @@ export class MaintenanceSettingsView extends LitElement {
.import-section { margin-top: 16px; }
.setting-row-block { flex-direction: column; align-items: stretch; gap: 6px; }
/* D#162 follow-up: battery lifetime table */
.bl-title { margin: 16px 0 8px; font-size: 14px; }
.bl-table { display: flex; flex-direction: column; gap: 4px; margin: 8px 0 12px; }
.bl-row { display: grid; grid-template-columns: minmax(0, 1fr) 72px max-content minmax(0, 1.4fr) max-content; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--divider-color); }
.bl-type { font-weight: 500; }
.bl-fleet { font-size: 11px; color: var(--secondary-text-color); font-weight: 400; margin-left: 4px; }
.bl-months { width: 72px; padding: 4px 6px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color); color: var(--primary-text-color); font: inherit; }
.bl-unit, .bl-source { font-size: 12px; color: var(--secondary-text-color); }
.bl-override .bl-source { color: var(--primary-color); }
.bl-learned { grid-column: 1 / -1; font-size: 12px; color: var(--secondary-text-color); }
.bl-reset { background: none; border: 1px solid var(--divider-color); border-radius: 12px; padding: 2px 10px; font-size: 12px; color: var(--primary-text-color); cursor: pointer; }
.bl-more > summary { cursor: pointer; font-size: 12px; color: var(--secondary-text-color); padding: 4px 0; }
@media (max-width: 640px) {
.bl-row { grid-template-columns: minmax(0, 1fr) 64px max-content; }
.bl-source { grid-column: 1 / 3; }
.bl-reset { grid-column: 3; justify-self: end; }
}
.notify-extra { min-height: 72px; font-family: var(--code-font-family, monospace); font-size: 12.5px; }
.import-area {
width: 100%;
@@ -2387,4 +2503,6 @@ export class MaintenanceSettingsView extends LitElement {
`];
}
customElements.define("maintenance-settings-view", MaintenanceSettingsView);
if (!customElements.get("maintenance-settings-view")) {
customElements.define("maintenance-settings-view", MaintenanceSettingsView);
}
@@ -14,6 +14,8 @@ import {
} from "../helpers/trigger-domains";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import type { TriggerType } from "../types";
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import "./ms-textfield";
import "./ms-date-field";
@@ -36,7 +38,7 @@ const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const;
* String-typed like the top-level fields (form inputs); coerced on save. */
interface CompoundConditionDraft {
entityIds: string; // comma-separated raw input
type: string; // threshold | counter | state_change | runtime
type: TriggerType;
attribute: string; // "" = use the entity state
above: string;
below: string;
@@ -161,6 +163,9 @@ export class MaintenanceTaskDialog extends LitElement {
private _pickerProbeStrikes = 0;
@state() private _loading = false;
@state() private _error = "";
/** The task saved but a Store-managed sub-save (environmental entity,
* adaptive tuning) was rejected — shown without closing the dialog. */
@state() private _warning = "";
@state() private _entryId = "";
@state() private _taskId: string | null = null; // null = create
// When openCreate is called without an entry_id and a list of objects is supplied,
@@ -210,7 +215,7 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _triggerEntityIds: string[] = [];
@state() private _triggerEntityLogic: "any" | "all" = "any";
@state() private _triggerAttribute = "";
@state() private _triggerType = "threshold";
@state() private _triggerType: TriggerType = "threshold";
@state() private _triggerAbove = "";
@state() private _triggerBelow = "";
@state() private _triggerEquals = "";
@@ -344,6 +349,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._entryId = entryId;
this._taskId = null;
this._error = "";
this._warning = "";
// If no entryId is preset but caller passed objects, expose them as a dropdown.
// Sort alphabetically by name so the user doesn't have to scan for a target
// object in creation order (#40). First object after sort becomes the
@@ -365,6 +371,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._entryId = entryId;
this._taskId = task.id;
this._error = "";
this._warning = "";
// The object picker belongs to CREATE only. openCreate("", objects) left
// the choices behind, so Create → Cancel → Edit showed the dropdown in
// edit mode — and picking another object re-pointed _entryId while
@@ -1501,23 +1508,28 @@ export class MaintenanceTaskDialog extends LitElement {
const envChanged =
this._environmentalEntity !== this._environmentalInitial
|| this._environmentalAttribute !== this._environmentalAttributeInitial;
// A rejected sub-save is non-fatal (the task itself is saved) but not
// silent any more: it becomes a warning line and the dialog stays
// open so the user sees it (DRY round 2026-09 — both catches swallowed).
this._warning = "";
const subSaveFailed = (detail: string) => {
this._warning = t("subsave_warning", this._lang).replace("{detail}", detail);
};
if (
savedTaskId
&& this._scheduleType === "sensor_based"
&& envChanged
) {
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/set_environmental_entity",
entry_id: this._entryId,
task_id: savedTaskId,
environmental_entity: this._environmentalEntity || null,
environmental_attribute: this._environmentalAttribute || null,
});
const ok = await runWs(this, {
type: "maintenance_supporter/task/set_environmental_entity",
entry_id: this._entryId,
task_id: savedTaskId,
environmental_entity: this._environmentalEntity || null,
environmental_attribute: this._environmentalAttribute || null,
}, { onError: subSaveFailed });
if (ok !== undefined) {
this._environmentalInitial = this._environmentalEntity;
this._environmentalAttributeInitial = this._environmentalAttribute;
} catch {
/* non-fatal — task itself saved */
}
}
@@ -1527,25 +1539,27 @@ export class MaintenanceTaskDialog extends LitElement {
const alpha = parseFloat(this._adaptiveAlpha);
const minIv = parseInt(this._adaptiveMin, 10);
const maxIv = parseInt(this._adaptiveMax, 10);
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/set_adaptive",
entry_id: this._entryId,
task_id: savedTaskId,
enabled: this._adaptiveEnabled,
...(alpha >= 0.1 && alpha <= 0.9 ? { ewa_alpha: alpha } : {}),
...(!isNaN(minIv) && minIv >= 1 ? { min_interval_days: minIv } : {}),
...(!isNaN(maxIv) && maxIv >= 1 ? { max_interval_days: maxIv } : {}),
seasonal_enabled: this._adaptiveSeasonal,
sensor_prediction_enabled: this._adaptivePrediction,
});
this._adaptiveInitial = this._adaptiveSnapshot();
} catch {
/* non-fatal — task itself saved */
}
const ok = await runWs(this, {
type: "maintenance_supporter/task/set_adaptive",
entry_id: this._entryId,
task_id: savedTaskId,
enabled: this._adaptiveEnabled,
...(alpha >= 0.1 && alpha <= 0.9 ? { ewa_alpha: alpha } : {}),
...(!isNaN(minIv) && minIv >= 1 ? { min_interval_days: minIv } : {}),
...(!isNaN(maxIv) && maxIv >= 1 ? { max_interval_days: maxIv } : {}),
seasonal_enabled: this._adaptiveSeasonal,
sensor_prediction_enabled: this._adaptivePrediction,
}, { onError: subSaveFailed });
if (ok !== undefined) this._adaptiveInitial = this._adaptiveSnapshot();
}
this._open = false;
if (this._warning) {
// Keep the dialog open on the (now existing) task so a retry is an
// update, never a second create; the panel still refreshes.
if (savedTaskId) this._taskId = savedTaskId;
} else {
this._open = false;
}
this.dispatchEvent(new CustomEvent("task-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
@@ -1556,6 +1570,7 @@ export class MaintenanceTaskDialog extends LitElement {
private _close(): void {
this._open = false;
this._warning = "";
if (this._pickerProbeTimer !== undefined) {
clearTimeout(this._pickerProbeTimer);
this._pickerProbeTimer = undefined;
@@ -1574,7 +1589,7 @@ export class MaintenanceTaskDialog extends LitElement {
<label>${t("trigger_type", L)}</label>
<select
.value=${this._triggerType}
@change=${(e: Event) => (this._triggerType = (e.target as HTMLSelectElement).value)}
@change=${(e: Event) => (this._triggerType = (e.target as HTMLSelectElement).value as TriggerType)}
>
${TRIGGER_TYPE_KEYS_WITH_COMPOUND.map(
(key) => html`<option value=${key} ?selected=${key === this._triggerType}>${t(key, L)}</option>`
@@ -1750,7 +1765,7 @@ export class MaintenanceTaskDialog extends LitElement {
<label>${t("trigger_type", L)}</label>
<select
.value=${c.type}
@change=${(e: Event) => this._patchCondition(i, { type: (e.target as HTMLSelectElement).value })}
@change=${(e: Event) => this._patchCondition(i, { type: (e.target as HTMLSelectElement).value as TriggerType })}
>
${TRIGGER_TYPE_KEYS.map(
(key) => html`<option value=${key} ?selected=${key === c.type}>${t(key, L)}</option>`
@@ -2602,6 +2617,7 @@ export class MaintenanceTaskDialog extends LitElement {
<div class="dialog-title">${title}</div>
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this._warning ? html`<div class="error warning">${this._warning}</div>` : nothing}
${this._taskId === null && this._objectChoices.length > 0 ? html`
<div class="select-row">
<label>${t("object", L)}</label>
@@ -3373,6 +3389,7 @@ export class MaintenanceTaskDialog extends LitElement {
color: var(--text-primary-color, #fff);
border-color: var(--primary-color, #03a9f4);
}
.error.warning { color: var(--warning-color, #ff9800); }
.error {
color: var(--error-color, #f44336);
font-size: 13px;
@@ -261,6 +261,7 @@ export class MaintenanceTaskDocuments extends LitElement {
}}
>
<div class="tdoc-title">${docDisplayName(doc)}</div>
${(doc as { description?: string }).description ? html`<div class="tdoc-desc">${(doc as { description?: string }).description}</div>` : nothing}
<div class="tdoc-meta">
${meta}${page ? html` · <span class="tdoc-pagetag">${t("doc_page", L)} ${page}</span>` : nothing}
</div>
@@ -328,6 +329,7 @@ export class MaintenanceTaskDocuments extends LitElement {
.tdoc-info:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.tdoc-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tdoc-meta { font-size: 12px; color: var(--secondary-text-color, #888); }
.tdoc-desc { font-size: 12px; color: var(--secondary-text-color, #888); white-space: pre-line; }
.tdoc-pagetag { color: var(--primary-color); font-weight: 500; }
.tdoc-page {
flex: none; width: 76px; padding: 5px 8px; border-radius: 6px; font: inherit; font-size: 13px;
@@ -14,14 +14,15 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, formatNumber, langOf } from "../styles";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
import { describeWsError } from "../ws-errors";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
import { phaseLabel } from "../helpers/phases";
import { historyPhotoIds } from "../helpers/history-photos";
import { entryReadingValues, readingSlotDelta } from "../helpers/reading-slots";
import "./history-photo";
import { buildHistoryEntryDraft } from "../helpers/history-draft";
import { readingSlotDelta } from "../helpers/reading-slots";
import { taskRef } from "../helpers/reference";
import { renderHistoryEntry } from "../renderers/history";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
@@ -40,7 +41,7 @@ import type {
interface MaintenanceObjectFull {
entry_id: string;
object: { id: string; name: string };
object: { id: string; name: string; ref_no?: number | null };
tasks: MaintenanceTask[];
}
@@ -52,6 +53,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
@state() private _taskId: string | null = null;
@state() private _task: MaintenanceTask | null = null;
@state() private _objectName = "";
/** #170: "8.3" once the backend numbered the task — the history rows' "#8.3-n" chips. */
@state() private _taskRef: string | null = null;
@state() private _busy = false;
@state() private _error = "";
@state() private _showSkip = false;
@@ -100,12 +103,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
try {
const r = await this.hass.connection.sendMessagePromise<{
features?: Partial<AdvancedFeatures>;
budget?: { currency_symbol?: string };
budget?: { currency_symbol?: string; currency_decimals?: number };
}>({ type: "maintenance_supporter/settings" });
if (r?.features) {
this._features = { ...this._features, ...r.features };
}
this._currencySymbol = r?.budget?.currency_symbol || "";
this._currencySymbol = currencySymbolOf(r?.budget);
syncCurrencyDecimals(r?.budget);
this._featuresLoaded = true;
} catch {
// Settings endpoint unavailable — leave defaults (all false), the
@@ -129,6 +133,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
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);
}
@@ -311,7 +316,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
interval: this._task.suggested_interval,
});
if (ok) {
this._toast = t("suggestion_applied", this._lang) || "Applied";
this._toast = t("suggestion_applied", this._lang);
this._notifyChanged("apply_suggestion");
// Refresh local task so the recommendation card hides
await this._loadTask();
@@ -336,8 +341,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
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) || "Recomputed"}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
: (t("reanalyze_insufficient_data", this._lang) || "Not enough data");
? `${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) {
@@ -349,26 +354,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
private _onEditHistoryEntry(entry: HistoryEntry): void {
if (!this._entryId || !this._taskId) return;
import("../dialog-mount").then(({ openHistoryEditDialog }) => {
openHistoryEditDialog({
entry_id: this._entryId!,
task_id: this._taskId!,
original_timestamp: entry.timestamp,
type: entry.type,
timestamp: entry.timestamp,
notes: entry.notes ?? null,
cost: entry.cost ?? null,
duration: entry.duration ?? null,
completed_by: entry.completed_by ?? null,
used_parts: entry.used_parts ?? null,
photo_doc_ids: historyPhotoIds(entry),
reading_value: entry.reading_value ?? null,
reading_values: entryReadingValues(entry),
readings: this._task?.readings ?? [],
task_type: this._task?.type ?? null,
reading_unit: this._task?.reading_unit ?? null,
});
});
const draft = buildHistoryEntryDraft(this._entryId, this._taskId, entry, this._task);
import("../dialog-mount").then(({ openHistoryEditDialog }) => openHistoryEditDialog(draft));
}
/** Inline recommendation card (Current vs Suggested with apply/reanalyze).
@@ -426,7 +413,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
if (!hasRecommendation && !hasPrediction && !hasWeibull && !hasSeasonal) {
return html`<div class="adaptive-empty">
${t("adaptive_no_data", L) || "Not enough completion history yet for adaptive analysis."}
${t("adaptive_no_data", L)}
</div>`;
}
return html`
@@ -448,30 +435,6 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
`;
}
/** #161: the entry's readings — per-slot lines with the delta against the
* previous entry carrying the slot, or the single value of a scalar task.
* Parity with the panel timeline for the Lovelace / strategy path. */
private _renderHistoryReadings(entry: HistoryEntry, history: HistoryEntry[], L: string) {
const values = entryReadingValues(entry);
const fmt = (n: number) => formatNumber(n, L, { maximumFractionDigits: 3 });
if (values.length > 0) {
return html`<div class="history-readings">
${values.map((v) => {
const d = readingSlotDelta(history, entry, v.id);
return html`<span class="history-reading"><span class="history-reading-name">${v.name}</span>
<span class="history-reading-value">${fmt(v.value)}${v.unit ? ` ${v.unit}` : ""}${d == null ? "" : ` (${d >= 0 ? "+" : ""}${fmt(d)})`}</span></span>`;
})}
</div>`;
}
if (entry.reading_value != null) {
const unit = this._task?.reading_unit ? ` ${this._task.reading_unit}` : "";
return html`<div class="history-readings"><span class="history-reading">
<span class="history-reading-name">${t("reading_label", L)}</span>
<span class="history-reading-value">${fmt(entry.reading_value)}${unit}</span></span></div>`;
}
return nothing;
}
/** Read-only details panel: stats + history. Shown when the user clicks
* "Show details" in the dialog. Edit-buttons on history entries open the
* existing history-edit dialog (which lives in the same dialog-mount). */
@@ -495,64 +458,37 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<div class="details">
<div class="stats-grid">
<div class="stat">
<span class="stat-label">${t("times_performed", L) || "Performed"}</span>
<span class="stat-label">${t("times_performed", L)}</span>
<span class="stat-value">${completed.length}</span>
</div>
<div class="stat">
<span class="stat-label">${t("total_cost", L) || "Total cost"}</span>
<span class="stat-value">${formatNumber(totalCost, L, 2)}</span>
<span class="stat-label">${t("total_cost", L)}</span>
<span class="stat-value">${formatCost(totalCost, this._currencySymbol, L)}</span>
</div>
<div class="stat">
<span class="stat-label">${t("avg_duration", L) || "Avg duration"}</span>
<span class="stat-value">${avgDuration != null ? `${avgDuration}m` : "—"}</span>
<span class="stat-label">${t("avg_duration", L)}</span>
<span class="stat-value">${formatDuration(avgDuration, L)}</span>
</div>
</div>
<div class="history-header">
<strong>${t("history", L) || "History"}</strong>
<strong>${t("history", L)}</strong>
<span class="history-count">${history.length}</span>
</div>
${history.length === 0
? html`<div class="history-empty">${t("history_empty", L) || "No history yet."}</div>`
? html`<div class="history-empty">${t("history_empty", L)}</div>`
: html`
<div class="history-list">
${[...history].reverse().slice(0, 20).map((entry) => {
const editable = ["completed", "reset", "skipped"].includes(entry.type);
return html`
<div class="history-entry">
<div class="history-line">
<span class="history-type type-${entry.type}">${t(entry.type, L)}</span>
<span class="history-date">${formatDateTime(entry.timestamp, L)}</span>
${editable
? html`<button class="history-edit"
title="${t("history_edit_button", L) || "Edit"}"
@click=${() => this._onEditHistoryEntry(entry)}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>`
: nothing}
</div>
${entry.notes
? html`<div class="history-notes">${entry.notes}</div>`
: nothing}
${this._renderHistoryReadings(entry, history, L)}
${(() => {
const photos = historyPhotoIds(entry);
return photos.length
? html`<div class="history-photos">
${photos.map((docId) => html`<maintenance-history-photo .hass=${this.hass} .docId=${docId}></maintenance-history-photo>`)}
</div>`
: nothing;
})()}
${entry.cost != null || entry.duration != null
? html`<div class="history-meta">
${entry.cost != null ? html`<span>💰 ${formatNumber(entry.cost, L, 2)}</span>` : nothing}
${entry.duration != null ? html`<span>⏱️ ${entry.duration}m</span>` : nothing}
</div>`
: nothing}
</div>
`;
})}
${[...history].reverse().slice(0, 20).map((entry) => renderHistoryEntry(entry, {
lang: L,
hass: this.hass,
currencySymbol: this._currencySymbol,
openEdit: (e) => this._onEditHistoryEntry(e),
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) || "older"}</div>`
? html`<div class="history-more">… +${history.length - 20} ${t("older_entries", L)}</div>`
: nothing}
</div>
`}
@@ -587,13 +523,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
</div>
<div class="quick-info">
${task.next_due
? html`<span><strong>${t("next_due", L) || "Next due"}:</strong> ${formatDate(task.next_due, L)}</span>`
? html`<span><strong>${t("next_due", L)}:</strong> ${formatDate(task.next_due, L)}</span>`
: nothing}
${task.last_performed
? html`<span><strong>${t("last_performed", L) || "Last"}:</strong> ${formatDate(task.last_performed, L)}</span>`
? html`<span><strong>${t("last_performed", L)}:</strong> ${formatDate(task.last_performed, L)}</span>`
: nothing}
${(task.schedule?.kind && !["manual", "one_time"].includes(task.schedule.kind)) || task.interval_days != null
? html`<span><strong>${t("interval", L) || "Interval"}:</strong> ${formatRecurrence(task, L)}</span>`
? html`<span><strong>${t("interval", L)}:</strong> ${formatRecurrence(task, L)}</span>`
: nothing}
${phaseLabel(task)
? html`<span><strong>${t("phase_current", L)}:</strong> ${phaseLabel(task)}</span>`
@@ -608,15 +544,15 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
${this._showSkip
? html`
<div class="inline-form">
<label>${t("skip_reason", L) || "Skip reason (optional)"}</label>
<label>${t("skip_reason", L)}</label>
<input type="text" .value=${this._skipReason}
@input=${(e: Event) => { this._skipReason = (e.target as HTMLInputElement).value; }} />
<div class="inline-actions">
<button class="btn cancel" @click=${() => { this._showSkip = false; }} ?disabled=${this._busy}>
${t("cancel", L) || "Cancel"}
${t("cancel", L)}
</button>
<button class="btn primary" @click=${this._onSkipConfirm} ?disabled=${this._busy}>
${t("skip", L) || "Skip"}
${t("skip", L)}
</button>
</div>
</div>
@@ -624,7 +560,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
: this._showReset
? html`
<div class="inline-form">
<label>${t("reset_to_date", L) || "Reset last_performed to"}</label>
<label>${t("reset_to_date", L)}</label>
<ms-date-field
kind="date"
.hass=${this.hass}
@@ -634,10 +570,10 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
></ms-date-field>
<div class="inline-actions">
<button class="btn cancel" @click=${() => { this._showReset = false; }} ?disabled=${this._busy}>
${t("cancel", L) || "Cancel"}
${t("cancel", L)}
</button>
<button class="btn primary" @click=${this._onResetConfirm} ?disabled=${this._busy}>
${t("reset", L) || "Reset"}
${t("reset", L)}
</button>
</div>
</div>
@@ -646,19 +582,19 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<div class="actions primary-row">
<ha-button appearance="accent" variant="success" @click=${this._onComplete} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:check"></ha-icon>
${t("complete", L) || "Complete"}
${t("complete", L)}
</ha-button>
${task.allow_skip !== false
? html`
<ha-button appearance="outlined" variant="warning" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>
${t("skip", L) || "Skip"}
${t("skip", L)}
</ha-button>
`
: nothing}
<ha-button appearance="outlined" variant="neutral" @click=${() => { this._showReset = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:restart"></ha-icon>
${t("reset", L) || "Reset"}
${t("reset", L)}
</ha-button>
</div>
${isAdmin
@@ -666,21 +602,21 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<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) || "Edit"}
${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) || "QR"}
${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) || "Unarchive") : (t("archive", L) || "Archive")}
${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) || "Delete"}
${t("delete", L)}
</ha-button>
</div>
`
@@ -689,8 +625,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<button class="link" @click=${() => { this._showDetails = !this._showDetails; }}>
<ha-icon icon="${this._showDetails ? 'mdi:chevron-up' : 'mdi:chevron-down'}"></ha-icon>
${this._showDetails
? (t("hide_details", L) || "Hide details")
: (t("show_details", L) || "Show history + stats")}
? t("hide_details", L)
: t("show_details", L)}
</button>
${this._features.adaptive
|| this._features.seasonal
@@ -698,8 +634,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
? html`<button class="link" @click=${() => { this._showAdaptive = !this._showAdaptive; }}>
<ha-icon icon="${this._showAdaptive ? 'mdi:chart-line' : 'mdi:chart-line-variant'}"></ha-icon>
${this._showAdaptive
? (t("hide_stats", L) || "Hide stats")
: (t("show_stats", L) || "Show stats + graphs")}
? t("hide_stats", L)
: t("show_stats", L)}
</button>`
: nothing}
</div>
@@ -708,12 +644,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<div class="footer">
<button class="link" @click=${this._onOpenInPanel}>
<ha-icon icon="mdi:open-in-new"></ha-icon>
${t("open_in_panel", L) || "Open in Maintenance panel"}
${t("open_in_panel", L)}
</button>
</div>
`}
`
: html`<div class="loading">${t("loading", L) || "Loading…"}</div>`}
: html`<div class="loading">${t("loading", L)}</div>`}
</div>
`;
}
@@ -843,41 +779,16 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
background: var(--secondary-background-color); padding: 2px 8px; border-radius: 999px;
}
.history-empty { color: var(--secondary-text-color); font-style: italic; font-size: 13px; }
.history-list { display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow: auto; }
.history-entry {
padding: 6px 8px; border-radius: 6px;
/* The rows themselves are the shared renderer's (.history-entry.compact
+ the history-* classes from sharedStyles); only the list chrome is
local. */
.history-list { display: flex; flex-direction: column; max-height: 280px; overflow: auto; }
.history-list .history-entry {
padding: 6px 8px; border-radius: 6px; border-bottom: none; margin-bottom: 6px;
background: var(--secondary-background-color, rgba(255,255,255,0.03));
font-size: 13px;
}
.history-line {
display: flex; align-items: center; gap: 8px;
justify-content: space-between;
}
.history-type {
font-weight: 600; font-size: 11px;
padding: 2px 6px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.type-completed { background: rgba(46,125,50,0.2); color: #66bb6a; }
.type-skipped { background: rgba(158,158,158,0.2); color: var(--secondary-text-color); }
.type-reset { background: rgba(33,150,243,0.2); color: #64b5f6; }
.type-triggered { background: rgba(255,87,34,0.2); color: #ff8a65; }
.history-date { font-size: 11px; color: var(--secondary-text-color); flex: 1; text-align: right; }
.history-edit {
background: transparent; border: none; cursor: pointer;
padding: 4px; border-radius: 4px;
color: var(--secondary-text-color);
}
.history-edit:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); color: var(--primary-color); }
.history-edit ha-icon { --mdc-icon-size: 14px; }
.history-notes { margin-top: 4px; color: var(--primary-text-color); }
.history-meta { display: flex; gap: 12px; margin-top: 4px; color: var(--secondary-text-color); font-size: 11px; }
/* #161: readings + photos on the entry (panel-timeline parity) */
.history-readings { display: flex; flex-wrap: wrap; gap: 2px 16px; margin-top: 4px; font-size: 12px; }
.history-reading { display: inline-flex; gap: 6px; }
.history-reading-name { color: var(--secondary-text-color); }
.history-reading-value { font-variant-numeric: tabular-nums; }
.history-photos { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.history-list .history-date { font-size: 11px; }
.history-list .history-details { font-size: 11px; }
.history-more { padding: 8px; text-align: center; font-size: 12px; color: var(--secondary-text-color); font-style: italic; }
/* Adaptive section — wraps the panel renderers (which assume sharedStyles
@@ -128,8 +128,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
private async _endNow(): Promise<void> {
if (!this._isAdmin) return;
if (!window.confirm(t("vacation_end_now_confirm", this._lang)
|| "End vacation immediately?")) return;
if (!window.confirm(t("vacation_end_now_confirm", this._lang))) return;
this._busy = true;
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
@@ -155,16 +154,16 @@ export class MaintenanceVacationSectionCard extends LitElement {
const L = this._lang;
const s = this._state;
if (!s) {
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
return html`<ha-card><div class="loading">${t("loading", L)}</div></ha-card>`;
}
const active = s.is_active === true;
const enabled = s.enabled === true;
const exemptCount = s.exempt_task_ids?.length ?? 0;
const statusLabel = active
? (t("vacation_status_active", L) || "Active now")
? t("vacation_status_active", L)
: enabled
? (t("vacation_status_scheduled", L) || "Scheduled")
: (t("vacation_status_inactive", L) || "Inactive");
? t("vacation_status_scheduled", L)
: t("vacation_status_inactive", L);
const statusClass = active ? "active" : enabled ? "scheduled" : "inactive";
return html`
@@ -173,7 +172,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
<div class="header">
<div class="title">
<span class="emoji">🏖️</span>
<span>${this._config.title || (t("vacation_mode", L) || "Vacation mode")}</span>
<span>${this._config.title || t("vacation_mode", L)}</span>
</div>
<span class="status-pill ${statusClass}">${statusLabel}</span>
</div>
@@ -183,7 +182,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
${this._isAdmin
? html`
<div class="row toggle-row">
<label>${t("enable", L) || "Enable"}</label>
<label>${t("enable", L)}</label>
<ha-switch
.checked=${enabled}
.disabled=${this._busy}
@@ -194,7 +193,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
<div class="dates-row">
<div class="date-field">
<label>${t("vacation_start", L) || "Start"}</label>
<label>${t("vacation_start", L)}</label>
<ms-date-field
kind="date"
clearable
@@ -209,7 +208,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
></ms-date-field>
</div>
<div class="date-field">
<label>${t("vacation_end", L) || "End"}</label>
<label>${t("vacation_end", L)}</label>
<ms-date-field
kind="date"
clearable
@@ -224,7 +223,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
></ms-date-field>
</div>
<div class="date-field buffer">
<label>${t("vacation_buffer", L) || "Buffer days"}</label>
<label>${t("vacation_buffer", L)}</label>
<input type="number" min="0" max="14"
.value=${String(this._localBuffer)}
?disabled=${this._busy}
@@ -242,24 +241,24 @@ export class MaintenanceVacationSectionCard extends LitElement {
?disabled=${this._busy || !this._dirty}>
<ha-icon icon="${this._dirty ? "mdi:content-save" : "mdi:check"}"></ha-icon>
${this._dirty
? (t("save", L) || "Save")
: (t("saved", L) || "Saved")}
? t("save", L)
: t("saved", L)}
</button>
${active
? html`<button class="btn"
@click=${this._endNow}
?disabled=${this._busy}>
${t("vacation_end_now", L) || "End now"}
${t("vacation_end_now", L)}
</button>`
: nothing}
${exemptCount > 0
? html`<button class="btn link"
@click=${this._onDeepLink}>
${exemptCount} ${t("vacation_exempt_count", L) || "exempt"}…
${exemptCount} ${t("vacation_exempt_count", L)}…
</button>`
: html`<button class="btn link"
@click=${this._onDeepLink}>
${t("vacation_advanced", L) || "Advanced…"}
${t("vacation_advanced", L)}
</button>`}
</div>
`
@@ -269,7 +268,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
? html`<div>${s.start} → ${s.end}</div>`
: nothing}
<button class="btn link" @click=${this._onDeepLink}>
${t("vacation_open_panel", L) || "Open in panel"}
${t("vacation_open_panel", L)}
</button>
</div>
`}