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
@@ -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;