Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TriggerConfig, HAUser } from "../types";
import { t, weekdayName } from "../styles";
import { formatDate, t, weekdayName } from "../styles";
import { UserService } from "../user-service";
import { describeWsError } from "../ws-errors";
@@ -32,16 +32,30 @@ interface CompoundConditionDraft {
toState: string;
targetChanges: string;
runtimeHours: string;
onStates: string;
/** Original keys this editor has no fields for (attribute, baseline, ...).
* Spread back on save so a compound roundtrip never drops them (#103 class). */
carry: Partial<TriggerConfig>;
}
function emptyCondition(): CompoundConditionDraft {
return {
entityIds: "", type: "threshold", above: "", below: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "",
targetChanges: "", runtimeHours: "",
targetChanges: "", runtimeHours: "", onStates: "", carry: {},
};
}
/** Keys the compound editor owns via its own form fields — everything else
* travels through `carry` untouched. */
const MANAGED_CONDITION_KEYS = new Set([
"entity_id", "entity_ids", "type",
"trigger_above", "trigger_below", "trigger_for_minutes",
"trigger_target_value", "trigger_delta_mode",
"trigger_from_state", "trigger_to_state", "trigger_target_changes",
"trigger_runtime_hours", "trigger_on_states",
]);
/** Map a persisted compound condition (storage shape) to an editable draft. */
function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []);
@@ -57,6 +71,10 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
toState: c.trigger_to_state || "",
targetChanges: c.trigger_target_changes?.toString() ?? "",
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
onStates: (c.trigger_on_states || []).join(", "),
carry: Object.fromEntries(
Object.entries(c).filter(([k]) => !MANAGED_CONDITION_KEYS.has(k) && !k.startsWith("_")),
) as Partial<TriggerConfig>,
};
}
@@ -65,7 +83,7 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null {
const ids = d.entityIds.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) return null;
const c: TriggerConfig = { entity_id: ids[0], entity_ids: ids, type: d.type };
const c: TriggerConfig = { ...(d.carry || {}), entity_id: ids[0], entity_ids: ids, type: d.type };
if (d.type === "threshold") {
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
@@ -79,6 +97,8 @@ function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null {
const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n;
} else if (d.type === "runtime") {
const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h;
const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean);
if (on.length > 0) c.trigger_on_states = on;
}
return c;
}
@@ -136,6 +156,12 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _endsMode: "never" | "count" | "until" = "never";
@state() private _endsCount = "";
@state() private _endsUntil = "";
// #83: live "next dates" preview — dates come from the BACKEND engine via
// schedule/preview (never a frontend reimplementation; the #103 lesson).
@state() private _schedulePreview: string[] = [];
@state() private _schedulePreviewEnded = false;
private _previewTimer: ReturnType<typeof setTimeout> | undefined;
private _previewSeq = 0;
@state() private _notes = "";
@state() private _documentationUrl = "";
@state() private _customIcon = "";
@@ -154,11 +180,22 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _triggerForMinutes = "0";
@state() private _triggerTargetValue = "";
@state() private _triggerDeltaMode = false;
@state() private _triggerBaselineValue = "";
// The LIVE counting anchor from the read-model (Store baseline — moves on
// completion). Display-only: adopted delta tasks have no config baseline,
// so without this the edit dialog would show an empty start-value field
// even though counting is anchored at e.g. 27,000 km.
@state() private _liveBaselineValue: number | null = null;
@state() private _autoCompleteOnRecovery = false;
@state() private _triggerFromState = "";
@state() private _triggerToState = "";
@state() private _triggerTargetChanges = "";
@state() private _triggerRuntimeHours = "";
// Comma-separated "running" states for the runtime trigger (#103) —
// empty = the backend default ["on"]. Must roundtrip on edit: adopted
// tasks ship e.g. ["mowing"], and dropping it on save silently stops
// the accumulation.
@state() private _triggerOnStates = "";
// Compound trigger (type === "compound"): a list of conditions + AND/OR logic
@state() private _compoundLogic: "AND" | "OR" = "AND";
@state() private _compoundConditions: CompoundConditionDraft[] = [];
@@ -320,7 +357,11 @@ export class MaintenanceTaskDialog extends LitElement {
if (task.trigger_config) {
const tc = task.trigger_config;
this._triggerEntityId = tc.entity_id || "";
// A trigger stored with only the plural entity_ids (e.g. the Battery
// Fleet task) must still hydrate the singular field — the save path
// gates on _triggerEntityId and would otherwise NULL the whole trigger
// on an unrelated edit (issue #106).
this._triggerEntityId = tc.entity_id || (tc.entity_ids && tc.entity_ids[0]) || "";
this._triggerEntityIds = tc.entity_ids || (tc.entity_id ? [tc.entity_id] : []);
this._triggerEntityLogic = tc.entity_logic || "any";
this._triggerAttribute = tc.attribute || "";
@@ -330,11 +371,14 @@ export class MaintenanceTaskDialog extends LitElement {
this._triggerForMinutes = tc.trigger_for_minutes?.toString() || "0";
this._triggerTargetValue = tc.trigger_target_value?.toString() || "";
this._triggerDeltaMode = tc.trigger_delta_mode || false;
this._triggerBaselineValue = tc.trigger_baseline_value?.toString() || "";
this._liveBaselineValue = task.trigger_baseline_value ?? null;
this._autoCompleteOnRecovery = tc.auto_complete_on_recovery || false;
this._triggerFromState = tc.trigger_from_state || "";
this._triggerToState = tc.trigger_to_state || "";
this._triggerTargetChanges = tc.trigger_target_changes?.toString() || "";
this._triggerRuntimeHours = tc.trigger_runtime_hours?.toString() || "";
this._triggerOnStates = (tc.trigger_on_states || []).join(", ");
if (tc.type === "compound") {
this._compoundLogic = tc.compound_logic === "OR" ? "OR" : "AND";
this._compoundConditions = (tc.conditions || []).map(conditionToDraft);
@@ -423,11 +467,14 @@ export class MaintenanceTaskDialog extends LitElement {
this._triggerForMinutes = "0";
this._triggerTargetValue = "";
this._triggerDeltaMode = false;
this._triggerBaselineValue = "";
this._liveBaselineValue = null;
this._autoCompleteOnRecovery = false;
this._triggerFromState = "";
this._triggerToState = "";
this._triggerTargetChanges = "";
this._triggerRuntimeHours = "";
this._triggerOnStates = "";
this._compoundLogic = "AND";
this._compoundConditions = [];
}
@@ -844,12 +891,21 @@ export class MaintenanceTaskDialog extends LitElement {
} else if (this._triggerType === "counter") {
if (this._triggerTargetValue) { const v = parseFloat(this._triggerTargetValue); if (!isNaN(v)) triggerConfig.trigger_target_value = v; }
triggerConfig.trigger_delta_mode = this._triggerDeltaMode;
// #102: optional counting start value ("last service was at X").
// Empty = count from the reading at creation / keep the live
// baseline; the backend clears stale Store state when it changes.
if (this._triggerDeltaMode && this._triggerBaselineValue) {
const b = parseFloat(this._triggerBaselineValue);
if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b;
}
} else if (this._triggerType === "state_change") {
if (this._triggerFromState) triggerConfig.trigger_from_state = this._triggerFromState;
if (this._triggerToState) triggerConfig.trigger_to_state = this._triggerToState;
if (this._triggerTargetChanges) { const v = parseInt(this._triggerTargetChanges, 10); if (!isNaN(v)) triggerConfig.trigger_target_changes = v; }
} else if (this._triggerType === "runtime") {
if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = v; }
const onStates = this._triggerOnStates.split(",").map((s) => s.trim()).filter(Boolean);
if (onStates.length > 0) triggerConfig.trigger_on_states = onStates;
}
data.trigger_config = triggerConfig;
@@ -1146,6 +1202,8 @@ export class MaintenanceTaskDialog extends LitElement {
return html`
<ms-textfield label="${t("runtime_hours", L)}" type="number" .value=${c.runtimeHours}
@input=${(e: Event) => this._patchCondition(i, { runtimeHours: (e.target as HTMLInputElement).value })}></ms-textfield>
<ms-textfield label="${t("runtime_on_states", L)}" placeholder="on" .value=${c.onStates}
@input=${(e: Event) => this._patchCondition(i, { onStates: (e.target as HTMLInputElement).value })}></ms-textfield>
`;
}
return nothing;
@@ -1175,6 +1233,99 @@ export class MaintenanceTaskDialog extends LitElement {
: [...this._weekdays, i];
}
/** The draft schedule in engine (Schedule.to_dict) form — MIRRORS the
* _save mapping; keep both in sync when adding schedule fields. Null =
* nothing to preview (manual, or trigger-only without an interval). */
private _previewScheduleDict(): Record<string, unknown> | null {
if (this._scheduleType === "one_time") {
return this._dueDate ? { kind: "one_time", due_date: this._dueDate } : null;
}
if (CALENDAR_KINDS.includes(this._scheduleType)) {
return { ...this._buildSchedule(), ...this._recurrenceExtras() };
}
const every = parseInt(this._intervalDays, 10);
if (this._scheduleType === "manual" || !every || every <= 0) return null;
return {
kind: "interval",
every,
unit: this._intervalUnit,
anchor: this._intervalAnchor,
...this._recurrenceExtras(),
};
}
private static readonly _PREVIEW_RELEVANT = new Set([
"_open", "_scheduleType", "_intervalDays", "_intervalUnit", "_intervalAnchor",
"_dueDate", "_weekdays", "_nth", "_nthWeekday", "_domDay", "_domLastDay",
"_domBusiness", "_calOffset", "_seasonMonths", "_endsMode", "_endsCount",
"_endsUntil", "_lastPerformed",
]);
protected updated(changed: Map<PropertyKey, unknown>): void {
super.updated?.(changed);
for (const key of changed.keys()) {
if (MaintenanceTaskDialog._PREVIEW_RELEVANT.has(String(key))) {
this._schedulePreviewRefresh();
return;
}
}
}
private _schedulePreviewRefresh(): void {
if (this._previewTimer) clearTimeout(this._previewTimer);
this._previewTimer = setTimeout(() => void this._fetchSchedulePreview(), 300);
}
private async _fetchSchedulePreview(): Promise<void> {
const sched = this._open ? this._previewScheduleDict() : null;
if (!sched) {
this._schedulePreview = [];
this._schedulePreviewEnded = false;
return;
}
const seq = ++this._previewSeq;
try {
const res = await this.hass.connection.sendMessagePromise<{
occurrences: string[];
series_ended: boolean;
}>({
type: "maintenance_supporter/schedule/preview",
schedule: sched,
...(this._lastPerformed ? { last_performed: this._lastPerformed } : {}),
});
if (seq !== this._previewSeq) return; // a newer edit superseded this
this._schedulePreview = res.occurrences || [];
this._schedulePreviewEnded = !!res.series_ended;
} catch {
// Transient WS error — keep the last preview instead of flickering.
}
}
private _renderSchedulePreview() {
if (this._schedulePreview.length === 0) return nothing;
const L = this._lang;
const time = this.scheduleTimeEnabled && this._scheduleTime ? ` ${this._scheduleTime}` : "";
const chips = this._schedulePreview
.map((iso, i) => {
const js = new Date(`${iso}T12:00:00`).getDay(); // 0=Sun
const wd = weekdayName(js === 0 ? 6 : js - 1, L, "short");
return `${wd} ${formatDate(iso, L)}${i === 0 ? time : ""}`;
})
.join(" · ");
const onTime =
this._scheduleType === "time_based" && this._intervalAnchor === "completion"
? html`<div class="field-help">${t("schedule_preview_ontime", L)}</div>`
: nothing;
return html`
<div class="trigger-live-hint schedule-preview">
${t("schedule_preview_title", L)}: ${chips}${this._schedulePreviewEnded
? html` <span class="field-help">${t("schedule_preview_ends", L)}</span>`
: nothing}
${onTime}
</div>
`;
}
/** Build the nested `schedule` object for the selected calendar kind. */
private _buildSchedule(): Record<string, unknown> {
const withOffset = (schedule: Record<string, unknown>) => {
@@ -1463,6 +1614,28 @@ export class MaintenanceTaskDialog extends LitElement {
/>
${t("delta_mode", L)}
</label>
${this._triggerDeltaMode
? html`
<ms-textfield
label="${t("baseline_start_value", L)}"
type="number"
step="any"
.value=${this._triggerBaselineValue}
@input=${(e: Event) => (this._triggerBaselineValue = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">
${this._taskId ? t("baseline_start_help_edit", L) : t("baseline_start_help", L)}
${this._taskId && this._liveBaselineValue != null
? html`<div class="baseline-effective">
${t("baseline_current_effective", L).replace(
"{value}",
String(this._liveBaselineValue),
)}
</div>`
: nothing}
</div>
`
: nothing}
`;
}
if (this._triggerType === "state_change") {
@@ -1497,6 +1670,13 @@ export class MaintenanceTaskDialog extends LitElement {
.value=${this._triggerRuntimeHours}
@input=${(e: Event) => (this._triggerRuntimeHours = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("runtime_on_states", L)}"
placeholder="on"
.value=${this._triggerOnStates}
@input=${(e: Event) => (this._triggerOnStates = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">${t("runtime_on_states_help", L)}</div>
`;
}
return nothing;
@@ -1583,12 +1763,13 @@ export class MaintenanceTaskDialog extends LitElement {
? html`<input
class="consumes-qty"
type="number"
min="1"
min="0.01"
max="999"
step="0.01"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 1 ? v : 1 };
const v = parseFloat((e.target as HTMLInputElement).value);
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
}}
/>`
: nothing}
@@ -1672,6 +1853,7 @@ export class MaintenanceTaskDialog extends LitElement {
`
: nothing}
${this._renderRecurrenceExtras()}
${this._renderSchedulePreview()}
<ms-textfield
label="${t("warning_days", L)}"
type="number"
@@ -1949,6 +2131,11 @@ export class MaintenanceTaskDialog extends LitElement {
font-size: 12px;
color: var(--secondary-text-color);
}
.baseline-effective {
margin-top: 2px;
font-weight: 500;
color: var(--primary-text-color);
}
/* Live computed trigger hint — reads the bound sensor and explains what
happens next. Info-accented so it reads as guidance, not an error. */
.trigger-live-hint {