/** Progress renderers shared by the dashboard rows, the object-detail view and * the task-detail overview tab. * * These are pure of component state — everything they need is passed in — so * they live outside the panel (which keeps the 2.5k-line panel focused on * routing/data) yet still render into its shadow root, where the CSS lives. * * - renderTriggerProgress: a "current / target" bar for a sensor trigger * (threshold / counter / state_change / runtime / compound). * - renderMiniSparkline: a tiny 60x20 trend line for overview rows. * - renderDaysProgress: the detailed last→next due bar for the detail view. */ import { html, nothing } from "lit"; import { t, formatDate, formatDueDays } from "../styles"; import { daysProgress } from "../helpers/interval"; import type { MaintenanceTask, TaskRow, StatisticsPoint } from "../types"; const MINI_SPARKLINE_W = 60; const MINI_SPARKLINE_H = 20; const MAX_MINI_POINTS = 30; export function renderTriggerProgress(row: TaskRow | MaintenanceTask) { const tc = row.trigger_config ?? null; if (!tc) return nothing; const triggerType = tc.type || "threshold"; const unit = row.trigger_entity_info?.unit_of_measurement ?? ""; let pct = 0; let label = ""; if (triggerType === "threshold") { const val = row.trigger_current_value ?? null; if (val == null) return nothing; const above = tc.trigger_above; const below = tc.trigger_below; if (above != null) { // Progress toward upper limit const low = below ?? 0; const range = above - low || 1; pct = Math.min(100, Math.max(0, ((val - low) / range) * 100)); label = `${val.toFixed(1)} / ${above} ${unit}`; } else if (below != null) { // Progress toward lower limit (inverted: lower is worse) // Use entity max, or 2x the threshold as a stable "safe" reference. // Using val*2 caused a dynamic ceiling that distorted the bar. const entityMax = row.trigger_entity_info?.max; const high = entityMax ?? ((below * 2) || 100); const range = high - below || 1; pct = Math.min(100, Math.max(0, ((high - val) / range) * 100)); label = `${val.toFixed(1)} / ${below} ${unit}`; } else { return nothing; } } else if (triggerType === "counter") { const target = tc.trigger_target_value || 1; let val: number | null; if (tc.trigger_delta_mode) { // Delta mode: NEVER fall back to the raw counter — a lifetime odometer // at 27,000 km with a 15,000 km interval would render as a full red // "27000/15000" bar right after adoption, before the baseline reaches // the read-model (issue #102). Compute from the baseline if the delta // itself isn't exposed yet; show nothing rather than lie. val = row.trigger_current_delta ?? null; if (val == null && row.trigger_baseline_value != null && row.trigger_current_value != null) { val = row.trigger_current_value - row.trigger_baseline_value; } } else { val = row.trigger_current_value ?? null; } if (val == null) return nothing; pct = Math.min(100, Math.max(0, (val / target) * 100)); label = `${val.toFixed(1)} / ${target} ${unit}`; } else if (triggerType === "state_change") { const target = tc.trigger_target_changes || 1; const val = row.trigger_current_value ?? null; if (val == null) return nothing; pct = Math.min(100, Math.max(0, (val / target) * 100)); label = `${Math.round(val)} / ${target}`; } else if (triggerType === "runtime") { const target = tc.trigger_runtime_hours || 100; const val = row.trigger_current_value ?? null; if (val == null) return nothing; pct = Math.min(100, Math.max(0, (val / target) * 100)); label = `${val.toFixed(1)}h / ${target}h`; } else if (triggerType === "compound") { const logic = tc.compound_logic || (tc as any).operator || "AND"; const condCount = tc.conditions?.length || 0; label = `${logic} (${condCount})`; pct = row.trigger_active ? 100 : 0; } else { return nothing; } const triggerOverflow = pct >= 100; const barColor = pct > 90 ? "var(--error-color, #f44336)" : pct > 70 ? "var(--warning-color, #ff9800)" : "var(--primary-color)"; return html`