/** 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; 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. */ 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_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; 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 }); } } } if (task.trigger_current_value != null) { 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); // 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. 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) { // Change counts / runtime hours already accumulate from zero. 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; if ( targetValue == null && task.degradation_rate != null && (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 + task.degradation_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)} > ${statsFetchedEmpty && !loading ? html`
${t("chart_no_stats", ctx.lang)}
` : nothing} `; }