/** Trigger section renderer (task detail). * * Owns the task semantics — which values the chart shows and what the * reference lines mean — and delegates the actual plotting to the * responsive component: * * - threshold tasks plot the raw sensor with the danger zone shaded; * - counter tasks plot **progress since the last service** (cumulative, * clamped at 0 — an odometer can never be negative) against the target, * headed by a "8,507 / 15,000 km · 57 %" progress bar; * - everything else plots the raw series. */ import { html, nothing } from "lit"; import { t, fireMoreInfo } from "../styles"; import { fmtNum, fmtVal } from "./chart-utils"; import "../components/trigger-chart"; import type { ChartPoint, ChartEvent } from "../components/trigger-chart"; import type { MaintenanceTask, TriggerConfig, StatisticsPoint } from "../types"; export interface SparklineContext { lang: string; detailStatsData: Map; hasStatsService: boolean; /** #141: entities whose series is recorder STATE HISTORY (no long-term * statistics exist — binary sensors etc.). Changes the footnote and * suppresses the trigger_current_value tail point (for a state_change * trigger that is the change COUNTER, not the plotted state). */ historyFallbackIds?: Set; isCounterEntity: (tc: TriggerConfig) => boolean; rangeDays: number; setRangeDays: (days: number) => void; /** When true, drop statistical outliers (sensor glitches) from the chart. */ hideOutliers: boolean; setHideOutliers: (hide: boolean) => void; } /** Drop outliers via the IQR fence (Tukey): keep points within * [Q1 − 1.5·IQR, Q3 + 1.5·IQR]. Robust to a few wild glitch readings (a * pressure sensor spiking to 100 while it normally sits at 1.5–3). No-ops on * short series (< 4 points) where quartiles aren't meaningful. */ /** A binary HA state as the 0/1 level the history fallback plots, or null * for states the fallback cannot express (then no trigger view is built). */ export function binaryLevel(state: string | null | undefined): 0 | 1 | null { const s = (state ?? "").trim().toLowerCase(); if (s === "on" || s === "open" || s === "true") return 1; if (s === "off" || s === "closed" || s === "false") return 0; return null; } export interface StateChangeView { points: ChartPoint[]; /** "alarm": single-shot latch (target 1 + a to-state) — 1 while the alert * state is held, 0 otherwise. "count": change counter since the last * service, stepped, honouring the hold filter. */ mode: "alarm" | "count"; } /** #141 (round 2): the recorder fallback plots the RAW entity state, but a * state_change trigger is about transitions. A water-valve switch that is * "on" all week with a few second-long "off" blips drew a flat 1 with the * "Target changes: 1" line on the same axis — while the header said 0 / 1, * because those blips never held for the #136 hold time. Re-express the * step series in the trigger's own terms: * * - latch (target_changes == 1 with a to-state): 1 = alert state HELD for * the hold time, 0 = fine. Reads as "is the trigger active" — for a * problem sensor (to-state on) this equals the raw line, for a switch * watched for "off" it is the inverse the user expects. * - counter (everything else): the cumulative number of matching, held * transitions since the last service, anchored so the line ends at the * trigger's live count (transitions before the recorder window are * unknown but already in that count). * * Returns null when the from/to states cannot be expressed as 0/1. */ export function stateChangeView( points: ChartPoint[], tc: { trigger_from_state?: string | null; trigger_to_state?: string | null; trigger_target_changes?: number; trigger_for_minutes?: number }, opts: { since: number | null; current: number | null; now?: number }, ): StateChangeView | null { if (points.length < 2) return null; const now = opts.now ?? Date.now(); const holdMs = Math.max(0, tc.trigger_for_minutes ?? 0) * 60000; const fromLevel = tc.trigger_from_state ? binaryLevel(tc.trigger_from_state) : null; const toLevel = tc.trigger_to_state ? binaryLevel(tc.trigger_to_state) : null; if ((tc.trigger_from_state && fromLevel === null) || (tc.trigger_to_state && toLevel === null)) return null; // Collapse the doubled step points into held segments (start, level). const sorted = [...points].sort((a, b) => a.ts - b.ts); const segments: { start: number; level: number }[] = []; for (const p of sorted) { const last = segments[segments.length - 1]; if (!last || last.level !== p.val) segments.push({ start: p.ts, level: p.val }); } const held = (i: number) => { const end = i + 1 < segments.length ? segments[i + 1].start : now; return end - segments[i].start >= holdMs; }; const step = (out: ChartPoint[], ts: number, val: number) => { const last = out[out.length - 1]; if (last && last.val !== val) out.push({ ts, val: last.val }); out.push({ ts, val }); }; const latch = (tc.trigger_target_changes ?? 1) === 1 && toLevel !== null; if (latch) { const out: ChartPoint[] = []; segments.forEach((seg, i) => step(out, seg.start, seg.level === toLevel && held(i) ? 1 : 0)); const lastVal = out[out.length - 1]?.val ?? 0; out.push({ ts: now, val: lastVal }); return { points: out, mode: "alarm" }; } const since = opts.since ?? segments[0].start; let counted = 0; const stepsAt: number[] = []; for (let i = 1; i < segments.length; i++) { const prev = segments[i - 1], seg = segments[i]; if (fromLevel !== null && prev.level !== fromLevel) continue; if (toLevel !== null && seg.level !== toLevel) continue; if (!held(i) || seg.start < since) continue; counted += 1; stepsAt.push(seg.start); } const base = Math.max(0, (opts.current ?? counted) - counted); const out: ChartPoint[] = [{ ts: Math.max(since, segments[0].start), val: base }]; let val = base; for (const ts of stepsAt) { val += 1; step(out, ts, val); } out.push({ ts: now, val }); return { points: out, mode: "count" }; } export function filterOutliers(points: ChartPoint[]): ChartPoint[] { if (points.length < 4) return points; const vals = points.map((p) => p.val).sort((a, b) => a - b); const q = (frac: number) => { const idx = (vals.length - 1) * frac; const lo = Math.floor(idx), hi = Math.ceil(idx); return vals[lo] + (vals[hi] - vals[lo]) * (idx - lo); }; const q1 = q(0.25), q3 = q(0.75), iqr = q3 - q1; if (iqr === 0) return points; // flat/degenerate — nothing to trim const lower = q1 - 1.5 * iqr, upper = q3 + 1.5 * iqr; const kept = points.filter((p) => p.val >= lower && p.val <= upper); return kept.length >= 2 ? kept : points; // never strip below a drawable series } export function renderTriggerSection(task: MaintenanceTask, ctx: SparklineContext) { const tc = task.trigger_config; if (!tc) return nothing; const L = ctx.lang; const info = task.trigger_entity_info; const infos = task.trigger_entity_infos; const friendlyName = info?.friendly_name || tc.entity_id || "—"; const entityId = tc.entity_id || ""; const entityIds = tc.entity_ids || (entityId ? [entityId] : []); const unit = info?.unit_of_measurement || ""; const currentVal = task.trigger_current_value; const triggerType = tc.type || "threshold"; const isMultiEntity = entityIds.length > 1; const spec = progressSpec(task, unit, ctx); return html`

${t("trigger", L)}

${isMultiEntity ? html`
${entityIds.length} ${t("entities", L)} (${tc.entity_logic || "any"})
${entityIds.map((eid, i) => html`${i > 0 ? ", " : ""} fireMoreInfo(ev, eid)}>${eid}`)}${tc.attribute ? ` → ${tc.attribute}` : ""}
` : html`
${friendlyName}
${entityId ? html` fireMoreInfo(ev, entityId)}>${entityId}` : ""}${tc.attribute ? ` → ${tc.attribute}` : ""}
`}
${task.trigger_active ? t("triggered", L) : t("ok", L)}
${spec ? renderProgress(spec, L) : currentVal !== null && currentVal !== undefined ? html`
${typeof currentVal === "number" ? fmtVal(currentVal, "", L) : currentVal} ${unit ? html`${unit}` : nothing}
` : nothing}
${triggerType === "threshold" ? html` ${tc.trigger_above != null ? html` ${t("threshold_above", L)}: ${tc.trigger_above} ${unit}` : nothing} ${tc.trigger_below != null ? html` ${t("threshold_below", L)}: ${tc.trigger_below} ${unit}` : nothing} ${tc.trigger_equals != null ? html` = ${tc.trigger_equals} ${unit}` : nothing} ${tc.trigger_not_equals != null ? html` ≠ ${tc.trigger_not_equals} ${unit}` : nothing} ${tc.trigger_for_minutes ? html` ${t("for_minutes", L)}: ${tc.trigger_for_minutes}` : nothing} ` : nothing} ${triggerType === "state_change" ? html` ${tc.trigger_target_changes != null ? html` ${t("target_changes", L)}: ${tc.trigger_target_changes}` : nothing} ` : nothing} ${triggerType === "runtime" ? html` ${tc.trigger_runtime_hours != null ? html` ${t("runtime_hours", L)}: ${tc.trigger_runtime_hours}h` : nothing} ` : nothing} ${triggerType === "compound" ? html` ${t("compound_logic", L)}: ${tc.compound_logic || (tc as any).operator || "AND"} ${(tc.conditions || []).map((cond: any, i: number) => html` ${i + 1}. ${t(cond.type || "unknown", L)}: ${cond.entity_id ? html` fireMoreInfo(ev, cond.entity_id)}>${cond.entity_id}` : ""} `)} ` : nothing}
${infos && infos.length > 1 ? html`
${infos.map(info => html` ${info.friendly_name} ( fireMoreInfo(ev, info.entity_id)}>${info.entity_id}) `)}
` : nothing} ${renderChart(task, unit, ctx)}
`; } /** Progress toward a trigger target — the "8,507 / 15,000 km · 57 %" story. * * All three accumulating trigger types map onto it: counters measure the * raw meter against a baseline; state_change counts and runtime hours * already accumulate from zero since the last reset. */ interface ProgressSpec { progress: number; target: number; unit: string; /** Raw meter reading, when it differs from the progress (counters). */ meter: number | null; } function progressSpec(task: MaintenanceTask, unit: string, ctx: SparklineContext): ProgressSpec | null { const tc = task.trigger_config; const cur = task.trigger_current_value; if (!tc || cur == null) return null; switch (tc.type || "threshold") { case "counter": { const target = tc.trigger_target_value; if (target == null || target <= 0) return null; if (!tc.trigger_delta_mode) { // Non-delta counters count from zero since the last reset — the raw // value IS the progress. Subtracting a baseline here showed a fresh // cycle as stuck at 0 (progress.ts branches the same way; bug audit // 2026-08-22). return { progress: Math.max(0, cur), target, unit, meter: null }; } const base = counterBaseline(task, rawStatsPoints(task, ctx)); return { progress: Math.max(0, cur - (base?.value ?? cur)), target, unit, meter: cur }; } case "state_change": { const target = tc.trigger_target_changes; if (target == null || target <= 0) return null; return { progress: Math.max(0, cur), target, unit: "", meter: null }; } case "runtime": { const target = tc.trigger_runtime_hours; if (target == null || target <= 0) return null; return { progress: Math.max(0, cur), target, unit: "h", meter: null }; } } return null; } /** Absolute baseline a counter's progress is measured from: the stored * delta baseline if present, else the reading nearest the last service. */ function counterBaseline(task: MaintenanceTask, rawPoints: ChartPoint[]): { value: number; ts: number | null } | null { if (task.trigger_baseline_value != null) { return { value: task.trigger_baseline_value, ts: lastServiceTs(task) }; } if (!rawPoints.length) return null; const ts = lastServiceTs(task); if (ts == null) return { value: rawPoints[0].val, ts: null }; let best = rawPoints[0]; let bestD = Math.abs(rawPoints[0].ts - ts); for (const p of rawPoints) { const d = Math.abs(p.ts - ts); if (d < bestD) { best = p; bestD = d; } } return { value: best.val, ts }; } function lastServiceTs(task: MaintenanceTask): number | null { const e = [...task.history] .filter((h) => h.type === "completed" || h.type === "reset") .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]; return e ? new Date(e.timestamp).getTime() : null; } /** "8,507 / 15,000 km · 57 %" header + progress bar (counter / state_change / runtime). */ function renderProgress(spec: ProgressSpec, L: string) { const pct = Math.min(999, Math.round((spec.progress / spec.target) * 100)); const level = pct >= 100 ? "over" : pct >= 75 ? "near" : "ok"; return html`
${fmtVal(spec.progress, "", L)} / ${fmtVal(spec.target, spec.unit, L)} ${pct} %
${t("chart_since_service", L)}${spec.meter != null ? html` · ${t("current", L)}: ${fmtVal(spec.meter, spec.unit, L)}` : nothing}
`; } /** Raw stats/history series for the task's entity (no transforms). */ function rawStatsPoints(task: MaintenanceTask, ctx: SparklineContext): ChartPoint[] { const tc = task.trigger_config; if (!tc) return []; const triggerType = tc.type || "threshold"; const entityId = tc.entity_id || ""; // Runtime accumulates hours DERIVED from the entity's on/off time — the // entity's own long-term statistics (an on/off ratio, or an unrelated raw // sensor value) are NOT that accumulation. Plot only the recorded per-cycle // trigger_value snapshots + the live current value instead. const statsPoints = triggerType === "runtime" ? [] : (ctx.detailStatsData.get(entityId) || []); const isCounter = ctx.isCounterEntity(tc); const points: ChartPoint[] = []; if (statsPoints.length >= 2) { for (const sp of statsPoints) { const pt: ChartPoint = { ts: sp.ts, val: sp.val }; if (!isCounter && sp.min != null && sp.max != null) { pt.min = sp.min; pt.max = sp.max; } points.push(pt); } } else { for (const h of task.history) { if (h.trigger_value != null) { points.push({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value }); } } } // #141: a history-fallback series already runs to "now" and plots the // STATE — appending trigger_current_value (a change counter on // state_change triggers) would graft a wrong-dimension point onto it. const fromHistory = !!entityId && !!ctx.historyFallbackIds?.has(entityId) && statsPoints.length >= 2; if (task.trigger_current_value != null && !fromHistory) { points.push({ ts: Date.now(), val: task.trigger_current_value }); } points.sort((a, b) => a.ts - b.ts); return points; } // Module-private chart assembly — maps task semantics onto chart props. function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext) { const tc = task.trigger_config; if (!tc) return nothing; const triggerType = tc.type || "threshold"; const entityId = tc.entity_id || ""; let points = rawStatsPoints(task, ctx); // #141 round 2: a state_change trigger on recorder history draws the // trigger's view (alert held / change count), not the raw entity state. let stateView: StateChangeView | null = null; if (triggerType === "state_change" && entityId && ctx.historyFallbackIds?.has(entityId)) { stateView = stateChangeView(points, tc, { since: lastServiceTs(task), current: task.trigger_current_value ?? null }); if (stateView) points = stateView.points; } // Runtime accumulates hours with no stored intermediate snapshots: synthesize // the current cycle (0 at the last service → live value now) so the chart // resets each completion and always has a drawable 2-point line. if ( triggerType === "runtime" && tc.trigger_runtime_hours && task.trigger_current_value != null ) { const cycleStart = lastServiceTs(task) ?? points[0]?.ts ?? (Date.now() - 86400000); points = [ { ts: cycleStart, val: 0 }, { ts: Date.now(), val: Math.max(0, task.trigger_current_value) }, ]; } if (ctx.hideOutliers) points = filterOutliers(points); // Still waiting for the first stats fetch → placeholder with the range chips. const loading = points.length < 2 && !!entityId && ctx.hasStatsService && !ctx.detailStatsData.has(entityId); if (points.length < 2 && !loading) return nothing; // Entities without long-term statistics (input_booleans, sensors without a // state_class) silently fall back to the sparse maintenance-event values — // say so instead of letting the thin chart look broken. const statsFetchedEmpty = !!entityId && ctx.detailStatsData.has(entityId) && (ctx.detailStatsData.get(entityId)?.length ?? 0) < 2; // The history fallback can span years; honor the selected window when enough // points remain (never crop below a drawable series). const cutoff = Date.now() - ctx.rangeDays * 86400000; const inRange = points.filter((p) => p.ts >= cutoff); if (inRange.length >= 2) points = inRange; let targetValue: number | null = null; let forceZero = false; if (triggerType === "counter" && tc.trigger_target_value != null && points.length) { // Progress domain: cumulative since the last service, never negative. // Baseline subtraction is a DELTA-mode concept — a non-delta counter's // raw value already is the cycle progress (bug audit 2026-08-22). if (tc.trigger_delta_mode) { const base = counterBaseline(task, points); if (base) { if (base.ts != null) { const kept = points.filter((p) => p.ts >= base.ts!); if (kept.length >= 2) points = kept; } points = points.map((p) => ({ ...p, val: Math.max(0, p.val - base.value) })); } } targetValue = tc.trigger_target_value; forceZero = true; } else if (triggerType === "state_change" && tc.trigger_target_changes && stateView?.mode !== "alarm") { // Change counts / runtime hours already accumulate from zero. A latch // ("alarm" view) has no meaningful target line: its axis is alert/ok. targetValue = tc.trigger_target_changes; forceZero = true; } else if (triggerType === "runtime" && tc.trigger_runtime_hours) { // Points are the synthesized current-cycle line (built above). targetValue = tc.trigger_runtime_hours; forceZero = true; } // Dashed degradation projection (30 days ahead of the last reading). Also // shown for a "stable"-classified slope when a real threshold prediction // exists — a slow 0.25 %/day decline classifies stable yet still crosses // the threshold in a foreseeable number of days. let projection: ChartPoint[] | null = null; // A projection pointing AWAY from the configured threshold is noise (the // ET-4800 finding: a refill-skewed rising rate on a below-threshold ink // sensor projected the ink level going UP) — draw it only when it heads // toward the bound, or when no bound is configured at all. const rate = task.degradation_rate; const pointsAway = rate != null && ((tc.trigger_below != null && tc.trigger_above == null && rate > 0) || (tc.trigger_above != null && tc.trigger_below == null && rate < 0)); if ( targetValue == null && rate != null && !pointsAway && (task.degradation_trend !== "stable" || task.days_until_threshold != null) && task.degradation_trend !== "insufficient_data" && points.length >= 2 ) { const lp = points[points.length - 1]; projection = [lp, { ts: lp.ts + 30 * 86400000, val: lp.val + rate * 30 }]; } const events: ChartEvent[] = task.history .filter((h) => ["completed", "skipped", "reset"].includes(h.type)) .map((h) => ({ ts: new Date(h.timestamp).getTime(), type: h.type })); return html` ) => ctx.setRangeDays(e.detail.days)} @outlier-toggle=${(e: CustomEvent<{ hide: boolean }>) => ctx.setHideOutliers(e.detail.hide)} > ${(() => { if (loading) return nothing; // #141: the series came from recorder state history (binary sensors // never have long-term statistics) — say so, incl. the shorter window. if (entityId && ctx.historyFallbackIds?.has(entityId) && !statsFetchedEmpty) { return html`
${t(stateView?.mode === "alarm" ? "chart_history_alarm" : stateView?.mode === "count" ? "chart_history_count" : "chart_history_fallback", ctx.lang)}
`; } if (statsFetchedEmpty) { return html`
${t("chart_no_stats", ctx.lang)}
`; } return nothing; })()} `; }