189 files

This commit is contained in:
Home Assistant Version Control
2026-09-08 18:05:32 +00:00
parent 79fefc0a7c
commit 303886f212
189 changed files with 21402 additions and 3514 deletions
@@ -35,6 +35,14 @@ interface BatteryRow {
* healthy. Never escalates to low/task — the row just shows the
* discrepancy (usual cause: an unrecorded swap). */
forecast_overdue?: boolean;
/** D#162: a Battery Notes note whose device has NO battery level at
* all — read from its type sensor. No level bar, never offline; the
* forecast is the whole signal and (option) a passed one is `low`. */
no_sensor?: boolean;
/** D#162 follow-up: the note has a replaced button → per-row Replaced action. */
can_mark_replaced?: boolean;
/** ISO date the forecast anchors on (Battery Notes' last replaced). */
last_replaced?: string | null;
}
interface RosterRow extends BatteryRow {
status: "low" | "soon" | "ok";
@@ -69,6 +77,9 @@ interface Overview {
excluded?: { entity_id: string; device_name: string }[];
// #135 follow-up: fleet-wide "track self-charging devices" opt-in.
track_self_charging?: boolean;
// D#162: a passed forecast on a sensorless note counts as due (default
// on — an older backend omits the field, which renders as on too).
due_without_sensor?: boolean;
}
export class MaintenanceBatteryFleetSection extends LitElement {
@@ -213,13 +224,22 @@ export class MaintenanceBatteryFleetSection extends LitElement {
// #135 follow-up: fleet-wide opt-in that keeps self-charging devices
// (phones, vacuums, smart rings) in the roster as rechargeables.
private async _setTrackSelf(e: Event): Promise<void> {
const enabled = (e.target as HTMLInputElement).checked;
await this._setFleetOption("set_track_self_charging", (e.target as HTMLInputElement).checked);
}
// D#162: fleet-wide option — a passed forecast on a sensorless note is
// due (fires the task). Default on; only the opt-out is stored.
private async _setDueWithoutSensor(e: Event): Promise<void> {
await this._setFleetOption("set_due_without_sensor", (e.target as HTMLInputElement).checked);
}
private async _setFleetOption(command: "set_track_self_charging" | "set_due_without_sensor", enabled: boolean): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/set_track_self_charging",
type: `maintenance_supporter/battery_fleet/${command}`,
enabled,
});
await this._load();
@@ -428,7 +448,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
<span class="bf-dev">${b.device_name}</span>
${b.available === false
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
: nothing}
: 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)}
@@ -496,8 +518,15 @@ export class MaintenanceBatteryFleetSection extends LitElement {
(b) => html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
<span class="bf-status bf-${b.status}">${t("battery_fleet_status_" + b.status, L)}</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
@@ -506,6 +535,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
${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;
@@ -564,6 +603,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
${t("battery_fleet_track_self", L)}
</label>
<div class="bf-roster-hint">${t("battery_fleet_track_self_hint", L)}</div>
<label class="bf-track-self bf-due-nosensor">
<input
type="checkbox"
.checked=${ov.due_without_sensor !== false}
.disabled=${this._marking}
@change=${this._setDueWithoutSensor}
/>
${t("battery_fleet_due_without_sensor", L)}
</label>
<div class="bf-roster-hint">${t("battery_fleet_due_without_sensor_hint", L)}</div>
</details>
`
: nothing}
@@ -712,6 +761,13 @@ 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 {
grid-column: 6;
justify-self: start;
white-space: nowrap;
}
.bf-level {
grid-column: 7;
justify-self: end;
@@ -719,6 +775,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-row .bf-mark {
grid-column: 8;
}
/* D#162: the sensorless row's Replaced action sits in the percentage
* slot (always empty for such a row) instead of the row-action column
* - a new max-content track there widens EVERY row via the subgrid
* and pushed the phone roster 34 px past its edge. */
.bf-row .bf-mark.bf-replaced {
grid-column: 7;
justify-self: end;
}
.bf-predicted {
grid-column: 9;
justify-self: end;
@@ -791,6 +855,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-row .bf-mark {
grid-row: 2;
}
/* 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 {
display: none;
}
}
.bf-spark-line {
fill: none;
@@ -2,11 +2,18 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, TaskPartLink } from "../types";
import { t, nativeFieldStyles, formatCost } from "../styles";
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles";
import { describeWsError } from "../ws-errors";
import { 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 "./ms-date-field";
export class MaintenanceCompleteDialog extends LitElement {
@@ -20,6 +27,14 @@ export class MaintenanceCompleteDialog extends LitElement {
// v2.20 (#83): task type + unit drive the reading-value field below.
@property() public taskType = "";
@property() public readingUnit = "";
/** #161 phase 2: the task's reading slots — one field each instead of
* the single value; [] keeps the scalar field. */
@property({ attribute: false }) public readings: ReadingSlot[] = [];
/** The task's dated slot snapshots (oldest first) — the "last: …" hint
* and the lower-than-last warning compare against the last value BEFORE
* the completion moment, so a backdated entry is judged by its own
* date rather than by the newest reading. */
@property({ attribute: false }) public readingHistory: ReadingHistoryEntry[] = [];
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
@property({ attribute: false }) public restockDefault: number | null = null;
/** #104 follow-up: the buy task's part unit cost — powers the cost
@@ -61,10 +76,15 @@ export class MaintenanceCompleteDialog extends LitElement {
@state() private _error = "";
@state() private _checklistState: Record<string, boolean> = {};
@state() private _feedback: string = "needed";
@state() private _photoDocId = "";
@state() private _photoPreview = "";
/** #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;
@state() private _readingValue = "";
/** #161 phase 2: typed text per slot id (parsed on save; "" = unread). */
@state() private _readingValues: Record<string, string> = {};
@state() private _restockQty = "";
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = now). */
@state() private _completedAt = "";
@@ -94,10 +114,12 @@ export class MaintenanceCompleteDialog extends LitElement {
.filter(([, done]) => done),
);
this._feedback = "needed";
this._photoDocId = "";
this._photoPreview = "";
this._photos.forEach((p) => URL.revokeObjectURL(p.preview));
this._photos = [];
this._uploadedIds = [];
this._photoUploading = false;
this._readingValue = "";
this._readingValues = {};
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
this._completedAt = "";
// #99: prefill "parts used" with the task's fixed links — the user can
@@ -118,45 +140,52 @@ 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 file = input.files?.[0];
const files = Array.from(input.files ?? []);
input.value = ""; // allow re-picking the same file
if (!file) return;
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 {
const form = new FormData();
form.append("entry_id", this.entryId);
form.append("tags", "photo");
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);
return;
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) }];
}
const doc = (await resp.json()) as { id?: string };
if (doc.id) {
this._photoDocId = doc.id;
this._photoPreview = URL.createObjectURL(file);
if (files.length > accepted.length) {
this._error = t("photos_limit", this.lang).replace("{max}", String(MAX_COMPLETION_PHOTOS));
}
} catch {
this._error = t("doc_upload_failed", this.lang);
} 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;
}
}
private _removePhoto(): void {
if (this._photoPreview) URL.revokeObjectURL(this._photoPreview);
this._photoDocId = "";
this._photoPreview = "";
/** ✕ 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> {
@@ -183,8 +212,8 @@ export class MaintenanceCompleteDialog extends LitElement {
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photoDocId) {
data.photo_doc_id = this._photoDocId;
if (this._photos.length > 0) {
data.photo_doc_ids = this._photos.map((p) => p.id);
}
// 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
@@ -204,7 +233,18 @@ export class MaintenanceCompleteDialog extends LitElement {
// normalisation as the history-edit dialog).
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
}
if (this._readingValue !== "") {
if (this.readings.length > 0) {
// #161 phase 2: only the meters actually read go out; the backend
// snapshots name + unit per slot id.
const values: Record<string, number> = {};
for (const slot of this.readings) {
const raw = (this._readingValues[slot.id] ?? "").trim();
if (raw === "") continue;
const num = parseFloat(raw.replace(",", "."));
if (!isNaN(num)) values[slot.id] = num;
}
if (Object.keys(values).length > 0) data.reading_values = values;
} else if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
@@ -226,6 +266,7 @@ export class MaintenanceCompleteDialog extends LitElement {
);
}
await this.hass.connection.sendMessagePromise(data);
this._uploadedIds = []; // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
} catch (e) {
@@ -235,13 +276,37 @@ export class MaintenanceCompleteDialog extends LitElement {
}
}
/** #161 phase 2: one field per slot with the previous value as a hint;
* a value below the last one gets a warning (meters count up — a
* replaced meter or a typo), never a block. */
private _renderReadingField(slot: ReadingSlot, L: string) {
const at = this._completedAt ? new Date(this._completedAt).getTime() : NaN;
const last = lastReadingBefore(this.readingHistory, slot.id, isNaN(at) ? undefined : at);
const unit = slot.unit || this.readingUnit;
const raw = (this._readingValues[slot.id] ?? "").trim();
const num = raw === "" ? NaN : parseFloat(raw.replace(",", "."));
const below = last !== undefined && !isNaN(num) && num < last.value;
const lastText = last !== undefined ? formatNumber(last.value, L, { maximumFractionDigits: 3 }) : "";
return html`
<label class="field reading-field">
<span class="field-label">${slot.name}${unit ? ` (${unit})` : ""}</span>
<input type="text" inputmode="decimal" class="field-input"
placeholder=${last !== undefined ? t("reading_last", L).replace("{value}", lastText) : ""}
.value=${this._readingValues[slot.id] ?? ""}
@input=${(e: Event) => {
this._readingValues = { ...this._readingValues, [slot.id]: (e.target as HTMLInputElement).value };
}} />
${below ? html`<span class="reading-warn">${t("reading_below_last", L).replace("{value}", lastText)}</span>` : nothing}
</label>`;
}
/** Required details the user has not supplied yet (drives Save + markers). */
private get _missingRequired(): string[] {
const filled: Record<string, boolean> = {
notes: this._notes.trim() !== "",
cost: this._cost.trim() !== "",
duration: this._duration.trim() !== "",
photo: this._photoDocId !== "",
photo: this._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
@@ -300,6 +365,11 @@ 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);
}
}
/** Seed the backdate field with the current minute (local, seconds zeroed). */
@@ -330,7 +400,12 @@ export class MaintenanceCompleteDialog extends LitElement {
`)}
</div>
` : nothing}
${this.taskType === "reading"
${this.readings.length > 0
? html`<div class="readings-block">
<span class="field-label">${t("readings_section", L)}</span>
${this.readings.map((slot) => this._renderReadingField(slot, L))}
</div>`
: this.taskType === "reading"
? html`
<label class="field">
<span class="field-label">${t("reading_value_label", L)}${this.readingUnit ? ` (${this.readingUnit})` : ""}</span>
@@ -433,22 +508,36 @@ export class MaintenanceCompleteDialog extends LitElement {
</button>`}
</div>
<div class="field">
<span class="field-label">${t("completion_photo_optional", L)}${this._req("photo")}</span>
${this._photoPreview
? html`
<div class="photo-preview">
<img src=${this._photoPreview} alt="" />
<button type="button" class="photo-remove" @click=${this._removePhoto}
title="${t("remove", L)}">✕</button>
<span class="field-label">${t("completion_photos_optional", L)}${this._req("photo")}</span>
${this._photos.length > 0
? html`<div class="photo-strip">
${this._photos.map((p) => html`
<div class="photo-preview">
<img src=${p.preview} alt="" />
<button type="button" class="photo-remove" @click=${() => this._removePhoto(p.id)}
title="${t("remove", L)}">✕</button>
</div>`)}
</div>`
: html`
<label class="photo-pick">
<ha-icon icon="mdi:camera"></ha-icon>
<span>${this._photoUploading ? t("uploading", L) : t("add_photo", L)}</span>
<input type="file" accept="image/*" capture="environment"
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>`}
: 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>`}
</div>
${this.adaptiveEnabled ? html`
<div class="feedback-section">
@@ -567,6 +656,13 @@ export class MaintenanceCompleteDialog extends LitElement {
font-size: 13px;
}
/* .field/.field-label/.field-input come from nativeFieldStyles */
/* #161 phase 2: the per-slot reading fields */
.readings-block { display: flex; flex-direction: column; gap: 8px; }
.readings-block > .field-label { margin-bottom: -4px; }
.reading-warn {
font-size: 12px;
color: var(--warning-color, #ff9800);
}
.photo-pick {
display: inline-flex;
align-items: center;
@@ -601,15 +697,37 @@ export class MaintenanceCompleteDialog extends LitElement {
}
.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;
gap: 12px;
margin: 8px 0; /* room for the remove badges above the tiles */
}
.photo-limit {
font-size: 12px;
color: var(--secondary-text-color);
}
.photo-android-hint { margin-top: 4px; }
.photo-preview {
position: relative;
width: fit-content;
}
/* Uniform tiles: a tiny or portrait shot must not collapse the strip. */
.photo-preview img {
max-width: 160px;
max-height: 160px;
width: 96px;
height: 96px;
object-fit: cover;
border-radius: 8px;
display: block;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
}
.photo-remove {
position: absolute;
@@ -16,6 +16,7 @@ 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 type { HomeAssistant } from "../types";
interface MaintenanceDocument {
@@ -36,6 +37,8 @@ export class MaintenanceDocumentsSection extends LitElement {
@property({ type: Boolean }) public canWrite = false;
@state() private _docs: MaintenanceDocument[] = [];
// Local filter (#171) — shown once the list is long enough to need one.
@state() private _filter = "";
@state() private _loaded = false;
@state() private _busy = false;
@state() private _error = "";
@@ -389,15 +392,23 @@ export class MaintenanceDocumentsSection extends LitElement {
`
: nothing}
${this._loaded && this._docs.length >= DOC_FILTER_MIN
? html`<div class="doc-filter">
<ha-icon icon="mdi:magnify"></ha-icon>
<input type="search" aria-label=${t("doc_search", L)} placeholder=${t("doc_search", L)}
.value=${this._filter} @input=${(e: Event) => (this._filter = (e.target as HTMLInputElement).value)} />
</div>`
: nothing}
${!this._loaded
? html`<div class="doc-empty">${t("loading", L)}</div>`
: this._docs.length === 0
? html`<div class="doc-empty">${t("documents_empty", L)}</div>`
: html`
<div class="doc-list">
${this._docs.map((doc) => this._renderDoc(doc, L))}
</div>
`}
: (() => {
const shown = filterDocuments(this._docs, this._filter);
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>`;
})()}
${this._lightboxUrl
? html`<div class="lightbox" @click=${() => (this._lightboxUrl = "")}>
@@ -561,6 +572,14 @@ export class MaintenanceDocumentsSection extends LitElement {
.doc-msg.error { color: var(--error-color, #f44336); }
.doc-msg.hint { color: var(--secondary-text-color, #888); }
.doc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 0; }
.doc-filter { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }
.doc-filter ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); }
.doc-filter input {
flex: 1; min-width: 0; font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 6px;
border: 1px solid var(--divider-color); background: var(--card-background-color, #fff);
color: var(--primary-text-color);
}
.doc-filter input:focus { outline: none; border-color: var(--primary-color); }
.doc-list { display: flex; flex-direction: column; gap: 4px; }
.doc-row {
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
@@ -1,5 +1,5 @@
/** Dialog for editing an existing history entry (timestamp / notes / cost /
* duration / completed_by). Backed by maintenance_supporter/task/history/update.
* duration / completed_by / photos). Backed by maintenance_supporter/task/history/update.
*
* Opened from:
* - Task detail page → history tab → Edit button per entry
@@ -9,10 +9,16 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, langOf } from "../styles";
import type { HomeAssistant } from "../types";
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 "./ms-date-field";
import "./history-photo";
export interface HistoryEntryDraft {
entry_id: string;
@@ -27,6 +33,19 @@ export interface HistoryEntryDraft {
// #130: the entry's recorded part consumption ({part_id, name, quantity,
// entry_id? for pooled parts}); absent/empty = nothing consumed.
used_parts?: Array<{ part_id: string; name?: string; quantity: number; entry_id?: string }> | null;
// #161: the entry's completion photos (callers fold the pre-multi-photo
// `photo_doc_id` scalar in via helpers/history-photos.historyPhotoIds).
photo_doc_ids?: string[] | null;
// #161 phase 2: the entry's readings — the v2.20 scalar and/or the
// per-slot snapshot — plus the task's current slots, so a meter that
// was skipped at completion can be filled in afterwards. task_type /
// reading_unit decide whether the scalar field shows on an entry
// that recorded nothing.
reading_value?: number | null;
reading_values?: ReadingValue[] | null;
readings?: ReadingSlot[] | null;
task_type?: string | null;
reading_unit?: string | null;
}
/** One selectable part option in the edit dialog — the object's own parts
@@ -60,6 +79,21 @@ 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[] = [];
private _photosOriginal = "";
// #161 phase 2: the reading rows (entry snapshot ∪ task slots) with the
// typed text per slot id; the original numbers as a JSON key.
@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)
* so the backend can find the entry. */
@@ -71,9 +105,83 @@ 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._seedReadings(draft);
void this._loadPartOptions();
}
private _seedReadings(draft: HistoryEntryDraft): void {
// Task slot order first (matches the complete dialog), then snapshot
// slots the task no longer has — with the entry's own name/unit.
const rows: ReadingSlot[] = [];
const seen = new Set<string>();
for (const s of draft.readings ?? []) {
if (seen.has(s.id)) continue;
seen.add(s.id);
rows.push({ id: s.id, name: s.name, unit: s.unit ?? null });
}
for (const v of draft.reading_values ?? []) {
if (seen.has(v.id)) continue;
seen.add(v.id);
rows.push({ id: v.id, name: v.name, unit: v.unit ?? null });
}
this._readingRows = rows;
const text: Record<string, string> = {};
for (const v of draft.reading_values ?? []) text[v.id] = String(v.value);
this._readingText = text;
this._readingsOriginal = JSON.stringify(this._readingNumbers());
}
/** {slot id: number} for every row with a parsable value. */
private _readingNumbers(): Record<string, number> {
const out: Record<string, number> = {};
for (const row of this._readingRows) {
const raw = (this._readingText[row.id] ?? "").trim();
if (raw === "") continue;
const num = parseFloat(raw.replace(",", "."));
if (!isNaN(num)) out[row.id] = num;
}
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> {
@@ -140,6 +248,12 @@ 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);
}
}
private _set<K extends keyof HistoryEntryDraft>(
@@ -188,6 +302,22 @@ export class MaintenanceHistoryEditDialog extends LitElement {
...(o.foreign ? { entry_id: o.entry_id } : {}),
}));
}
// #161 phase 2: readings. The scalar rides the changed-field check
// like notes; the slot map is sent whole when any value differs —
// the backend replaces the snapshot, a missing id means "unread".
if (this._draft.reading_value !== this._originalSnapshot.reading_value) {
patch.reading_value = this._draft.reading_value ?? null;
}
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
const numbers = this._readingNumbers();
const map: Record<string, number | null> = {};
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
patch.reading_values = map;
}
// #161: the photo list, only when it differs from what we opened with.
if (JSON.stringify(this._photos) !== this._photosOriginal) {
patch.photo_doc_ids = [...this._photos];
}
// Nothing changed → close without WS call
const changedKeys = Object.keys(patch).filter(
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
@@ -197,6 +327,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
return;
}
await this.hass.connection.sendMessagePromise(patch);
this._uploadedIds = []; // saved — they belong to the entry now
// Notify upstream so they can refresh
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
@@ -273,6 +404,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
}} />
</label>
</div>
${this._renderReadings(d, L)}
${this._partOptions && this._partOptions.length > 0 ? html`
<div class="parts-block">
<span class="parts-title">${t("complete_parts_used", L)}</span>
@@ -300,6 +432,27 @@ export class MaintenanceHistoryEditDialog extends LitElement {
})}
</div>
` : nothing}
<div class="photos-block">
<span class="parts-title">${t("completion_photos", L)}</span>
${this._photos.length > 0 ? html`
<div class="photo-strip">
${this._photos.map((id) => html`
<div class="photo-tile">
<maintenance-history-photo .hass=${this.hass} .docId=${id}></maintenance-history-photo>
<button type="button" class="photo-remove" title=${t("remove", L)}
@click=${() => this._removePhoto(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>`}
<span class="photos-hint">${t("history_edit_photos_hint", L)}</span>
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="actions">
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
@@ -313,6 +466,39 @@ export class MaintenanceHistoryEditDialog extends LitElement {
`;
}
/** #161 phase 2: per-slot rows for a slot task (entry snapshot ∪ current
* slots), else the scalar field on a reading task / an entry that has one. */
private _renderReadings(d: HistoryEntryDraft, L: string) {
if (this._readingRows.length > 0) {
return html`
<div class="readings-block">
<span class="parts-title">${t("readings_section", L)}</span>
${this._readingRows.map((row) => html`
<label class="reading-row-edit">
<span class="reading-row-name">${row.name}${row.unit ? ` (${row.unit})` : ""}</span>
<input type="text" inputmode="decimal" class="reading-row-input"
.value=${this._readingText[row.id] ?? ""}
@input=${(e: Event) => {
this._readingText = { ...this._readingText, [row.id]: (e.target as HTMLInputElement).value };
}} />
</label>
`)}
</div>`;
}
if (d.reading_value == null && d.task_type !== "reading") return nothing;
return html`
<label>
<span>${t("reading_value_label", L)}${d.reading_unit ? ` (${d.reading_unit})` : ""}</span>
<input type="number" step="any"
.value=${d.reading_value != null ? String(d.reading_value) : ""}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
const num = v === "" ? NaN : Number(v);
this._set("reading_value", isNaN(num) ? null : num);
}} />
</label>`;
}
static styles = css`
:host { display: contents; }
.backdrop {
@@ -389,6 +575,42 @@ export class MaintenanceHistoryEditDialog extends LitElement {
.part-row-edit input[type="checkbox"] { width: auto; }
.part-label { flex: 1; color: var(--primary-text-color); }
.part-qty { width: 76px; }
/* #161 phase 2: readings on the entry */
.readings-block {
display: flex; flex-direction: column; gap: 6px;
border: 1px solid var(--divider-color, #444);
border-radius: 6px; padding: 8px;
}
.reading-row-edit {
display: flex; flex-direction: row; align-items: center; gap: 8px;
font-size: 14px;
}
.reading-row-name { flex: 1; color: var(--primary-text-color); min-width: 0; }
.reading-row-input { width: 140px; font-variant-numeric: tabular-nums; }
/* #161: photos on the entry */
.photos-block {
display: flex; flex-direction: column; gap: 6px;
border: 1px solid var(--divider-color, #444);
border-radius: 6px; padding: 8px;
}
.photo-strip { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
.photo-tile { position: relative; width: fit-content; }
.photo-remove {
position: absolute; top: -4px; right: -8px;
width: 22px; height: 22px; border-radius: 50%; border: none;
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; }
.photos-hint { font-size: 12px; color: var(--secondary-text-color); }
`;
}
@@ -48,16 +48,20 @@ export class MaintenanceHistoryPhoto extends LitElement {
static styles = css`
.wrap { display: inline-block; margin-top: 4px; }
/* #161: uniform 96px tiles — several photos sit in a strip, so a
tiny or portrait shot must not collapse its slot. */
img {
max-width: 96px;
max-height: 96px;
width: 96px;
height: 96px;
object-fit: cover;
border-radius: 6px;
display: block;
border: 1px solid var(--divider-color);
box-sizing: border-box;
}
.ph {
width: 96px;
height: 64px;
height: 96px;
border-radius: 6px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
margin-top: 4px;
@@ -5,12 +5,14 @@ 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 } from "../styles";
import { t, langOf, personStyles } from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
import { invalidateSettingsCache } from "../helpers/settings-cache";
import "./ms-date-field";
/** One household member and the notify services they actually resolve to.
@@ -26,6 +28,8 @@ interface PersonNotifyTarget {
interface SettingsResponse {
features: AdvancedFeatures;
admin_panel_user_ids?: string[];
/** #169 follow-up: per-member avatar overrides. */
member_display?: Record<string, { initials?: string; color?: string }>;
operator_write_enabled?: boolean;
objects_table_columns?: string[];
/** v2.21: template-gallery curation — hidden template ids. */
@@ -73,6 +77,9 @@ interface SettingsResponse {
bundle_threshold: number;
reminder_lead_days: number[];
scope_view_id: string;
/** #165: your own notification rule — event-only delivery + extra-data template. */
event_only?: boolean;
extra_data?: string;
};
actions: {
complete_enabled: boolean;
@@ -117,6 +124,8 @@ interface VacationPreviewRow {
confidence: "deterministic" | "estimated" | "unpredictable";
events: Array<{ date: string; status: "due_soon" | "overdue" | "triggered_est" }>;
will_suppress: boolean;
/** The task's own skip rule (#150); absent on older backends = allowed. */
allow_skip?: boolean;
}
// Keep in lockstep with const.BUDGET_CURRENCIES (keys + order). Parity is
@@ -269,11 +278,18 @@ export class MaintenanceSettingsView extends LitElement {
.replace("{n}", String(bn.devices));
const rule = (warn ? t("bn_above_floor", L).replace("{name}", "Battery Notes") : t("bn_floor_decides", L))
.replace("{floor}", String(floor));
// The integration name becomes the link, the rest stays text. ja/zh
// punctuate with the fullwidth colon — an ASCII-only split linked the
// whole sentence and then duplicated its last character (bug review
// 2026-09-04).
const cut = summary.search(/[::]/);
const linkText = cut < 0 ? summary : summary.slice(0, cut);
const rest = cut < 0 ? "" : summary.slice(cut);
return html`
<div class="bn-note${warn ? " warn" : ""}">
<ha-icon icon="${warn ? "mdi:alert-outline" : "mdi:battery-heart-variant"}"></ha-icon>
<span>
<a class="bn-link" href="/config/integrations/integration/battery_notes">${summary.split(":")[0]}</a>${summary.slice(summary.indexOf(":"))}
<a class="bn-link" href="/config/integrations/integration/battery_notes">${linkText}</a>${rest}
${bn.overrides.length
? html` · ${t("bn_overrides", L).replace("{n}", String(bn.overrides.length + bn.more))}
${bn.overrides.map((o, i) => html`${i ? " · " : " "}${o.device_id
@@ -286,6 +302,62 @@ export class MaintenanceSettingsView extends LitElement {
</div>`;
}
/** #169 follow-up: Settings → household members — the avatar each member
* gets in task lists (initials + a palette colour). Defaults come from
* the backend (name / user id); an override is stored per user id. */
private _renderMemberAvatars(L: string) {
if (!this._users.length) return nothing;
const overrides = this._settings?.member_display || {};
return html`
<div class="settings-section member-avatars" data-section="member_avatars">
<h3>${t("member_avatars", L)}</h3>
<p class="section-desc">${t("member_avatars_hint", L)}</p>
${this._users.map((u) => {
const person = personOf(u)!;
const override = overrides[u.id] || {};
return html`
<div class="member-avatar-row">
${renderPersonAvatar(person)}
<span class="member-avatar-name">${u.name}</span>
<input class="member-initials" type="text" maxlength="3" autocomplete="off"
aria-label="${t("member_initials", L)}"
placeholder="${person.initials}"
.value=${override.initials ?? ""}
@change=${(e: Event) => this._setMemberAvatar(u.id, { initials: (e.target as HTMLInputElement).value })} />
<span class="member-palette" role="radiogroup" aria-label="${t("member_color", L)}">
${AVATAR_PALETTE.map((color) => html`
<button type="button" class="member-swatch${person.color === color ? " selected" : ""}"
style="--person-color: ${color}" title=${color} aria-label=${color}
@click=${() => this._setMemberAvatar(u.id, { color })}></button>`)}
</span>
${override.initials || override.color
? html`<button class="ha-button secondary member-reset" @click=${() => this._setMemberAvatar(u.id, null)}>${t("member_avatar_reset", L)}</button>`
: nothing}
</div>`;
})}
</div>`;
}
/** Merge one member's override into the map and save; null resets the member. */
private async _setMemberAvatar(userId: string, patch: { initials?: string; color?: string } | null): Promise<void> {
const current = { ...(this._settings?.member_display || {}) };
if (patch === null) {
delete current[userId];
} else {
const next = { ...(current[userId] || {}), ...patch };
if ("initials" in patch && !(patch.initials || "").trim()) delete next.initials;
if (Object.keys(next).length) current[userId] = next;
else delete current[userId];
}
await this._updateSetting("member_display", current);
// The avatars come back resolved through users/list — refresh the cache.
try {
this._users = (await this._userService?.getUsers(true)) ?? this._users;
} catch {
/* keep the stale list; the next open reloads it */
}
}
private async _updateSetting(key: string, value: unknown): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
@@ -293,6 +365,9 @@ export class MaintenanceSettingsView extends LitElement {
settings: { [key]: value },
});
this._settings = result as SettingsResponse;
// 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 {
@@ -329,6 +404,23 @@ export class MaintenanceSettingsView extends LitElement {
setTimeout(() => { this._toast = ""; }, 3000);
}
/** 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. */
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);
return;
}
this._showToast(
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
);
input.value = String(current);
}
private _downloadFile(content: string, filename: string, mime: string): void {
// Companion-app safe download (target=_blank + DOM + deferred revoke).
downloadTextFile(content, filename, mime);
@@ -345,6 +437,7 @@ export class MaintenanceSettingsView extends LitElement {
return html`
${this._renderFeatures(L)}
${this._renderPanelAccess(L)}
${this._renderMemberAvatars(L)}
${this._renderGeneral(L)}
${this._renderObjectsColumns(L)}
${this._settings.general.notifications_enabled ? this._renderNotifications(L) : nothing}
@@ -632,28 +725,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=${String(g.default_warning_days)}
@change=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
<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.
if (v >= 0 && v <= 365) this._updateSetting("default_warning_days", v);
}} />
this._onBoundedIntChange(e, "default_warning_days", 0, 365, 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=${String(g.default_consumable_threshold ?? 10)}
@change=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
if (v >= 1 && v <= 90) this._updateSetting("default_consumable_threshold", v);
}} />
<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)} />
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_battery_low_percent", L)}</span>
<input type="number" min="1" max="90" .value=${String(g.battery_low_percent ?? 20)}
@change=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
if (v >= 1 && v <= 90) this._updateSetting("battery_low_percent", v);
}} />
<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)} />
</label>
${this._renderBatteryNotesHint(L)}
<div class="setting-hint">${t("settings_thresholds_hint", L)}</div>
@@ -884,6 +971,25 @@ export class MaintenanceSettingsView extends LitElement {
</label>
<div class="setting-hint">${t("settings_notify_scope_hint", L)}</div>
<div class="notify-rule">
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_notify_rule", L)}</h4>
<div class="setting-hint">${t("settings_notify_rule_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notify_event_only", L)}</span>
<input type="checkbox" .checked=${!!n.event_only}
@change=${(e: Event) => this._updateSetting("notify_event_only", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_notify_event_only_hint", L)}</div>
<label class="setting-row setting-row-block">
<span class="setting-label">${t("settings_notify_extra_data", L)}</span>
<textarea class="import-area notify-extra" .value=${live(n.extra_data || "")} maxlength="2000" spellcheck="false"
placeholder=${'{"category": "maintenance", "critical": {{ priority == "high" }}, "navigate_to": "{{ url }}"}'}
@change=${(e: Event) => this._updateSetting("notify_extra_data", (e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
<div class="setting-hint">${t("settings_notify_extra_data_hint", L)}</div>
</div>
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_actions", L)}</h4>
<label class="setting-row">
<span class="setting-label">${t("settings_action_complete", L)}</span>
@@ -1128,7 +1234,7 @@ export class MaintenanceSettingsView extends LitElement {
</div>
<div class="vac-preview-actions">
<button @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
${row.kind === "time_based"
${row.kind === "time_based" && row.allow_skip !== false
? html`<button @click=${() => this._previewActionSkip(row)}>${t("qr_action_skip", L)}</button>`
: nothing}
<button class=${isExempt ? "vac-notify-on" : ""}
@@ -1761,7 +1867,7 @@ export class MaintenanceSettingsView extends LitElement {
// --- Styles ---
static styles = css`
static styles = [personStyles, css`
.bn-note {
display: flex; align-items: flex-start; gap: 10px;
margin: 6px 0 10px; padding: 10px 12px; border-radius: 8px;
@@ -1836,6 +1942,21 @@ export class MaintenanceSettingsView extends LitElement {
.notify-person-target.muted {
font-style: italic;
}
/* #169 follow-up: member avatar editor */
.member-avatar-row { display: flex; align-items: center; gap: 10px; padding: 6px 0 0; flex-wrap: wrap; }
.member-avatar-name { font-weight: 500; min-width: 120px; }
.member-initials {
width: 4.5em; padding: 4px 6px; text-align: center; text-transform: uppercase;
border: 1px solid var(--divider-color, #e0e0e0); border-radius: 6px;
background: var(--card-background-color); color: var(--primary-text-color); font: inherit;
}
.member-palette { display: inline-flex; gap: 4px; flex-wrap: wrap; }
.member-swatch {
width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent; padding: 0;
background: var(--person-color); cursor: pointer;
}
.member-swatch.selected { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--card-background-color) inset; }
.member-reset { --ha-button-font-size: 12px; }
/* v2.27: template gallery clustered by category */
.tpl-group { margin-top: 14px; }
.tpl-group-head {
@@ -1927,6 +2048,8 @@ export class MaintenanceSettingsView extends LitElement {
.import-section { margin-top: 16px; }
.setting-row-block { flex-direction: column; align-items: stretch; gap: 6px; }
.notify-extra { min-height: 72px; font-family: var(--code-font-family, monospace); font-size: 12.5px; }
.import-area {
width: 100%;
min-height: 120px;
@@ -2259,7 +2382,7 @@ export class MaintenanceSettingsView extends LitElement {
.qr-print-cell .qr-svg { max-width: 48mm; }
.qr-label { font-size: 9pt; }
}
`;
`];
}
customElements.define("maintenance-settings-view", MaintenanceSettingsView);
@@ -23,6 +23,8 @@ interface StorageSummary {
link_count: number;
document_count: number;
by_object: Record<string, { bytes: number; files: number; links: number }>;
/** #171: how much of the library the full-text search can see. */
search_index?: { total: number; indexed: number; no_text: number; unsupported: number; pending: number };
}
interface PanelObject {
@@ -221,6 +223,16 @@ export class MaintenanceStorageSectionCard extends LitElement {
: nothing}
</div>
${s.search_index && s.search_index.total > 0
? html`<div class="index-status">
<ha-icon icon="mdi:text-search"></ha-icon>
${t("search_index_status", L)
.replace("{indexed}", String(s.search_index.indexed))
.replace("{total}", String(s.search_index.total))
.replace("{no_text}", String(s.search_index.no_text + s.search_index.unsupported))
.replace("{pending}", String(s.search_index.pending))}
</div>`
: nothing}
<div class="doc-search">
<ha-icon icon="mdi:magnify"></ha-icon>
<input
@@ -284,6 +296,11 @@ export class MaintenanceStorageSectionCard extends LitElement {
static styles = css`
ha-card { margin-top: 16px; }
.card-content { padding: 16px; }
.index-status {
display: flex; align-items: center; gap: 6px; margin: 10px 0 0; font-size: 12.5px;
color: var(--secondary-text-color, #888);
}
.index-status ha-icon { --mdc-icon-size: 18px; }
.doc-search {
display: flex; align-items: center; gap: 6px; margin: 10px 0 4px;
padding: 2px 10px; border-radius: 8px;
@@ -2,10 +2,11 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, t, weekdayName, monthName, langOf } from "../styles";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, ReadingSlot, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, formatNumber, t, weekdayName, monthName, langOf } from "../styles";
import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import { MAX_READING_SLOTS, cleanReadingSlots, duplicateReadingSlotIds, newReadingSlotId } from "../helpers/reading-slots";
import {
ENVIRONMENTAL_PICKER_DEVICE_CLASSES,
ENVIRONMENTAL_PICKER_DOMAINS,
@@ -21,6 +22,9 @@ const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibra
const PRIORITY_KEYS = ["low", "normal", "high"];
const SCHEDULE_TYPE_KEYS = ["time_based", "weekdays", "nth_weekday", "day_of_month", "sensor_based", "one_time", "manual"];
const CALENDAR_KINDS = ["weekdays", "nth_weekday", "day_of_month"];
// #168: kinds that produce a due DATE and therefore take a time of day
// (mirrors SCHEDULE_TIME_KINDS in config_flow_helpers.py).
const SCHEDULE_TIME_KINDS = ["time_based", "one_time", ...CALENDAR_KINDS];
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
// The type selector additionally offers "compound" (a group of conditions
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
@@ -249,6 +253,8 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _allowSkip = true;
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
@state() private _readingUnit = "";
// #161 phase 2: named reading slots — several values per completion.
@state() private _readings: ReadingSlot[] = [];
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
* since the same part id can exist on two objects (battery fleet). */
@state() private _consumesParts: Record<string, TaskPartLink> = {};
@@ -285,6 +291,9 @@ export class MaintenanceTaskDialog extends LitElement {
// Schedule time (HH:MM, advanced feature)
@state() private _scheduleTime = "";
// The picker only shows once this is ticked (#168 follow-up) — most tasks
// are due "on the day", so the form stays lean. Unticking clears the time.
@state() private _scheduleTimeOn = false;
// v1.3.0: on_complete_action (gated by completionActionsEnabled)
// v1.3.1: _actionData is the parsed object (was: _actionDataJson string)
@@ -404,6 +413,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._requireTagScan = !!task.require_tag_scan;
this._allowSkip = task.allow_skip !== false;
this._readingUnit = task.reading_unit || "";
this._readings = (task.readings || []).map((s) => ({ ...s }));
// Whole link, entry_id included — hydrating only part_id would turn every
// shared-pool link into an own-part link on the next save (#111).
this._consumesParts = Object.fromEntries(
@@ -443,6 +453,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._phaseSeq = [...(task.phase_sequence || [])];
this._requiredCompletion = [...(task.required_completion_fields || [])];
this._scheduleTime = task.schedule_time || "";
this._scheduleTimeOn = !!task.schedule_time;
// v1.3.0: hydrate on_complete_action + quick_complete_defaults
const oca = task.on_complete_action;
@@ -557,6 +568,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._requireTagScan = false;
this._allowSkip = true;
this._readingUnit = "";
this._readings = [];
this._consumesParts = {};
this._responsibleUserId = null;
this._assigneePool = [];
@@ -566,6 +578,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._phaseSeq = [];
this._requiredCompletion = [];
this._scheduleTime = "";
this._scheduleTimeOn = false;
this._environmentalEntity = "";
this._environmentalAttribute = "";
this._environmentalInitial = "";
@@ -1052,6 +1065,47 @@ export class MaintenanceTaskDialog extends LitElement {
this._requiredCompletion = [...next];
}
// ── Reading slots (#161 phase 2) ─────────────────────────────────────
private _patchReading(id: string, patch: Partial<ReadingSlot>): void {
this._readings = this._readings.map((s) => (s.id === id ? { ...s, ...patch } : s));
}
private _renderReadingsEditor(L: string) {
const dupes = duplicateReadingSlotIds(this._readings);
return html`
<div class="readings-editor">
<div class="field-label">${t("readings_section", L)}</div>
<div class="field-help">${t("readings_hint", L)}</div>
${this._readings.map((s) => html`
${dupes.has(s.id) ? html`<div class="field-help reading-dup">${t("reading_duplicate_name", L)}</div>` : nothing}
<div class="reading-row">
<ms-textfield
class="reading-name"
label="${t("reading_name_label", L)}"
.value=${s.name}
@input=${(e: Event) => this._patchReading(s.id, { name: (e.target as HTMLInputElement).value })}
></ms-textfield>
<ms-textfield
class="reading-unit"
label="${t("reading_unit_short", L)}"
.value=${s.unit || ""}
@input=${(e: Event) => this._patchReading(s.id, { unit: (e.target as HTMLInputElement).value })}
></ms-textfield>
<mwc-icon-button class="phase-remove reading-remove" @click=${() => (this._readings = this._readings.filter((x) => x.id !== s.id))}>
<ha-icon icon="mdi:delete-outline"></ha-icon>
</mwc-icon-button>
</div>
`)}
${this._readings.length < MAX_READING_SLOTS ? html`
<ha-button appearance="plain" class="reading-add"
@click=${() => (this._readings = [...this._readings, { id: newReadingSlotId(), name: "", unit: this._readings.length ? this._readings[this._readings.length - 1].unit : this._readingUnit }])}>
<ha-icon icon="mdi:plus"></ha-icon> ${t("reading_add", L)}
</ha-button>` : nothing}
</div>
`;
}
// ── Task phases (#139) ───────────────────────────────────────────────
private _phaseSlug(name: string): string {
@@ -1265,6 +1319,10 @@ export class MaintenanceTaskDialog extends LitElement {
data.require_tag_scan = this._requireTagScan;
data.allow_skip = this._allowSkip;
data.reading_unit = this._readingUnit.trim() || null;
// #161 phase 2: always sent — [] clears the slots (single-value task).
// Sent as hydrated even when the editor is hidden (type != reading), so
// a no-op edit never wipes stored slots (the #42/#50 wipe class).
data.readings = cleanReadingSlots(this._readings);
// Task phases (#139): always sent — null clears a removed cycle.
{
const defs: Record<string, unknown> = {};
@@ -1388,9 +1446,11 @@ export class MaintenanceTaskDialog extends LitElement {
data.trigger_config = null;
}
// Schedule time only sent when feature is enabled — empty string clears.
if (this.scheduleTimeEnabled && this._scheduleType === "time_based") {
const t = this._scheduleTime.trim();
// Schedule time only sent when feature is enabled — null clears.
// Every date-driven kind takes one (#168), not only the interval; an
// unticked checkbox (or a ticked one with an empty picker) clears it.
if (this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType)) {
const t = this._scheduleTimeOn ? this._scheduleTime.trim() : "";
data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
}
@@ -2103,7 +2163,7 @@ export class MaintenanceTaskDialog extends LitElement {
private _renderSchedulePreview() {
if (this._schedulePreview.length === 0) return nothing;
const L = this._lang;
const time = this.scheduleTimeEnabled && this._scheduleTime ? ` ${this._scheduleTime}` : "";
const time = this.scheduleTimeEnabled && this._scheduleTimeOn && this._scheduleTime ? ` ${this._scheduleTime}` : "";
const chips = this._schedulePreview
.map((iso, i) => {
const js = new Date(`${iso}T12:00:00`).getDay(); // 0=Sun
@@ -2331,7 +2391,9 @@ export class MaintenanceTaskDialog extends LitElement {
: st.state;
const num = typeof raw === "number" ? raw : parseFloat(String(raw));
const hasNum = raw !== "unknown" && raw !== "unavailable" && raw != null && !isNaN(num);
const fmt = (v: number) => (Number.isInteger(v) ? String(v) : String(Math.round(v * 10) / 10));
// Profile number format like every other figure (#163) — the hint used
// to print "1.2" next to a "1,2 bar" entity card (bug review 2026-09-04).
const fmt = (v: number) => formatNumber(v, L, { maximumFractionDigits: 1 });
const parts: string[] = [];
if (this._triggerType === "threshold") {
@@ -2487,6 +2549,9 @@ export class MaintenanceTaskDialog extends LitElement {
@input=${(e: Event) => (this._triggerTargetChanges = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">${t("target_changes_help", L)}</div>
${(this._triggerTargetChanges || "1") === "1" && (this._triggerFromState || this._triggerToState)
? html`<div class="field-help">${t("state_latch_help", L)}</div>`
: nothing}
<ms-textfield
label="${t("for_at_least_minutes", L)}"
type="number"
@@ -2579,6 +2644,7 @@ export class MaintenanceTaskDialog extends LitElement {
@input=${(e: Event) => (this._readingUnit = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">${t("reading_unit_help", L)}</div>
${this._renderReadingsEditor(L)}
`
: nothing}
${this._partsLoadFailed
@@ -2659,18 +2725,6 @@ export class MaintenanceTaskDialog extends LitElement {
<option value="planned" ?selected=${this._intervalAnchor === "planned"}>${t("anchor_planned", L)}</option>
</select>
</div>
${this.scheduleTimeEnabled ? html`
<ms-date-field
kind="time"
clearable
.hass=${this.hass}
.lang=${L}
label="${t("schedule_time_optional", L)}"
.value=${this._scheduleTime}
helper="${t("schedule_time_help", L)}"
@value-changed=${(e: CustomEvent) => (this._scheduleTime = e.detail.value as string)}
></ms-date-field>
` : nothing}
`
: nothing}
${this._renderCalendarFields()}
@@ -2686,6 +2740,23 @@ export class MaintenanceTaskDialog extends LitElement {
></ms-date-field>
`
: nothing}
${this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType) ? html`
<label class="checkbox-row schedule-time-toggle">
<input type="checkbox" .checked=${this._scheduleTimeOn}
@change=${(e: Event) => (this._scheduleTimeOn = (e.target as HTMLInputElement).checked)} />
<span>${t("schedule_time_toggle", L)}</span>
</label>
${this._scheduleTimeOn ? html`
<ms-date-field
kind="time"
.hass=${this.hass}
.lang=${L}
.value=${this._scheduleTime}
helper="${t("schedule_time_help", L)}"
@value-changed=${(e: CustomEvent) => (this._scheduleTime = e.detail.value as string)}
></ms-date-field>
` : nothing}
` : nothing}
${this._renderRecurrenceExtras()}
${this._renderSchedulePreview()}
<ms-textfield
@@ -3067,6 +3138,17 @@ export class MaintenanceTaskDialog extends LitElement {
--mdc-icon-button-size: 36px;
color: var(--secondary-text-color);
}
/* #161 phase 2: reading slots — name wide, unit narrow, trash. */
.readings-editor { margin-top: 14px; }
.reading-row {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
}
.reading-row .reading-name { flex: 2; min-width: 0; }
.reading-row .reading-unit { flex: 1; min-width: 0; max-width: 140px; }
.reading-dup { color: var(--warning-color, #ff9800); margin-top: 6px; }
.phase-seq-label {
font-size: 12px;
color: var(--secondary-text-color);
@@ -3214,6 +3296,10 @@ export class MaintenanceTaskDialog extends LitElement {
height: 16px;
cursor: pointer;
}
.schedule-time-toggle {
display: flex;
margin: 8px 0 4px;
}
.pool-item {
display: inline-flex;
align-items: center;
@@ -18,6 +18,7 @@ import { downloadSignedDocument, openSignedDocument } from "../helpers/document-
import { isSafeHttpUrl } from "../helpers/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 type { HomeAssistant } from "../types";
interface Doc {
@@ -47,6 +48,8 @@ export class MaintenanceTaskDocuments extends LitElement {
@state() private _busy = false;
@state() private _error = "";
@state() private _attachId = "";
// Local filter (#171) — shown once the linked list is long enough to need one.
@state() private _filter = "";
private _loadedKey = "";
private _localeReady = false;
@@ -199,9 +202,21 @@ export class MaintenanceTaskDocuments extends LitElement {
<div class="task-docs">
<h3><ha-icon icon="mdi:paperclip"></ha-icon> ${t("documents", L)} (${linked.length})</h3>
${this._error ? html`<div class="tdoc-error">${this._error}</div>` : nothing}
${linked.length >= DOC_FILTER_MIN
? html`<div class="doc-filter">
<ha-icon icon="mdi:magnify"></ha-icon>
<input type="search" aria-label=${t("doc_search", L)} placeholder=${t("doc_search", L)}
.value=${this._filter} @input=${(e: Event) => (this._filter = (e.target as HTMLInputElement).value)} />
</div>`
: nothing}
${linked.length === 0
? html`<div class="tdoc-empty">${t(this.partId ? "doc_part_none" : "doc_task_none", L)}</div>`
: html`<div class="tdoc-list">${linked.map((d) => this._renderRow(d, L))}</div>`}
: (() => {
const shown = filterDocuments(linked, this._filter);
return shown.length === 0
? html`<div class="tdoc-empty">${t("doc_search_none", L)}</div>`
: html`<div class="tdoc-list">${shown.map((d) => this._renderRow(d, L))}</div>`;
})()}
${this.canWrite && available.length
? html`<div class="tdoc-attach">
<select
@@ -295,6 +310,14 @@ export class MaintenanceTaskDocuments extends LitElement {
.tdoc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 2px 0 8px; }
.tdoc-error { color: var(--error-color, #f44336); font-size: 13px; margin: 4px 0; }
.tdoc-list { display: flex; flex-direction: column; gap: 4px; }
.doc-filter { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }
.doc-filter ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); }
.doc-filter input {
flex: 1; min-width: 0; font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 6px;
border: 1px solid var(--divider-color); background: var(--card-background-color, #fff);
color: var(--primary-text-color);
}
.doc-filter input:focus { outline: none; border-color: var(--primary-color); }
.tdoc-row {
display: flex; align-items: center; gap: 10px; padding: 6px 10px;
border: 1px solid var(--divider-color); border-radius: 8px;
@@ -19,6 +19,9 @@ 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 { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
@@ -358,6 +361,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
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,
});
});
}
@@ -439,6 +448,30 @@ 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). */
@@ -500,6 +533,15 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
${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}
@@ -830,6 +872,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
.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-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