/** Dialog for creating/editing a maintenance task. */ 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 } from "../styles"; import { UserService } from "../user-service"; import { partLinkKey } from "../helpers/shared-parts"; import { describeWsError } from "../ws-errors"; import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels"; import "./ms-textfield"; const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"]; 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"]; 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. const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"]; /** One condition of a compound trigger — a flat trigger the user edits inline. * String-typed like the top-level fields (form inputs); coerced on save. */ interface CompoundConditionDraft { entityIds: string; // comma-separated raw input type: string; // threshold | counter | state_change | runtime above: string; below: string; forMinutes: string; targetValue: string; deltaMode: boolean; fromState: string; 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; } function emptyCondition(): CompoundConditionDraft { return { entityIds: "", type: "threshold", above: "", below: "", forMinutes: "0", targetValue: "", deltaMode: false, fromState: "", toState: "", 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] : []); return { entityIds: ids.join(", "), type: c.type || "threshold", above: c.trigger_above?.toString() ?? "", below: c.trigger_below?.toString() ?? "", forMinutes: c.trigger_for_minutes?.toString() ?? "0", targetValue: c.trigger_target_value?.toString() ?? "", deltaMode: c.trigger_delta_mode || false, fromState: c.trigger_from_state || "", 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, }; } /** Build the persisted condition (storage shape) from an editable draft. * Returns null when the condition has no entity (skip it). */ 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 = { ...(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; const f = parseInt(d.forMinutes, 10); if (!isNaN(f)) c.trigger_for_minutes = f; } else if (d.type === "counter") { const v = parseFloat(d.targetValue); if (!isNaN(v)) c.trigger_target_value = v; c.trigger_delta_mode = d.deltaMode; } else if (d.type === "state_change") { if (d.fromState) c.trigger_from_state = d.fromState; if (d.toState) c.trigger_to_state = d.toState; 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; } /** Short localized weekday names (0=Mon … 6=Sun) — uses the shared weekdayName (DRY). */ function weekdayNames(lang?: string): string[] { return Array.from({ length: 7 }, (_, i) => weekdayName(i, lang, "short")); } /** Short localized month names, indexed 0=Jan … 11=Dec. */ function monthNames(lang?: string): string[] { const fmt = new Intl.DateTimeFormat(lang || "en", { month: "short" }); return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2021, i, 1))); } export class MaintenanceTaskDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean, attribute: "checklists-enabled" }) public checklistsEnabled = false; @property({ type: Boolean, attribute: "schedule-time-enabled" }) public scheduleTimeEnabled = false; @property({ type: Boolean, attribute: "completion-actions-enabled" }) public completionActionsEnabled = false; @property({ type: Number, attribute: "default-warning-days" }) public defaultWarningDays = 7; /** The object's spare parts — offered as "consumes parts" checkboxes. */ @state() private parts: Array<{ id: string; name: string; unit?: string }> = []; /** #111: OTHER objects that own spare parts, so a task can draw on a shared * pool (three vacuums, one box of dust bags). Grouped by owner in the UI — * which pool a link means must never be a guess. */ @state() private _foreignOwners: Array<{ entry_id: string; name: string; parts: Array<{ id: string; name: string; unit?: string }>; }> = []; @state() private _open = false; @state() private _loading = false; @state() private _error = ""; @state() private _entryId = ""; @state() private _taskId: string | null = null; // null = create // When openCreate is called without an entry_id and a list of objects is supplied, // the dialog renders an Object selector dropdown so the user can pick the parent. @state() private _objectChoices: Array<{ entry_id: string; name: string }> = []; // Task fields @state() private _name = ""; @state() private _type = "custom"; @state() private _scheduleType = "time_based"; @state() private _intervalDays = "30"; @state() private _intervalUnit = "days"; @state() private _dueDate = ""; @state() private _warningDays = "7"; @state() private _earliestCompletionDays = ""; @state() private _intervalAnchor: "completion" | "planned" = "completion"; // Calendar kinds (Phase 4): weekdays (0=Mon…6=Sun), nth_weekday, day_of_month @state() private _weekdays: number[] = []; @state() private _nth = "1"; // "1".."5" or "-1" (last) @state() private _nthWeekday = "5"; // Saturday @state() private _domDay = "1"; // (#83) end-of-month options: last day / business-day rollback / ±N offset. @state() private _domLastDay = false; @state() private _domBusiness = false; @state() private _calOffset = "0"; // Recurrence extras (apply to interval + calendar kinds): a seasonal active // window and a finite-series end condition. @state() private _seasonMonths: number[] = []; @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 | undefined; private _previewSeq = 0; @state() private _notes = ""; @state() private _documentationUrl = ""; @state() private _customIcon = ""; @state() private _priority = "normal"; @state() private _labels = ""; @state() private _enabled = true; // Trigger fields @state() private _triggerEntityId = ""; @state() private _triggerEntityIds: string[] = []; @state() private _triggerEntityLogic: "any" | "all" = "any"; @state() private _triggerAttribute = ""; @state() private _triggerType = "threshold"; @state() private _triggerAbove = ""; @state() private _triggerBelow = ""; @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[] = []; // Entity attribute introspection @state() private _suggestedAttributes: string[] = []; @state() private _availableAttributes: Array<{ name: string; value: unknown; numeric: boolean }> = []; @state() private _entityDomain = ""; // NFC @state() private _lastPerformed = ""; @state() private _nfcTagId = ""; // v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...) @state() private _readingUnit = ""; /** 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 = {}; @state() private _partsLoadFailed = false; @state() private _availableTags: Array<{id: string; name: string}> = []; // User assignment @state() private _responsibleUserId: string | null = null; @state() private _assigneePool: string[] = []; @state() private _rotationStrategy = ""; @state() private _availableUsers: HAUser[] = []; // Checklist (newline-separated steps, one per line) @state() private _checklistText = ""; /** Details this task demands on completion — enforced by the backend on * every surface, so a button press or voice command cannot bypass it. */ @state() private _requiredCompletion: string[] = []; // Schedule time (HH:MM, advanced feature) @state() private _scheduleTime = ""; // v1.3.0: on_complete_action (gated by completionActionsEnabled) // v1.3.1: _actionData is the parsed object (was: _actionDataJson string) // so ha-form can drive the data fields when the service schema is known. @state() private _actionService = ""; @state() private _actionTargetEntity = ""; @state() private _actionData: Record = {}; @state() private _actionDataJsonFallback = ""; @state() private _actionTesting = false; @state() private _actionTestResult: "" | "ok" | "error" = ""; @state() private _actionTestError = ""; // v1.3.0: quick_complete_defaults (gated by completionActionsEnabled) @state() private _qcNotes = ""; @state() private _qcCost = ""; @state() private _qcDuration = ""; @state() private _qcFeedback: "" | "needed" | "not_needed" = ""; // Environmental entity (adaptive_config) @state() private _environmentalEntity = ""; @state() private _environmentalAttribute = ""; private _environmentalInitial = ""; // for change detection on save private _environmentalAttributeInitial = ""; private _userService: UserService | null = null; private get _lang(): string { return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; } public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise { this._entryId = entryId; this._taskId = null; this._error = ""; // If no entryId is preset but caller passed objects, expose them as a dropdown. // Sort alphabetically by name so the user doesn't have to scan for a target // object in creation order (#40). First object after sort becomes the // default selection so save can work without forced UI. if (!entryId && objects && objects.length > 0) { this._objectChoices = objects .map(o => ({ entry_id: o.entry_id, name: o.object.name })) .sort((a, b) => a.name.localeCompare(b.name)); this._entryId = this._objectChoices[0].entry_id; } else { this._objectChoices = []; } this._resetFields(); await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]); this._open = true; } public async openEdit(entryId: string, task: MaintenanceTask): Promise { this._entryId = entryId; this._taskId = task.id; this._error = ""; this._name = task.name; this._type = task.type; this._scheduleType = task.schedule_type; // Preserve null (= no safety interval set) instead of forcing "30". // Bug #42: the old `?.toString() || "30"` fallback reverted a cleared // safety_interval back to 30 on next edit, silently overwriting the // user's intent the moment they touched any other field and saved. this._intervalDays = task.interval_days != null ? String(task.interval_days) : ""; this._intervalUnit = task.interval_unit || "days"; this._dueDate = task.due_date || ""; // Calendar kinds read the nested schedule (the only place they live). const sched = task.schedule; this._weekdays = sched?.kind === "weekdays" ? [...(sched.weekdays ?? [])] : []; this._nth = sched?.kind === "nth_weekday" ? String(sched.nth ?? 1) : "1"; this._nthWeekday = sched?.kind === "nth_weekday" ? String(sched.weekday ?? 5) : "5"; this._domDay = sched?.kind === "day_of_month" && (sched.day ?? 1) >= 1 ? String(sched.day ?? 1) : "1"; this._domLastDay = sched?.kind === "day_of_month" && sched.day === -1; this._domBusiness = sched?.kind === "day_of_month" && sched.business === true; this._calOffset = sched?.offset ? String(sched.offset) : "0"; // Recurrence extras — read from the nested schedule wherever it lives. this._seasonMonths = Array.isArray(sched?.season_months) ? [...sched!.season_months] : []; const ends = sched?.ends; if (ends && typeof ends.count === "number") { this._endsMode = "count"; this._endsCount = String(ends.count); this._endsUntil = ""; } else if (ends && typeof ends.until === "string") { this._endsMode = "until"; this._endsUntil = ends.until; this._endsCount = ""; } else { this._endsMode = "never"; this._endsCount = ""; this._endsUntil = ""; } this._warningDays = task.warning_days.toString(); this._earliestCompletionDays = task.earliest_completion_days != null ? String(task.earliest_completion_days) : ""; this._intervalAnchor = task.interval_anchor || "completion"; this._notes = task.notes || ""; this._documentationUrl = task.documentation_url || ""; this._customIcon = task.custom_icon || ""; this._priority = task.priority || "normal"; this._labels = (task.labels || []).join(", "); this._enabled = task.enabled !== false; this._lastPerformed = task.last_performed || ""; this._nfcTagId = task.nfc_tag_id || ""; this._readingUnit = task.reading_unit || ""; // 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( (task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]), ); this._responsibleUserId = task.responsible_user_id || null; this._assigneePool = [...(task.assignee_pool || [])]; this._rotationStrategy = task.rotation_strategy || ""; this._checklistText = (task.checklist || []).join("\n"); this._requiredCompletion = [...(task.required_completion_fields || [])]; this._scheduleTime = task.schedule_time || ""; // v1.3.0: hydrate on_complete_action + quick_complete_defaults const oca = task.on_complete_action; if (oca && oca.service) { this._actionService = oca.service; const tgt = oca.target?.entity_id; this._actionTargetEntity = Array.isArray(tgt) ? (tgt[0] || "") : (tgt || ""); this._actionData = (oca.data && typeof oca.data === "object") ? { ...oca.data } : {}; this._actionDataJsonFallback = ""; } else { this._actionService = ""; this._actionTargetEntity = ""; this._actionData = {}; this._actionDataJsonFallback = ""; } const qcd = task.quick_complete_defaults; this._qcNotes = qcd?.notes || ""; this._qcCost = qcd?.cost != null ? String(qcd.cost) : ""; this._qcDuration = qcd?.duration != null ? String(qcd.duration) : ""; this._qcFeedback = (qcd?.feedback as "needed" | "not_needed" | undefined) || ""; const ac: Partial = task.adaptive_config || {}; this._environmentalEntity = ac.environmental_entity || ""; this._environmentalAttribute = ac.environmental_attribute || ""; this._environmentalInitial = this._environmentalEntity; this._environmentalAttributeInitial = this._environmentalAttribute; if (task.trigger_config) { const tc = task.trigger_config; // 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 || ""; this._triggerType = tc.type || "threshold"; this._triggerAbove = tc.trigger_above?.toString() || ""; this._triggerBelow = tc.trigger_below?.toString() || ""; 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); } else { this._compoundLogic = "AND"; this._compoundConditions = []; } } else { this._resetTriggerFields(); } // Fetch entity attributes if trigger entity is set if (this._triggerEntityId) { this._fetchEntityAttributes(this._triggerEntityId); } await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]); this._open = true; } private _resetFields(): void { this._name = ""; this._type = "custom"; this._scheduleType = "time_based"; this._intervalDays = "30"; this._intervalUnit = "days"; this._dueDate = ""; this._warningDays = String(this.defaultWarningDays); this._earliestCompletionDays = ""; this._intervalAnchor = "completion"; this._weekdays = []; this._nth = "1"; this._nthWeekday = "5"; this._domDay = "1"; this._domLastDay = false; this._domBusiness = false; this._calOffset = "0"; this._seasonMonths = []; this._endsMode = "never"; this._endsCount = ""; this._endsUntil = ""; this._notes = ""; this._documentationUrl = ""; this._customIcon = ""; this._priority = "normal"; this._labels = ""; this._enabled = true; this._lastPerformed = ""; this._nfcTagId = ""; this._readingUnit = ""; this._consumesParts = {}; this._responsibleUserId = null; this._assigneePool = []; this._rotationStrategy = ""; this._checklistText = ""; this._requiredCompletion = []; this._scheduleTime = ""; this._environmentalEntity = ""; this._environmentalAttribute = ""; this._environmentalInitial = ""; this._environmentalAttributeInitial = ""; // v1.3.0 this._actionService = ""; this._actionTargetEntity = ""; this._actionData = {}; this._actionDataJsonFallback = ""; this._actionTesting = false; this._actionTestResult = ""; this._qcNotes = ""; this._qcCost = ""; this._qcDuration = ""; this._qcFeedback = ""; this._resetTriggerFields(); } private _resetTriggerFields(): void { this._triggerEntityId = ""; this._triggerEntityIds = []; this._triggerEntityLogic = "any"; this._triggerAttribute = ""; this._suggestedAttributes = []; this._availableAttributes = []; this._entityDomain = ""; this._triggerType = "threshold"; this._triggerAbove = ""; this._triggerBelow = ""; 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 = []; } private async _loadUsers(): Promise { if (!this._userService) { this._userService = new UserService(this.hass); } try { this._availableUsers = await this._userService.getUsers(); } catch (error) { console.error("Failed to load users:", error); this._availableUsers = []; } } private _toggleAssignee(userId: string): void { this._assigneePool = this._assigneePool.includes(userId) ? this._assigneePool.filter((u) => u !== userId) : [...this._assigneePool, userId]; } // v1.3.0: fire the configured action immediately so the user can verify // it works before saving the task. Doesn't persist anything. private async _testAction(): Promise { // VALIDATE-ONLY — does NOT actually fire the service-call. // // Earlier this method called hass.callService(...) for real, which // had real side effects: counter.increment incremented the counter, // input_button.press fired the button (which on the user's vacuum // robot would actually reset the dirty-time sensor as if maintenance // had been performed). User pushback (correct): // *"setzt Test auch den zustand? dann würde beim testen bereits so // getan als ob eine Wartung ausgeführt wurde — das ist nicht gut"* // // The Test button is for verifying the *configuration* is correct // before saving — not for triggering the action. The user can mark // the task complete to actually fire it. // // Checks performed (all without side effects): // 1. Service format matches `domain.service` regex // 2. Service exists in hass.services registry // 3. Domain whitelist for cross-domain services // 4. Service-entity domain match (otherwise HA silently drops the // call at fire-time with "Referenced entities ... missing or // not currently available") // 5. Target entity exists in hass.states (when target set) const svc = this._actionService.trim(); if (!svc || !/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) { this._actionTestResult = "error"; this._actionTestError = "Invalid service format (expected 'domain.service')"; setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 5000); return; } const [domain, name] = svc.split("."); // 2. Service exists? if (!this.hass?.services?.[domain]?.[name]) { this._actionTestResult = "error"; this._actionTestError = `Service "${svc}" is not registered in Home Assistant. Check spelling and that the integration providing it is loaded.`; setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000); return; } const tgt = this._actionTargetEntity.trim(); // 3 + 4. Cross-domain whitelist + domain match. // homeassistant.* / scene.* / notify.* / persistent_notification.* // legitimately accept cross-domain targets; everything else needs the // entity domain to match the service domain. if (tgt) { const entityDomain = tgt.split(".")[0]; const crossDomainServices = new Set([ "homeassistant", "scene", "notify", "persistent_notification", ]); if (entityDomain !== domain && !crossDomainServices.has(domain)) { this._actionTestResult = "error"; this._actionTestError = `Service "${svc}" only works on ${domain}.* entities; entity "${tgt}" is in ${entityDomain}.* — pick a service that matches the entity domain (e.g. ${entityDomain}.${name})`; setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000); return; } // 5. Entity exists? if (!this.hass.states?.[tgt]) { this._actionTestResult = "error"; this._actionTestError = `Target entity "${tgt}" not found in Home Assistant — the entity may have been renamed or its integration removed.`; setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000); return; } } // All checks passed — configuration is valid. The action will fire // when the task is marked complete (not now). this._actionTestResult = "ok"; setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 5000); } // v1.3.1: derive the data dict from either the schema-driven _actionData // (preferred) or the JSON fallback textfield. Returns {} on any parse // problem so the caller still gets a usable empty object. private _buildActionData(): Record { if (this._actionDataJsonFallback.trim()) { try { const parsed = JSON.parse(this._actionDataJsonFallback); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed as Record; } } catch { /* fallthrough to ha-form data */ } } return { ...this._actionData }; } // v1.3.1: look up the selected service in hass.services and convert its // `fields` map into the schema shape ha-form expects. Returns null when // the service is unknown or has no fields metadata — caller falls back // to a free-form JSON textfield. private _serviceSchema(): Array<{ name: string; required: boolean; selector: Record; }> | null { const svc = this._actionService.trim(); if (!svc || !/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) return null; const [domain, name] = svc.split("."); const fields = this.hass?.services?.[domain]?.[name]?.fields; if (!fields || Object.keys(fields).length === 0) return null; return Object.entries(fields).map(([fname, def]) => ({ name: fname, required: !!def.required, selector: (def.selector as Record) || { text: {} }, })); } private _renderCompletionActionsSection(L: string) { if (!this.completionActionsEnabled) return nothing; const schema = this._serviceSchema(); return html`
${t("on_complete_action_title", L)}

${t("on_complete_action_desc", L)}

{ this._actionService = e.detail.value || ""; // Schema changed → drop fields the new service doesn't accept. const newSchema = this._serviceSchema(); if (newSchema) { const allowed = new Set(newSchema.map(f => f.name)); this._actionData = Object.fromEntries( Object.entries(this._actionData).filter(([k]) => allowed.has(k)), ); } }} > t("on_complete_action_target", L)} @value-changed=${(e: CustomEvent) => { const v = e.detail.value as { target_entity?: string }; this._actionTargetEntity = v.target_entity || ""; }} >

${t("on_complete_action_target_hint", L)}

${schema ? html` { this._actionData = { ...(e.detail.value as Record) }; }} > ` : html` { this._actionDataJsonFallback = (e.target as HTMLInputElement).value; }} > `}
${this._actionTestResult === "ok" ? html`${t("on_complete_action_test_success", L)}` : nothing} ${this._actionTestResult === "error" ? html`
${t("on_complete_action_test_failed", L)} ${this._actionTestError ? html`
${this._actionTestError}
` : nothing}
` : nothing}
${t("quick_complete_defaults_title", L)}

${t("quick_complete_defaults_desc", L)}

{ this._qcNotes = (e.target as HTMLInputElement).value; }} > { this._qcCost = (e.target as HTMLInputElement).value; }} > { this._qcDuration = (e.target as HTMLInputElement).value; }} >
`; } private async _loadParts(): Promise { // The object's parts back the "consumes parts" checkboxes. Self-loaded so // every dialog opener (panel, card, task detail) gets them without // threading props through. this.parts = []; if (!this._entryId) return; try { const result = (await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/object", entry_id: this._entryId, })) as { parts?: Array<{ id: string; name: string; unit?: string }> }; this.parts = result.parts || []; this._partsLoadFailed = false; } catch { // Surface the failure: an empty-but-failed load must not just hide the // "consumes parts" section as if the object had no parts. this.parts = []; this._partsLoadFailed = true; } } /** #111: the other objects' spare-part pools this task could draw on. * * A sibling of `_loadParts`, run in the SAME Promise.all rather than nested * inside it: chaining it after the own-parts fetch delays the dialog opening * by a further round trip for a list that is secondary to it. * * Failure is soft on purpose — the own-parts picker is the primary path and * must not disappear because this second call did not come back. */ private async _loadForeignPools(): Promise { this._foreignOwners = []; if (!this._entryId) return; try { const result = (await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", })) as { objects?: Array<{ entry_id: string; object?: { name?: string }; parts?: Array<{ id: string; name: string; unit?: string }>; }>; }; this._foreignOwners = (result.objects || []) .filter((o) => o.entry_id !== this._entryId && (o.parts || []).length > 0) .map((o) => ({ entry_id: o.entry_id, name: o.object?.name || o.entry_id, parts: o.parts || [], })) .sort((a, b) => a.name.localeCompare(b.name)); } catch { this._foreignOwners = []; } } private async _loadTags(): Promise { try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/tags/list", }) as { tags: Array<{id: string; name: string}> }; this._availableTags = result.tags || []; } catch { this._availableTags = []; } } private async _fetchEntityAttributes(entityId: string): Promise { if (!entityId || !this.hass) { this._suggestedAttributes = []; this._availableAttributes = []; this._entityDomain = ""; return; } try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/entity/attributes", entity_id: entityId, }) as { domain: string; suggested_attributes: string[]; available_attributes: Array<{ name: string; value: unknown; numeric: boolean }>; }; this._entityDomain = result.domain || ""; this._suggestedAttributes = result.suggested_attributes || []; this._availableAttributes = result.available_attributes || []; } catch { this._suggestedAttributes = []; this._availableAttributes = []; this._entityDomain = ""; } } /** A task already drawing on a shared pool opens that section expanded — a * collapsed disclosure would hide a link that is very much active. */ private get _hasForeignPick(): boolean { return Object.values(this._consumesParts).some((l) => !!l.entry_id); } /** One "consumes parts" checkbox + quantity. * * `ownerEntryId` is undefined for the object's own parts and set for a pool * owned by another object (#111) — that argument is the ONLY difference * between the two lists, which is why they share this renderer. */ private _renderConsumesRow( part: { id: string; name: string; unit?: string }, ownerEntryId?: string, ) { const key = partLinkKey({ part_id: part.id, entry_id: ownerEntryId }); const link = this._consumesParts[key]; const base: TaskPartLink = ownerEntryId ? { part_id: part.id, quantity: 1, entry_id: ownerEntryId } : { part_id: part.id, quantity: 1 }; return html`
${link !== undefined ? html` { const v = parseFloat((e.target as HTMLInputElement).value); this._consumesParts = { ...this._consumesParts, [key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 }, }; }} />` : nothing}
`; } private _toggleRequired(field: string, on: boolean): void { const next = new Set(this._requiredCompletion); if (on) next.add(field); else next.delete(field); this._requiredCompletion = [...next]; } private async _save(): Promise { if (this._loading) return; // synchronous re-entry guard (double-click) if (!this._name.trim()) return; this._loading = true; this._error = ""; try { const data: Record = { type: this._taskId ? "maintenance_supporter/task/update" : "maintenance_supporter/task/create", entry_id: this._entryId, name: this._name, task_type: this._type, schedule_type: this._scheduleType, // `0` is a legal, meaningful value — "no due-soon window, go straight // from ok to overdue" (backend range is 0–365). The old // `parseInt(...) || 7` treated it as falsy and silently rewrote a // stored 0 to 7 on EVERY save, even when the user never touched the // field. Same class as bug #42, but worse: it needed no user action. // Only a genuinely unparseable field falls back, and to the // configured default rather than a hardcoded 7. warning_days: Number.isNaN(parseInt(this._warningDays, 10)) ? this.defaultWarningDays : Math.max(0, parseInt(this._warningDays, 10)), }; const ecd = this._earliestCompletionDays.trim(); data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0); if (this._taskId) data.task_id = this._taskId; if (this._scheduleType === "one_time") { data.due_date = this._dueDate || null; data.interval_days = null; } else if (CALENDAR_KINDS.includes(this._scheduleType)) { // Calendar kinds are sent as the nested schedule; the backend prefers // it over the flat fields and clears any stale interval/due_date. data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() }; data.interval_days = null; if (this._taskId) data.due_date = null; } else { // Switching away from one-time clears the stale due_date on edit. if (this._taskId) data.due_date = null; if (this._scheduleType !== "manual" && this._intervalDays) { data.interval_days = parseInt(this._intervalDays, 10); data.interval_unit = this._intervalUnit; data.interval_anchor = this._intervalAnchor; // Season / finite-series ride a minimal nested schedule alongside the // flat interval fields; the backend carries them onto the interval it // derives from the flat fields (they can't be expressed flatly). // Always send an authoritative nested schedule for interval tasks so // an edit that clears season/ends actually clears them. if (this._scheduleType === "time_based") { data.schedule = { kind: "interval", ...this._recurrenceExtras() }; } } else if (this._taskId) { data.interval_days = null; data.interval_anchor = "completion"; } } data.notes = this._notes || null; data.documentation_url = this._documentationUrl || null; data.custom_icon = this._customIcon || null; data.priority = this._priority; data.labels = this._labels .split(",") .map((s) => s.trim()) .filter(Boolean); data.enabled = this._enabled; data.last_performed = this._lastPerformed || null; data.nfc_tag_id = this._nfcTagId || null; data.reading_unit = this._readingUnit.trim() || null; // Only send when a picker was actually rendered. A failed parts load // leaves both lists empty, and sending [] then would silently wipe links // the user never saw. entry_id is written ONLY for a foreign pick, so an // own-parts task saves byte-identically to before (#111). if (this.parts.length || this._foreignOwners.length) { data.consumes_parts = Object.values(this._consumesParts).map((l) => l.entry_id ? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id } : { part_id: l.part_id, quantity: l.quantity }, ); } data.responsible_user_id = this._responsibleUserId; data.assignee_pool = this._assigneePool; data.required_completion_fields = this._requiredCompletion; data.rotation_strategy = this._assigneePool.length >= 2 && this._rotationStrategy ? this._rotationStrategy : null; if (this._scheduleType === "sensor_based" && this._triggerType === "compound") { // Compound: a group of conditions joined by AND/OR. Each condition // carries its own entity + type + params; the top-level entity picker // does not apply here. const conditions = this._compoundConditions .map(draftToCondition) .filter((c): c is TriggerConfig => c !== null); if (conditions.length > 0) { const triggerConfig: TriggerConfig = { type: "compound", compound_logic: this._compoundLogic, conditions, }; if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true; data.trigger_config = triggerConfig; } else if (this._taskId) { data.trigger_config = null; } } else if (this._scheduleType === "sensor_based" && this._triggerEntityId) { const entityIds = this._triggerEntityIds.length > 0 ? this._triggerEntityIds : [this._triggerEntityId]; const triggerConfig: TriggerConfig = { entity_id: entityIds[0], entity_ids: entityIds, type: this._triggerType, }; if (this._triggerAttribute) triggerConfig.attribute = this._triggerAttribute; if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true; // Multi-entity: store entity_logic for all trigger types if (entityIds.length > 1) { triggerConfig.entity_logic = this._triggerEntityLogic; } if (this._triggerType === "threshold") { if (this._triggerAbove) { const v = parseFloat(this._triggerAbove); if (!isNaN(v)) triggerConfig.trigger_above = v; } if (this._triggerBelow) { const v = parseFloat(this._triggerBelow); if (!isNaN(v)) triggerConfig.trigger_below = v; } if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; } } 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; } else if (this._taskId) { 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(); data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null; } if (this.checklistsEnabled) { const items = this._checklistText .split("\n") .map((l) => l.trim()) .filter(Boolean) .slice(0, 100); data.checklist = items.length ? items : null; } // v1.3.0: on_complete_action + quick_complete_defaults (gated) if (this.completionActionsEnabled) { const svc = this._actionService.trim(); if (svc && /^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) { const action: Record = { service: svc }; const tgt = this._actionTargetEntity.trim(); if (tgt) action.target = { entity_id: tgt }; const dataDict = this._buildActionData(); if (Object.keys(dataDict).length > 0) { action.data = dataDict; } data.on_complete_action = action; } else { data.on_complete_action = null; } const qcd: Record = {}; if (this._qcNotes.trim()) qcd.notes = this._qcNotes.trim(); const cost = parseFloat(this._qcCost); if (!isNaN(cost) && cost >= 0) qcd.cost = cost; const dur = parseInt(this._qcDuration, 10); if (!isNaN(dur) && dur >= 0) qcd.duration = dur; if (this._qcFeedback) qcd.feedback = this._qcFeedback; data.quick_complete_defaults = Object.keys(qcd).length ? qcd : null; } const result = await this.hass.connection.sendMessagePromise(data) as { task_id?: string }; const savedTaskId = this._taskId || result?.task_id; // Environmental entity lives in adaptive_config (Store-managed), // so it has a dedicated endpoint. Only call it when something // actually changed, and only for sensor_based tasks. const envChanged = this._environmentalEntity !== this._environmentalInitial || this._environmentalAttribute !== this._environmentalAttributeInitial; if ( savedTaskId && this._scheduleType === "sensor_based" && envChanged ) { try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/set_environmental_entity", entry_id: this._entryId, task_id: savedTaskId, environmental_entity: this._environmentalEntity || null, environmental_attribute: this._environmentalAttribute || null, }); this._environmentalInitial = this._environmentalEntity; this._environmentalAttributeInitial = this._environmentalAttribute; } catch { /* non-fatal — task itself saved */ } } this._open = false; this.dispatchEvent(new CustomEvent("task-saved")); } catch (e) { this._error = describeWsError(e, this._lang, t("save_error", this._lang)); } finally { this._loading = false; } } private _close(): void { this._open = false; } private _renderTriggerFields() { if (this._scheduleType !== "sensor_based") return nothing; const L = this._lang; const isCompound = this._triggerType === "compound"; return html`

${t("trigger_configuration", L)}

${isCompound ? this._renderCompoundEditor() : html` 0 ? this._triggerEntityIds.join(", ") : this._triggerEntityId} @input=${(e: Event) => { const raw = (e.target as HTMLInputElement).value; const ids = raw.split(",").map((s: string) => s.trim()).filter(Boolean); this._triggerEntityId = ids[0] || ""; this._triggerEntityIds = ids; if (ids[0]) this._fetchEntityAttributes(ids[0]); }} > ${this._triggerEntityIds.length > 1 ? html`
` : nothing} ${this._availableAttributes.length > 0 ? html`
` : html` (this._triggerAttribute = (e.target as HTMLInputElement).value)} > ` } ${this._renderTriggerTypeFields()} ${this._renderTriggerLiveHint()} `}
${t("auto_complete_on_recovery_help", L)}
(this._intervalDays = (e.target as HTMLInputElement).value)} > ${this._intervalDays ? this._renderUnitSelect() : nothing} `; } /** Immutably patch one compound condition and trigger a re-render. */ private _patchCondition(index: number, patch: Partial): void { this._compoundConditions = this._compoundConditions.map((c, i) => i === index ? { ...c, ...patch } : c ); } private _addCondition(): void { this._compoundConditions = [...this._compoundConditions, emptyCondition()]; } private _removeCondition(index: number): void { this._compoundConditions = this._compoundConditions.filter((_, i) => i !== index); } /** Compound trigger editor: AND/OR logic + a list of inline conditions. */ private _renderCompoundEditor() { const L = this._lang; return html`
${t("compound_help", L)}
${this._compoundConditions.length === 0 ? html`
${t("compound_no_conditions", L)}
` : this._compoundConditions.map((c, i) => this._renderCondition(c, i))} `; } /** One compound condition row: entity + sub-type + type-specific params. */ private _renderCondition(c: CompoundConditionDraft, i: number) { const L = this._lang; const num = i + 1; return html`
${t("compound_condition", L)} ${num}
this._patchCondition(i, { entityIds: (e.target as HTMLInputElement).value })} >
${this._renderConditionTypeFields(c, i)}
`; } /** Type-specific inputs for a single compound condition (mirrors the flat * per-type fields, bound to the condition draft). */ private _renderConditionTypeFields(c: CompoundConditionDraft, i: number) { const L = this._lang; if (c.type === "threshold") { return html` this._patchCondition(i, { above: (e.target as HTMLInputElement).value })}> this._patchCondition(i, { below: (e.target as HTMLInputElement).value })}> this._patchCondition(i, { forMinutes: (e.target as HTMLInputElement).value })}> `; } if (c.type === "counter") { return html` this._patchCondition(i, { targetValue: (e.target as HTMLInputElement).value })}> `; } if (c.type === "state_change") { return html` this._patchCondition(i, { fromState: (e.target as HTMLInputElement).value })}> this._patchCondition(i, { toState: (e.target as HTMLInputElement).value })}> this._patchCondition(i, { targetChanges: (e.target as HTMLInputElement).value })}> `; } if (c.type === "runtime") { return html` this._patchCondition(i, { runtimeHours: (e.target as HTMLInputElement).value })}> this._patchCondition(i, { onStates: (e.target as HTMLInputElement).value })}> `; } return nothing; } /** Shared interval-unit dropdown (DRY: time-based interval + sensor safety * interval). Bound to _intervalUnit; options localized via unit_* keys. */ private _renderUnitSelect() { const L = this._lang; return html`
`; } private _toggleWeekday(i: number): void { this._weekdays = this._weekdays.includes(i) ? this._weekdays.filter((d) => d !== i) : [...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 | 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): 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 { 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`
${t("schedule_preview_ontime", L)}
` : nothing; return html`
${t("schedule_preview_title", L)}: ${chips}${this._schedulePreviewEnded ? html` ${t("schedule_preview_ends", L)}` : nothing} ${onTime}
`; } /** Build the nested `schedule` object for the selected calendar kind. */ private _buildSchedule(): Record { const withOffset = (schedule: Record) => { const off = parseInt(this._calOffset, 10) || 0; if (off) schedule.offset = Math.max(-15, Math.min(off, 15)); return schedule; }; if (this._scheduleType === "weekdays") { return withOffset({ kind: "weekdays", weekdays: [...this._weekdays].sort((a, b) => a - b) }); } if (this._scheduleType === "nth_weekday") { return withOffset({ kind: "nth_weekday", nth: parseInt(this._nth, 10), weekday: parseInt(this._nthWeekday, 10), }); } // (#83) "last day" wins over the day number; business rolls weekends back. const schedule: Record = { kind: "day_of_month", day: this._domLastDay ? -1 : (parseInt(this._domDay, 10) || 1), }; if (this._domBusiness) schedule.business = true; return withOffset(schedule); } /** The season/finite-series extras to attach to a recurring schedule. Empty * values are omitted, so a clean schedule stays clean (and, since the sent * schedule is authoritative, an omitted extra clears a previously-set one). */ private _recurrenceExtras(): { season_months?: number[]; ends?: { count?: number; until?: string } } { const extras: { season_months?: number[]; ends?: { count?: number; until?: string } } = {}; if (this._seasonMonths.length) extras.season_months = [...this._seasonMonths].sort((a, b) => a - b); if (this._endsMode === "count") { const n = parseInt(this._endsCount, 10); if (n >= 1) extras.ends = { count: n }; } else if (this._endsMode === "until" && this._endsUntil) { extras.ends = { until: this._endsUntil }; } return extras; } private _toggleSeasonMonth(m: number): void { this._seasonMonths = this._seasonMonths.includes(m) ? this._seasonMonths.filter((x) => x !== m) : [...this._seasonMonths, m]; } /** Seasonal window + finite-series end — shown for recurring (interval + * calendar) kinds, where both apply. */ private _renderRecurrenceExtras() { const L = this._lang; const recurring = this._scheduleType === "time_based" || CALENDAR_KINDS.includes(this._scheduleType); if (!recurring) return nothing; const months = monthNames(L); return html`
${t("season_window_hint", L)}
${months.map((name, i) => html` `)}
${this._endsMode === "count" ? html` (this._endsCount = (e.target as HTMLInputElement).value)} >` : nothing} ${this._endsMode === "until" ? html` (this._endsUntil = (e.target as HTMLInputElement).value)} >` : nothing} `; } /** Per-kind field groups for the calendar recurrence kinds. */ private _renderCalendarFields() { const L = this._lang; const days = weekdayNames(L); if (this._scheduleType === "weekdays") { return html`
${days.map((name, i) => html` `)}
${this._renderCalOffsetField()}`; } if (this._scheduleType === "nth_weekday") { const nths: Array<[string, string]> = [ ["1", t("ord_1", L)], ["2", t("ord_2", L)], ["3", t("ord_3", L)], ["4", t("ord_4", L)], ["5", t("ord_5", L)], ["-1", t("ord_last", L)], ]; return html`
${this._renderCalOffsetField()}`; } if (this._scheduleType === "day_of_month") { return html` ${this._domLastDay ? nothing : html` (this._domDay = (e.target as HTMLInputElement).value)} >`} ${this._renderCalOffsetField()}`; } return nothing; } /** (#83) ±N-day shift shared by all calendar kinds. */ private _renderCalOffsetField() { const L = this._lang; return html` (this._calOffset = (e.target as HTMLInputElement).value)} >`; } /** Live "what happens next" hint for sensor-based triggers. * * Reads the bound entity's CURRENT state client-side (the dialog already * holds `hass`) and spells out the trigger semantics against it — clearing * the most common usage-meter confusion: a delta counter counts from the * sensor's current reading (not from zero) and restarts after each * completion. Renders nothing when there's no entity/state to read. */ private _renderTriggerLiveHint() { if (this._triggerType === "compound") return nothing; const entityId = this._triggerEntityId || this._triggerEntityIds[0]; if (!entityId || !this.hass?.states) return nothing; const st = this.hass.states[entityId]; if (!st) return nothing; const L = this._lang; const unitAttr = st.attributes?.unit_of_measurement; const unit = typeof unitAttr === "string" && unitAttr ? ` ${unitAttr}` : ""; const raw = this._triggerAttribute ? st.attributes?.[this._triggerAttribute] : 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)); const parts: string[] = []; if (this._triggerType === "threshold") { const above = parseFloat(this._triggerAbove); const below = parseFloat(this._triggerBelow); if (isNaN(above) && isNaN(below)) return nothing; if (hasNum) parts.push(t("trigger_hint_now", L).replace("{value}", fmt(num) + unit)); if (!isNaN(above)) parts.push(t("trigger_hint_above", L).replace("{target}", fmt(above) + unit)); if (!isNaN(below)) parts.push(t("trigger_hint_below", L).replace("{target}", fmt(below) + unit)); } else if (this._triggerType === "counter") { const target = parseFloat(this._triggerTargetValue); if (isNaN(target)) return nothing; if (this._triggerDeltaMode) { if (this._taskId) { // Editing: the baseline is the reading at the last completion (or // task creation), not the current value — don't imply otherwise. parts.push(t("trigger_hint_counter_delta_edit", L).replace("{target}", fmt(target) + unit)); } else if (hasNum) { parts.push( t("trigger_hint_counter_delta", L) .replace("{value}", fmt(num) + unit) .replace("{due}", fmt(num + target) + unit) .replace("{target}", fmt(target) + unit), ); } else { parts.push(t("trigger_hint_counter_delta_edit", L).replace("{target}", fmt(target) + unit)); } } else { if (hasNum) parts.push(t("trigger_hint_now", L).replace("{value}", fmt(num) + unit)); parts.push(t("trigger_hint_counter_abs", L).replace("{target}", fmt(target) + unit)); } } else if (this._triggerType === "runtime") { const hours = parseFloat(this._triggerRuntimeHours); if (isNaN(hours)) return nothing; parts.push(t("trigger_hint_runtime", L).replace("{hours}", fmt(hours))); parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state))); } else if (this._triggerType === "state_change") { const n = parseInt(this._triggerTargetChanges, 10) || 1; const to = this._triggerToState.trim(); parts.push( (to ? t("trigger_hint_state_change_to", L).replace("{state}", to) : t("trigger_hint_state_change", L) ).replace("{count}", String(n)), ); parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state))); } if (!parts.length) return nothing; return html`
${parts.join(" ")}
`; } private _renderTriggerTypeFields() { const L = this._lang; if (this._triggerType === "threshold") { return html` (this._triggerAbove = (e.target as HTMLInputElement).value)} > (this._triggerBelow = (e.target as HTMLInputElement).value)} > (this._triggerForMinutes = (e.target as HTMLInputElement).value)} > `; } if (this._triggerType === "counter") { return html` (this._triggerTargetValue = (e.target as HTMLInputElement).value)} > ${this._triggerDeltaMode ? html` (this._triggerBaselineValue = (e.target as HTMLInputElement).value)} >
${this._taskId ? t("baseline_start_help_edit", L) : t("baseline_start_help", L)} ${this._taskId && this._liveBaselineValue != null ? html`
${t("baseline_current_effective", L).replace( "{value}", String(this._liveBaselineValue), )}
` : nothing}
` : nothing} `; } if (this._triggerType === "state_change") { return html` (this._triggerFromState = (e.target as HTMLInputElement).value)} >
${t("state_value_help", L)}
(this._triggerToState = (e.target as HTMLInputElement).value)} > (this._triggerTargetChanges = (e.target as HTMLInputElement).value)} >
${t("target_changes_help", L)}
`; } if (this._triggerType === "runtime") { return html` (this._triggerRuntimeHours = (e.target as HTMLInputElement).value)} > (this._triggerOnStates = (e.target as HTMLInputElement).value)} >
${t("runtime_on_states_help", L)}
`; } return nothing; } render() { if (!this._open) return html``; const L = this._lang; const title = this._taskId ? t("edit_task", L) : t("new_task", L); return html`
${title}
${this._error ? html`
${this._error}
` : nothing} ${this._objectChoices.length > 0 ? html`
` : nothing} (this._name = (e.target as HTMLInputElement).value)} >
${this._type === "reading" ? html` (this._readingUnit = (e.target as HTMLInputElement).value)} >
${t("reading_unit_help", L)}
` : nothing} ${this._partsLoadFailed ? html`
${t("parts_load_failed", L)}
` : nothing} ${this.parts.length || this._foreignOwners.length ? html`
${this.parts.map((part) => this._renderConsumesRow(part))} ${this._foreignOwners.length ? html`
${t("shared_parts_other_objects", L)}
${t("shared_parts_help", L)}
${this._foreignOwners.map( (owner) => html`
${owner.name}
${owner.parts.map((part) => this._renderConsumesRow(part, owner.entry_id), )} `, )}
` : nothing}
` : nothing}
(this._labels = (e.target as HTMLInputElement).value)} />
${t("labels_help", L)}
${this._scheduleType === "time_based" ? html` (this._intervalDays = (e.target as HTMLInputElement).value)} > ${this._renderUnitSelect()}
${this.scheduleTimeEnabled ? html` (this._scheduleTime = (e.target as HTMLInputElement).value)} > ` : nothing} ` : nothing} ${this._renderCalendarFields()} ${this._scheduleType === "one_time" ? html` (this._dueDate = (e.target as HTMLInputElement).value)} > ` : nothing} ${this._renderRecurrenceExtras()} ${this._renderSchedulePreview()} (this._warningDays = (e.target as HTMLInputElement).value)} > (this._earliestCompletionDays = (e.target as HTMLInputElement).value)} > ${this.checklistsEnabled ? html`

${t("checklist_steps_optional", L)}

${t("checklist_help", L)}
` : nothing}

${t("require_on_completion", L)}

${REQUIRED_COMPLETION_KEYS.map((field) => html` `)}
(this._lastPerformed = (e.target as HTMLInputElement).value)} >
${this._availableUsers.length >= 2 ? html`
${t("shared_with_help", L)}
${this._availableUsers.map((user) => html` `)}
${this._assigneePool.length >= 2 ? html`
` : nothing} ` : nothing} ${this._renderTriggerFields()} ${this._scheduleType === "sensor_based" ? html` (this._environmentalEntity = (e.target as HTMLInputElement).value.trim())} > ${this._environmentalEntity ? html` (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())} > ` : nothing} ` : nothing} (this._notes = (e.target as HTMLInputElement).value)} > (this._documentationUrl = (e.target as HTMLInputElement).value)} > (this._customIcon = (e.detail.value as string) || "")} > ${this._availableTags.length > 0 ? html`
` : html` (this._nfcTagId = (e.target as HTMLInputElement).value)} >
${t("nfc_tags_empty_help", L)} ${t("nfc_tags_open_settings", L)} ·
` } ${this._renderCompletionActionsSection(L)}
${t("cancel", L)} ${this._loading ? t("saving", L) : t("save", L)}
`; } static styles = css` .dialog-title { font-size: 18px; font-weight: 500; padding-bottom: 12px; } /* v1.3.0: completion-action sections */ .ca-section { border: 1px solid var(--divider-color); border-radius: 6px; padding: 8px 12px; margin-top: 8px; } .ca-section > summary { cursor: pointer; font-weight: 500; } .ca-section ms-textfield, .ca-section ha-entity-picker, .ca-section ha-service-picker, .ca-section ha-form, .ca-section .qc-feedback { width: 100%; margin-top: 8px; display: block; } .ca-section .qc-feedback { padding: 8px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); } .ca-test-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; } .ca-test-ok { color: var(--success-color, #4caf50); font-size: 13px; } .ca-test-error { color: var(--error-color, #f44336); font-size: 13px; font-weight: 500; } .ca-test-error-block { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; } .ca-test-error-detail { font-size: 12px; color: var(--secondary-text-color); background: rgba(244, 67, 54, 0.08); padding: 6px 8px; border-radius: 4px; line-height: 1.4; word-break: break-word; } .content { display: flex; flex-direction: column; gap: 12px; min-width: 350px; max-height: 70vh; overflow-y: auto; } @media (max-width: 600px) { .content { min-width: 0; max-height: none; } } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 16px; } ms-textfield { display: block; } .field-label { font-size: 12px; color: var(--secondary-text-color); } .checklist-textarea { width: 100%; min-height: 88px; padding: 8px; font-family: inherit; font-size: 14px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color); color: var(--primary-text-color); resize: vertical; box-sizing: border-box; } .consumes-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; } .consumes-check { display: flex; align-items: center; gap: 6px; flex: 1; } .consumes-qty { width: 64px; padding: 4px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color); color: var(--primary-text-color); } /* #111: other objects' pools sit behind a disclosure so the object's OWN parts stay the primary list; each group is headed by the owning object's name, so which pool a checkbox means is never a guess. */ .shared-pools { margin-top: 6px; } .shared-pools > summary { cursor: pointer; padding: 2px 0; font-size: 13px; color: var(--secondary-text-color); } .shared-pool-owner { margin-top: 6px; font-size: 12px; font-weight: 500; color: var(--secondary-text-color); } .field-help { 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 { font-size: 12px; color: var(--secondary-text-color); border-left: 3px solid var(--info-color, #2196f3); background: rgba(33, 150, 243, 0.08); border-radius: 0 6px 6px 0; padding: 6px 10px; margin: 4px 0; } .field-help a, .link-button { background: none; border: 0; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; } .field-help a:hover, .link-button:hover { text-decoration: none; } /* Smaller refresh icon-button when shown next to the dropdown. */ .select-row .link-button { margin-left: 8px; text-decoration: none; font-size: 16px; } .select-row .link-button:hover { color: var(--primary-color); opacity: 0.7; } h3 { margin: 8px 0 0; font-size: 14px; color: var(--primary-color); } .select-row { display: flex; flex-direction: column; gap: 4px; } .assignee-pool { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 4px; } .checkbox-row { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 2px 0; } .checkbox-row input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; } .pool-item { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; } .pool-item input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; } .select-row label { font-size: 12px; color: var(--secondary-text-color); } .select-row select { padding: 8px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 14px; } .field-label { font-size: 12px; color: var(--secondary-text-color); } .weekday-chips { display: flex; flex-wrap: wrap; gap: 6px; } .weekday-chip { padding: 6px 12px; border: 1px solid var(--divider-color); border-radius: 16px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 13px; cursor: pointer; } .weekday-chip.selected { background: var(--primary-color, #03a9f4); color: var(--text-primary-color, #fff); border-color: var(--primary-color, #03a9f4); } .season-chip { padding: 6px 10px; border: 1px solid var(--divider-color); border-radius: 16px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 13px; cursor: pointer; } .season-chip.selected { background: var(--primary-color, #03a9f4); color: var(--text-primary-color, #fff); border-color: var(--primary-color, #03a9f4); } .error { color: var(--error-color, #f44336); font-size: 13px; } .toggle-row { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; } `; } if (!customElements.get("maintenance-task-dialog")) { customElements.define("maintenance-task-dialog", MaintenanceTaskDialog); }