295 lines
14 KiB
TypeScript
295 lines
14 KiB
TypeScript
/** 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, formatNumber } from "../styles";
|
||
import { px } from "./chart-utils";
|
||
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 type TrendState = "approaching" | "stable" | "easing";
|
||
const TREND_GLYPHS: Record<TrendState, string> = { approaching: "\u2197", stable: "\u2192", easing: "\u2198" };
|
||
|
||
/** #150 follow-up: on narrow/tight rows the squeezed sparkline is unreadable
|
||
* — this reduces the same points to the one question the curve answered:
|
||
* is the value heading TOWARD the threshold, holding, or easing away?
|
||
* Direction is relative to the threshold (a dropping salt level and a
|
||
* rising runtime are both "approaching"). null = no arrow (no direction,
|
||
* compound, or fewer than two points). */
|
||
export function computeTrend(
|
||
row: TaskRow | MaintenanceTask,
|
||
miniStatsData: Map<string, StatisticsPoint[]>,
|
||
): TrendState | null {
|
||
const tc = row.trigger_config;
|
||
if (!tc?.entity_id) return null;
|
||
let toward: 1 | -1;
|
||
const type = tc.type || "threshold";
|
||
if (type === "threshold") {
|
||
if (tc.trigger_above != null) toward = 1;
|
||
else if (tc.trigger_below != null) toward = -1;
|
||
else return null; // discrete = / \u2260 levels have no gradient
|
||
} else if (type === "counter" || type === "runtime" || type === "state_change") {
|
||
toward = 1; // these accumulate toward their target
|
||
} else {
|
||
return null; // compound: conditions may point both ways
|
||
}
|
||
const statsPoints = miniStatsData.get(tc.entity_id) || [];
|
||
// Runtime and state_change accumulate SINCE the last completion — the
|
||
// history's trigger_value is the count at the previous trigger, a point
|
||
// from before the reset. Falling back to it read a freshly reset task
|
||
// (100 h at the trigger, 20 h now) as "easing" while the hours climb
|
||
// (bug review 2026-09-04). Recorder stats and the live value only.
|
||
const resetsOnCompletion = type === "runtime" || type === "state_change";
|
||
let points: { ts: number; val: number }[] =
|
||
statsPoints.length >= 2
|
||
? statsPoints.map((p) => ({ ts: p.ts, val: p.val }))
|
||
: resetsOnCompletion
|
||
? []
|
||
: (row.history || [])
|
||
.filter((h) => h.trigger_value != null)
|
||
.map((h) => ({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value as number }));
|
||
if (row.trigger_current_value != null) points = [...points, { ts: Date.now(), val: row.trigger_current_value }];
|
||
if (points.length < 2) return null;
|
||
points.sort((a, b) => a.ts - b.ts);
|
||
const vals = points.map((p) => p.val);
|
||
const range = Math.max(...vals) - Math.min(...vals);
|
||
const delta = vals[vals.length - 1] - vals[0];
|
||
// Significance is judged against the JOURNEY to the threshold, not the
|
||
// series' own noise band: a sensor crawling 50.0 -> 50.15 under a
|
||
// 60-threshold is flat, even though that move spans most of its range.
|
||
// A delta counter's series is the RAW reading (odometer), so its finish
|
||
// line is baseline + interval — judged against the bare interval, a car
|
||
// at 100 000 km with a 15 000 km service interval read "stable" forever
|
||
// (bug review 2026-09-04).
|
||
const counterTarget =
|
||
tc.trigger_delta_mode && row.trigger_baseline_value != null && tc.trigger_target_value != null
|
||
? row.trigger_baseline_value + tc.trigger_target_value
|
||
: tc.trigger_target_value;
|
||
const target =
|
||
type === "threshold" ? (tc.trigger_above ?? tc.trigger_below)
|
||
: type === "counter" ? counterTarget
|
||
: type === "runtime" ? tc.trigger_runtime_hours
|
||
: tc.trigger_target_changes;
|
||
const base = typeof target === "number" ? Math.max(Math.abs(target - vals[0]), range) : range;
|
||
if (base === 0 || Math.abs(delta) < base * (typeof target === "number" ? 0.05 : 0.15)) return "stable";
|
||
return Math.sign(delta) === toward ? "approaching" : "easing";
|
||
}
|
||
|
||
export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { trend?: TrendState | null; lang?: string }) {
|
||
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 && below != null) {
|
||
// Both limits = a band the reading must stay inside (OR-ed on the
|
||
// server): the bar measures the distance from the band's centre, so a
|
||
// reading near EITHER edge fills it. Measuring from `below` up to
|
||
// `above` painted a reading just under `below` as 0 % / green
|
||
// although it had already triggered (bug review 2026-09-04).
|
||
const mid = (above + below) / 2;
|
||
const half = Math.abs(above - below) / 2 || 1;
|
||
pct = Math.min(100, Math.max(0, (Math.abs(val - mid) / half) * 100));
|
||
const nearer = Math.abs(val - above) <= Math.abs(val - below) ? above : below;
|
||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(nearer, opts?.lang)} ${unit}`;
|
||
} else if (above != null) {
|
||
// Progress toward the upper limit, from a sign-aware floor: 0 for a
|
||
// positive limit; for a negative one (freezer "warn above −15 °C")
|
||
// the entity's min or 2× the limit — anchoring at 0 put the floor
|
||
// ABOVE the limit and rendered every safe reading as a full red bar
|
||
// (bug review 2026-09-04).
|
||
const entityMin = row.trigger_entity_info?.min;
|
||
const low = above > 0 ? 0 : entityMin != null && entityMin < above ? entityMin : above < 0 ? 2 * above : -100;
|
||
const range = above - low || 1;
|
||
pct = Math.min(100, Math.max(0, ((val - low) / range) * 100));
|
||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(above, opts?.lang)} ${unit}`;
|
||
} else if (below != null) {
|
||
// Progress toward the lower limit (inverted: lower is worse), from a
|
||
// sign-aware ceiling: the entity max, else 2× the limit for a
|
||
// positive one and 0 for a negative one (2× a negative limit is BELOW
|
||
// it). A fixed ceiling — val*2 made the bar wander with the reading.
|
||
const entityMax = row.trigger_entity_info?.max;
|
||
const high = entityMax != null && entityMax > below ? entityMax : below > 0 ? below * 2 : below < 0 ? 0 : 100;
|
||
const range = high - below || 1;
|
||
pct = Math.min(100, Math.max(0, ((high - val) / range) * 100));
|
||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(below, opts?.lang)} ${unit}`;
|
||
} else if (tc.trigger_equals != null || tc.trigger_not_equals != null) {
|
||
// Discrete = / ≠ levels have no meaningful gradient — binary bar,
|
||
// same treatment as compound.
|
||
const target = tc.trigger_equals != null ? `= ${tc.trigger_equals}` : `≠ ${tc.trigger_not_equals}`;
|
||
label = `${formatNumber(val, opts?.lang, 1)} (${target}${unit ? ` ${unit}` : ""})`;
|
||
pct = row.trigger_active ? 100 : 0;
|
||
} 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 = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(target, opts?.lang)} ${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 = `${formatNumber(val, opts?.lang, 0)} / ${formatNumber(target, opts?.lang, 0)}`;
|
||
} 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 = `${formatNumber(val, opts?.lang, 1)}h / ${formatNumber(target, opts?.lang)}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`
|
||
<div class="trigger-progress">
|
||
<div class="trigger-progress-bar">
|
||
<div class="trigger-progress-fill${triggerOverflow ? " overflow" : ""}" style="width:${pct}%;background:${barColor}"></div>
|
||
</div>
|
||
<span class="trigger-progress-label">${label}${opts?.trend
|
||
? html` <i class="trend-arrow trend-${opts.trend}" title="${t(`trend_${opts.trend}`, opts.lang ?? "en")}" aria-label="${t(`trend_${opts.trend}`, opts.lang ?? "en")}">${TREND_GLYPHS[opts.trend]}</i>`
|
||
: nothing}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/** A mini sparkline for overview rows (tiny trend line). */
|
||
export function renderMiniSparkline(
|
||
row: TaskRow | MaintenanceTask,
|
||
miniStatsData: Map<string, StatisticsPoint[]>,
|
||
lang: string,
|
||
) {
|
||
if (!row.trigger_config?.entity_id) return nothing;
|
||
const entityId = row.trigger_config.entity_id;
|
||
|
||
// PRIMARY: HA recorder statistics (daily, last 14 days)
|
||
const statsPoints = miniStatsData.get(entityId) || [];
|
||
|
||
let points: { ts: number; val: number }[] = [];
|
||
|
||
if (statsPoints.length >= 2) {
|
||
points = statsPoints.map((p) => ({ ts: p.ts, val: p.val }));
|
||
} else {
|
||
// FALLBACK: original behavior from task history
|
||
if (!row.history) return nothing;
|
||
for (const h of row.history) {
|
||
if (h.trigger_value != null) {
|
||
points.push({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (row.trigger_current_value != null) {
|
||
points.push({ ts: Date.now(), val: row.trigger_current_value });
|
||
}
|
||
if (points.length < 2) return nothing;
|
||
points.sort((a, b) => a.ts - b.ts);
|
||
|
||
const W = MINI_SPARKLINE_W, H = MINI_SPARKLINE_H;
|
||
const vals = points.map((p) => p.val);
|
||
let minV = Math.min(...vals), maxV = Math.max(...vals);
|
||
const range = maxV - minV || 1;
|
||
minV -= range * 0.1; maxV += range * 0.1;
|
||
const tsMin = points[0].ts, tsMax = points[points.length - 1].ts;
|
||
const tsR = tsMax - tsMin || 1;
|
||
|
||
const toX = (ts: number) => ((ts - tsMin) / tsR) * W;
|
||
const toY = (v: number) => 2 + (1 - (v - minV) / (maxV - minV)) * (H - 4);
|
||
|
||
// Downsample for tiny SVG
|
||
let renderPoints = points;
|
||
if (renderPoints.length > MAX_MINI_POINTS) {
|
||
const step = Math.ceil(renderPoints.length / MAX_MINI_POINTS);
|
||
renderPoints = renderPoints.filter((_, i) => i % step === 0 || i === renderPoints.length - 1);
|
||
}
|
||
|
||
const pts = renderPoints.map((p) => `${px(toX(p.ts))},${px(toY(p.val))}`).join(" ");
|
||
// Match the detail chart's semantics: an actively-triggered sensor tints red.
|
||
const stroke = row.trigger_active
|
||
? "var(--error-color, #f44336)"
|
||
: "var(--primary-color)";
|
||
|
||
return html`
|
||
<svg class="mini-sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="${t("chart_mini_sparkline", lang)}">
|
||
<polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="1.5" stroke-linejoin="round" />
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
/** A detailed days-progress bar (last performed → next due) for the detail view. */
|
||
export function renderDaysProgress(task: MaintenanceTask, lang: string) {
|
||
const L = lang;
|
||
if (task.days_until_due == null || !task.interval_days || task.interval_days <= 0) return nothing;
|
||
|
||
// Unit-aware (issue #59): a "1 year" task has span ≈365 d, not 1 d.
|
||
const { pct, overflow: daysOverflow } = daysProgress(
|
||
task.interval_days, task.days_until_due, task.interval_unit,
|
||
);
|
||
|
||
let barColor = "var(--success-color, #4caf50)";
|
||
if (task.status === "overdue") barColor = "var(--error-color, #f44336)";
|
||
else if (task.status === "due_soon") barColor = "var(--warning-color, #ff9800)";
|
||
|
||
return html`
|
||
<div class="days-progress">
|
||
<div class="days-progress-labels">
|
||
<span>${task.last_performed ? `${t("last_performed", L)}: ${formatDate(task.last_performed, L)}` : ""}</span>
|
||
<span>${task.next_due ? `${t("next_due", L)}: ${formatDate(task.next_due, L)}` : ""}</span>
|
||
</div>
|
||
<div class="days-progress-bar" role="progressbar" aria-valuenow="${Math.round(pct)}" aria-valuemin="0" aria-valuemax="100" aria-label="${t("days_progress", L)}">
|
||
<div class="days-progress-fill${daysOverflow ? " overflow" : ""}" style="width:${pct}%;background:${barColor}"></div>
|
||
</div>
|
||
<div class="days-progress-text">${formatDueDays(task.days_until_due, L)}</div>
|
||
</div>
|
||
`;
|
||
}
|