Updated apps
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user