New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,90 @@
/** Shared chart helpers: nice axis ticks and consistent number/date formats. */
/** Round axis ticks covering [min, max] — the classic "nice numbers" loop.
* Returns the tick values plus the (possibly widened) nice domain, so the
* chart can plot against the same bounds the labels describe. */
export function niceTicks(
min: number,
max: number,
targetCount = 4,
): { ticks: number[]; niceMin: number; niceMax: number } {
if (!isFinite(min) || !isFinite(max)) return { ticks: [], niceMin: 0, niceMax: 1 };
if (min === max) {
// Degenerate domain: pad around the value so the line isn't glued to an edge.
const pad = Math.abs(min) * 0.1 || 1;
min -= pad;
max += pad;
}
const span = max - min;
const step0 = Math.pow(10, Math.floor(Math.log10(span / Math.max(1, targetCount))));
let step = step0;
for (const m of [1, 2, 5, 10]) {
step = step0 * m;
if (span / step <= targetCount + 0.5) break;
}
const niceMin = Math.floor(min / step) * step;
const niceMax = Math.ceil(max / step) * step;
const ticks: number[] = [];
// Epsilon guards float drift (0.30000000000000004 style) at tick boundaries.
for (let v = niceMin; v <= niceMax + step * 1e-6; v += step) {
ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v);
}
return { ticks, niceMin, niceMax };
}
/** Compact, consistent number format: 1.2M / 88k / 730 / 7.5 / 0.42 */
export function fmtNum(v: number): string {
const a = Math.abs(v);
if (a >= 1_000_000) return trimZero((v / 1_000_000).toFixed(a >= 10_000_000 ? 0 : 1)) + "M";
if (a >= 10_000) return trimZero((v / 1000).toFixed(0)) + "k";
if (a >= 1000) return trimZero((v / 1000).toFixed(1)) + "k";
if (a >= 100) return v.toFixed(0);
if (a >= 10) return trimZero(v.toFixed(1));
if (a >= 1) return trimZero(v.toFixed(1));
if (a === 0) return "0";
return trimZero(v.toFixed(2));
}
function trimZero(s: string): string {
return s.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
}
/** Full-precision value + unit for tooltips (locale-aware grouping). */
export function fmtVal(v: number, unit: string, lang: string): string {
const s = v.toLocaleString(lang, { maximumFractionDigits: Math.abs(v) >= 100 ? 0 : 1 });
return unit ? `${s} ${unit}` : s;
}
/** Short date tick: "3. Juli" / "Jul 3", with year appended when the plotted
* range spans more than ~10 months (disambiguates "Jun … Feb" sequences). */
export function fmtDateTick(ts: number, lang: string, withYear: boolean): string {
const d = new Date(ts);
const opts: Intl.DateTimeFormatOptions = withYear
? { month: "short", day: "numeric", year: "2-digit" }
: { month: "short", day: "numeric" };
return d.toLocaleDateString(lang, opts);
}
/** Date+time for crosshair/tooltip labels. */
export function fmtDateTime(ts: number, lang: string): string {
return new Date(ts).toLocaleDateString(lang, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/** Whether date ticks need the year: the range crosses a calendar-year
* boundary (a "Jun … Feb" sequence would otherwise read backwards). */
export function needsYear(tsMin: number, tsMax: number): boolean {
return new Date(tsMin).getFullYear() !== new Date(tsMax).getFullYear();
}
/** Evenly-spaced x-axis tick timestamps (first/last inclusive). */
export function timeTicks(tsMin: number, tsMax: number, count: number): number[] {
if (count < 2 || tsMax <= tsMin) return [tsMin, tsMax];
const out: number[] = [];
for (let i = 0; i < count; i++) out.push(tsMin + ((tsMax - tsMin) * i) / (count - 1));
return out;
}
@@ -0,0 +1,149 @@
/** Cost/duration chart renderers (task detail).
*
* Completions are plotted on a true time axis (a completion 8 months ago sits
* visibly further away than one from last week), with round y-ticks per axis
* and year-aware date labels once the span crosses years — "Jun 3 '25" cannot
* be misread as coming after "Feb 21 '26".
*/
import { html, svg, nothing } from "lit";
import { t } from "../styles";
import { niceTicks, fmtNum, fmtDateTick, timeTicks, needsYear } from "./chart-utils";
import type { MaintenanceTask } from "../types";
const COST_CHART_H = 200;
const PAD_T = 10;
const PAD_B = 22;
export function renderCostDurationCard(
task: MaintenanceTask,
lang: string,
toggle: "cost" | "duration" | "both",
setToggle: (val: "cost" | "duration" | "both") => void,
) {
const completedEntries = task.history.filter((h) => h.type === "completed" && (h.cost != null || h.duration != null));
if (completedEntries.length < 2) return nothing;
const anyCost = completedEntries.some((h) => (h.cost ?? 0) > 0);
const anyDuration = completedEntries.some((h) => (h.duration ?? 0) > 0);
if (!anyCost && !anyDuration) return nothing;
return html`
<div class="cost-duration-card">
<div class="card-header">
<h3>${t("cost_duration_chart", lang)}</h3>
<div class="toggle-buttons">
${anyCost ? html`<button
class="toggle-btn ${toggle === 'cost' ? 'active' : ''}"
@click=${() => setToggle('cost')}>
${t("cost", lang)}
</button>` : nothing}
${anyCost && anyDuration ? html`<button
class="toggle-btn ${toggle === 'both' ? 'active' : ''}"
@click=${() => setToggle('both')}>
${t("both", lang)}
</button>` : nothing}
${anyDuration ? html`<button
class="toggle-btn ${toggle === 'duration' ? 'active' : ''}"
@click=${() => setToggle('duration')}>
${t("duration", lang)}
</button>` : nothing}
</div>
</div>
${renderHistoryChart(task, lang, toggle)}
</div>
`;
}
function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost" | "duration" | "both") {
const entries = task.history
.filter((h) => h.type === "completed" && (h.cost != null || h.duration != null))
.map((h) => ({ ts: new Date(h.timestamp).getTime(), cost: h.cost ?? 0, duration: h.duration ?? 0 }))
.sort((a, b) => a.ts - b.ts);
if (entries.length < 2) return nothing;
const dataCost = entries.some((e) => e.cost > 0);
const dataDuration = entries.some((e) => e.duration > 0);
if (!dataCost && !dataDuration) return nothing;
const hasCost = toggle !== "duration" && dataCost;
const hasDuration = toggle !== "cost" && dataDuration;
const showCost = hasCost || (!hasDuration && dataCost);
const showDuration = hasDuration || (!hasCost && dataDuration);
const W = 640; // wide viewBox; the container scales it to full card width
const H = COST_CHART_H;
const PAD_L = showCost ? 44 : 12;
const PAD_R = showDuration ? 44 : 12;
const plotW = W - PAD_L - PAD_R;
const plotB = H - PAD_B;
const plotH = plotB - PAD_T;
// True time axis with a padded domain so edge bars don't clip.
const tsMin = entries[0].ts;
const tsMax = entries[entries.length - 1].ts;
const tsPad = (tsMax - tsMin || 86400000) * 0.05;
const t0 = tsMin - tsPad;
const t1 = tsMax + tsPad;
const withYear = needsYear(tsMin, tsMax);
const toX = (ts: number) => PAD_L + ((ts - t0) / (t1 - t0)) * plotW;
const costAxis = niceTicks(0, Math.max(...entries.map((e) => e.cost)) || 1, 3);
const durAxis = niceTicks(0, Math.max(...entries.map((e) => e.duration)) || 1, 3);
const costY = (v: number) => PAD_T + (1 - v / (costAxis.niceMax || 1)) * plotH;
const durY = (v: number) => PAD_T + (1 - v / (durAxis.niceMax || 1)) * plotH;
// Bars keep a readable width even when completions crowd together.
const minGap = entries.length > 1
? Math.min(...entries.slice(1).map((e, i) => toX(e.ts) - toX(entries[i].ts)))
: plotW;
const barW = Math.max(6, Math.min(22, minGap * 0.55));
const xTicks = timeTicks(tsMin, tsMax, Math.max(2, Math.min(4, entries.length)));
return html`
<div class="sparkline-container">
<svg class="history-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${t("chart_history", lang)}">
${showCost ? costAxis.ticks.map((v) => {
const y = costY(v);
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
return svg`
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}" stroke="var(--divider-color)" stroke-width="1" opacity="0.55" />
<text x="${PAD_L - 6}" y="${(y + 3.5).toFixed(1)}" text-anchor="end" fill="var(--primary-color)" font-size="10.5">${fmtNum(v)}€</text>`;
}) : nothing}
${showDuration ? durAxis.ticks.map((v) => {
const y = durY(v);
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
return svg`<text x="${W - PAD_R + 6}" y="${(y + 3.5).toFixed(1)}" text-anchor="start" fill="var(--accent-color, #ff9800)" font-size="10.5">${fmtNum(v)}m</text>`;
}) : nothing}
${showCost ? entries.filter((e) => e.cost > 0).map((e) => svg`
<rect x="${(toX(e.ts) - barW / 2).toFixed(1)}" y="${costY(e.cost).toFixed(1)}" width="${barW.toFixed(1)}" height="${(plotB - costY(e.cost)).toFixed(1)}"
fill="var(--primary-color)" opacity="0.6" rx="2">
<title>${fmtDateTick(e.ts, lang, true)}: ${e.cost.toLocaleString(lang)}${e.duration ? ` · ${e.duration}m` : ""}</title>
</rect>
`) : nothing}
${showDuration ? svg`
<polyline points="${entries.map((e) => `${toX(e.ts).toFixed(1)},${durY(e.duration).toFixed(1)}`).join(" ")}"
fill="none" stroke="var(--accent-color, #ff9800)" stroke-width="2" stroke-linejoin="round" />
${entries.map((e) => svg`
<circle cx="${toX(e.ts).toFixed(1)}" cy="${durY(e.duration).toFixed(1)}" r="3.5" fill="var(--accent-color, #ff9800)">
<title>${fmtDateTick(e.ts, lang, true)}: ${e.duration}m${e.cost ? ` · ${e.cost.toLocaleString(lang)}` : ""}</title>
</circle>
`)}
` : nothing}
<line x1="${PAD_L}" y1="${plotB}" x2="${W - PAD_R}" y2="${plotB}" stroke="var(--divider-color)" stroke-width="1" />
${xTicks.map((ts, i) => {
const anchor = i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle";
return svg`<text x="${toX(ts).toFixed(1)}" y="${H - 6}" text-anchor="${anchor}" fill="var(--secondary-text-color)" font-size="10">${fmtDateTick(ts, lang, withYear)}</text>`;
})}
</svg>
</div>
<div class="chart-legend">
${showCost ? html`<span class="legend-item"><span class="legend-swatch" style="background:var(--primary-color);opacity:0.6"></span>${t("cost", lang)}</span>` : nothing}
${showDuration ? html`<span class="legend-item"><span class="legend-swatch" style="background:var(--accent-color, #ff9800)"></span>${t("duration", lang)}</span>` : nothing}
</div>
`;
}
@@ -0,0 +1,122 @@
/** Task history sub-view: filter chips + search + the timeline of entries.
*
* Extracted from the panel (renderers/ pattern). State the panel owns —
* the active filter, the search text, the currency symbol — plus the
* callbacks that mutate it or open the edit dialog are passed in via
* HistoryContext, keeping these functions pure of component internals.
*/
import { html, nothing } from "lit";
import { t, formatDateTime, STATUS_ICONS } from "../styles";
import type { MaintenanceTask, HistoryEntry, HomeAssistant } from "../types";
import "../components/history-photo";
export interface HistoryContext {
lang: string;
/** Home Assistant object — used to sign completion-photo URLs. */
hass: HomeAssistant;
/** Active type filter, or null for "all". */
filter: string | null;
/** Free-text search over entry notes. */
search: string;
/** Currency symbol for cost display (defaults to €). */
currencySymbol: string;
setFilter: (filter: string | null) => void;
setSearch: (search: string) => void;
openEdit: (entry: HistoryEntry) => void;
/** v2.20 (#83): unit + delta-vs-previous for reading-task entries. */
readingUnit?: string | null;
readingDelta?: (entry: HistoryEntry) => number | null;
}
const _FILTER_TYPES = ["completed", "skipped", "missed", "reset", "triggered"] as const;
export function renderHistoryFilters(task: MaintenanceTask, ctx: HistoryContext) {
const L = ctx.lang;
return html`
<div class="history-filters-new">
<div class="filter-chips">
${_FILTER_TYPES.map((type) => {
const count = task.history.filter((h) => h.type === type).length;
if (count === 0) return nothing;
return html`
<span class="filter-chip ${ctx.filter === type ? "active" : ""}"
@click=${() => ctx.setFilter(ctx.filter === type ? null : type)}>
${t(type, L)} (${count})
</span>
`;
})}
${ctx.filter ? html`<span class="filter-chip clear" @click=${() => ctx.setFilter(null)}>${t("show_all", L)}</span>` : nothing}
</div>
<div class="filter-controls">
<input type="text" class="search-input" placeholder="${t("search_notes", L)}..." .value=${ctx.search} @input=${(e: Event) => ctx.setSearch((e.target as HTMLInputElement).value)} />
</div>
</div>
`;
}
export function renderHistoryList(task: MaintenanceTask, ctx: HistoryContext) {
const L = ctx.lang;
let filtered = ctx.filter
? task.history.filter((h) => h.type === ctx.filter)
: task.history;
// Apply search filter
if (ctx.search) {
const search = ctx.search.toLowerCase();
filtered = filtered.filter((h) => h.notes?.toLowerCase().includes(search));
}
if (filtered.length === 0) {
return html`<p class="empty">${t("no_history", L)}</p>`;
}
return html`
<div class="history-timeline">
${[...filtered].reverse().map((entry: HistoryEntry) => renderHistoryEntry(entry, ctx))}
</div>
`;
}
export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryContext) {
const L = ctx.lang;
// v2.2.0: only "lifecycle + cost-bearing" entries are user-editable.
// Triggers are auto-generated by sensors and shouldn't be retroactively
// rewritten. Allow edits for completed / reset / skipped entries.
const editable = ["completed", "reset", "skipped"].includes(entry.type);
return html`
<div class="history-entry">
<div class="history-icon ${entry.type}">
<ha-icon .icon=${STATUS_ICONS[entry.type] || "mdi:circle"}></ha-icon>
</div>
<div class="history-content">
<div class="history-row">
<strong>${t(entry.type, L)}</strong>
${editable
? html`<button class="history-edit-btn"
title=${t("history_edit_button", L) || "Edit entry"}
@click=${() => ctx.openEdit(entry)}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>`
: nothing}
</div>
<div class="history-date">${formatDateTime(entry.timestamp, L)}</div>
${entry.notes ? html`<div>${entry.notes}</div>` : nothing}
${entry.photo_doc_id
? html`<maintenance-history-photo .hass=${ctx.hass} .docId=${entry.photo_doc_id}></maintenance-history-photo>`
: nothing}
<div class="history-details">
${entry.cost != null ? html`<span>${t("cost", L)}: ${entry.cost.toFixed(2)} ${ctx.currencySymbol}</span>` : nothing}
${entry.duration != null ? html`<span>${t("duration", L)}: ${entry.duration} min</span>` : nothing}
${entry.trigger_value != null ? html`<span>${t("trigger_val", L)}: ${entry.trigger_value}</span>` : nothing}
${entry.reading_value != null
? html`<span>${t("reading_label", L)}: ${entry.reading_value}${ctx.readingUnit ? ` ${ctx.readingUnit}` : ""}${(() => {
const d = ctx.readingDelta?.(entry);
return d == null ? "" : ` (${d >= 0 ? "+" : ""}${Number(d.toFixed(3))})`;
})()}</span>`
: nothing}
</div>
</div>
</div>
`;
}
@@ -0,0 +1,60 @@
/** Sensor prediction section renderer. */
import { html, nothing } from "lit";
import { t, formatDate, fireMoreInfo } from "../styles";
import type { MaintenanceTask, AdvancedFeatures } from "../types";
export function renderPredictionSection(task: MaintenanceTask, lang: string, features: AdvancedFeatures) {
const hasDegradation = task.degradation_trend != null && task.degradation_trend !== "insufficient_data";
const hasThreshold = task.days_until_threshold != null;
const hasEnv = task.environmental_factor != null && task.environmental_factor !== 1.0;
if (!hasDegradation && !hasThreshold && !hasEnv) return nothing;
const trendIcon = task.degradation_trend === "rising"
? "M16,6L18.29,8.29L13.41,13.17L9.41,9.17L2,16.59L3.41,18L9.41,12L13.41,16L19.71,9.71L22,12V6H16Z"
: task.degradation_trend === "falling"
? "M16,18L18.29,15.71L13.41,10.83L9.41,14.83L2,7.41L3.41,6L9.41,12L13.41,8L19.71,14.29L22,12V18H16Z"
: "M22,12L18,8V11H3V13H18V16L22,12Z";
return html`
<div class="prediction-section">
${task.sensor_prediction_urgency ? html`
<div class="prediction-urgency-banner">
<ha-svg-icon path="M1,21H23L12,2L1,21M12,18A1,1 0 0,1 11,17A1,1 0 0,1 12,16A1,1 0 0,1 13,17A1,1 0 0,1 12,18M13,15H11V10H13V15Z"></ha-svg-icon>
${t("sensor_prediction_urgency", lang).replace("{days}", String(Math.round(task.days_until_threshold || 0)))}
</div>
` : nothing}
<div class="prediction-title">
<ha-svg-icon path="M2,2V4H7V2H2M22,2V4H13V2H22M7,7V9H2V7H7M22,7V9H13V7H22M7,12V14H2V12H7M22,12V14H13V12H22M7,17V19H2V17H7M22,17V19H13V17H22M9,2V19L12,22L15,19V2H9M11,4H13V17.17L12,18.17L11,17.17V4Z"></ha-svg-icon>
${t("sensor_prediction", lang)}
</div>
<div class="prediction-grid">
${hasDegradation ? html`
<div class="prediction-item">
<ha-svg-icon path="${trendIcon}"></ha-svg-icon>
<span class="prediction-label">${t("degradation_trend", lang)}</span>
<span class="prediction-value ${task.degradation_trend}">${t("trend_" + task.degradation_trend, lang)}</span>
${task.degradation_rate != null ? html`<span class="prediction-rate">${task.degradation_rate > 0 ? "+" : ""}${Math.abs(task.degradation_rate) >= 10 ? Math.round(task.degradation_rate).toLocaleString() : task.degradation_rate.toFixed(1)} ${task.trigger_entity_info?.unit_of_measurement || ""}/${t("day_short", lang)}</span>` : nothing}
</div>
` : nothing}
${hasThreshold ? html`
<div class="prediction-item">
<ha-svg-icon path="M12,20A7,7 0 0,1 5,13A7,7 0 0,1 12,6A7,7 0 0,1 19,13A7,7 0 0,1 12,20M12,4A9,9 0 0,0 3,13A9,9 0 0,0 12,22A9,9 0 0,0 21,13A9,9 0 0,0 12,4M12.5,8H11V14L15.75,16.85L16.5,15.62L12.5,13.25V8M7.88,3.39L6.6,1.86L2,5.71L3.29,7.24L7.88,3.39M22,5.72L17.4,1.86L16.11,3.39L20.71,7.25L22,5.72Z"></ha-svg-icon>
<span class="prediction-label">${t("days_until_threshold", lang)}</span>
<span class="prediction-value prediction-days${task.days_until_threshold === 0 ? " exceeded" : task.sensor_prediction_urgency ? " urgent" : ""}">${task.days_until_threshold === 0 ? t("threshold_exceeded", lang) : "~" + Math.round(task.days_until_threshold!) + " " + t("days", lang)}</span>
${task.threshold_prediction_date ? html`<span class="prediction-date">${formatDate(task.threshold_prediction_date, lang)}</span>` : nothing}
${task.threshold_prediction_confidence ? html`<span class="confidence-dot ${task.threshold_prediction_confidence}"></span>` : nothing}
</div>
` : nothing}
${hasEnv && features.environmental ? html`
<div class="prediction-item">
<ha-svg-icon path="M15,13V5A3,3 0 0,0 12,2A3,3 0 0,0 9,5V13A5,5 0 0,0 7,17A5,5 0 0,0 12,22A5,5 0 0,0 17,17A5,5 0 0,0 15,13M12,4A1,1 0 0,1 13,5V8H11V5A1,1 0 0,1 12,4Z"></ha-svg-icon>
<span class="prediction-label">${t("environmental_adjustment", lang)}</span>
<span class="prediction-value">${task.environmental_factor!.toFixed(2)}x</span>
${task.environmental_entity ? html`<span class="prediction-entity entity-link" @click=${(ev: Event) => fireMoreInfo(ev, task.environmental_entity!)}>${task.environmental_entity}</span>` : nothing}
</div>
` : nothing}
</div>
</div>
`;
}
@@ -0,0 +1,189 @@
/** 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;
// Use delta if available, otherwise current value
const delta = row.trigger_current_delta ?? null;
const val = delta ?? (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`
<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}</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) => `${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).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>
`;
}
@@ -0,0 +1,49 @@
/** Shared recommendation-card visuals (bars + confidence badge).
*
* The visual layout is shared between the panel's task-detail page
* (maintenance-panel._renderRecommendationCard) and the Lovelace
* task-quick-actions dialog (task-quick-actions-dialog._renderRecommendation).
*
* The two contexts disagree on:
* - Whether ha-button or native <button> is used (ha-button isn't always
* registered in Lovelace context — same lazy-load issue that bit
* complete-dialog with ha-textfield in #50)
* - Which actions are exposed (panel has Apply/Reanalyze/Dismiss; the
* dialog has Apply/Reanalyze, Dismiss is panel-only)
*
* So this renderer only emits the **bars + labels + confidence badge** —
* every caller wraps it with its own header + actions row. CSS classes
* are in sharedStyles (.interval-comparison, .interval-bar,
* .interval-label, .interval-visual.current/.suggested, .confidence-badge).
*/
import { html, type TemplateResult } from "lit";
import { t } from "../styles";
export function renderRecommendationBars(
current: number | null | undefined,
suggested: number,
confidence: string,
lang: string,
): TemplateResult {
const maxBar = Math.max(current || 1, suggested);
return html`
<div class="interval-comparison">
<div class="interval-bar">
<div class="interval-label">
${t("current", lang)}: ${current ?? "—"} ${current != null ? t("days", lang) : ""}
</div>
<div class="interval-visual current"
style="width: ${current != null ? Math.min((current / maxBar) * 100, 100) : 0}%"></div>
</div>
<div class="interval-bar">
<div class="interval-label">
${t("recommended", lang)}: ${suggested} ${t("days", lang)}
<span class="confidence-badge ${confidence}">${t(`confidence_${confidence}`, lang)}</span>
</div>
<div class="interval-visual suggested"
style="width: ${Math.min((suggested / maxBar) * 100, 100)}%"></div>
</div>
</div>
`;
}
@@ -0,0 +1,111 @@
/** Seasonal factor chart renderers. */
import { html, svg, nothing } from "lit";
import { t } from "../styles";
import type { MaintenanceTask, AdvancedFeatures } from "../types";
const MONTH_KEYS = [
"month_jan", "month_feb", "month_mar", "month_apr",
"month_may", "month_jun", "month_jul", "month_aug",
"month_sep", "month_oct", "month_nov", "month_dec",
];
export function renderSeasonalCardCompact(task: MaintenanceTask, lang: string, features: AdvancedFeatures) {
if (!features.seasonal || !task.seasonal_factor || task.seasonal_factor === 1.0) {
return nothing;
}
const months = MONTH_KEYS.map(k => t(k, lang));
const currentMonth = new Date().getMonth();
const realFactors = task.seasonal_factors || task.interval_analysis?.seasonal_factors || null;
const seasonalData = realFactors && realFactors.length === 12
? realFactors
: months.map((_, i) => {
const base = task.seasonal_factor || 1.0;
const variation = Math.sin((i - 6) * Math.PI / 6) * 0.3;
return Math.max(0.7, Math.min(1.3, base + variation));
});
return html`
<div class="seasonal-card-compact">
<h4>${t("seasonal_awareness", lang)}</h4>
<div class="seasonal-mini-chart">
${seasonalData.map((factor, i) => {
const height = factor * 40;
const colorClass = factor < 0.9 ? 'low' : factor > 1.1 ? 'high' : 'normal';
const isCurrentMonth = i === currentMonth;
return html`
<div class="seasonal-bar ${colorClass} ${isCurrentMonth ? 'current' : ''}"
style="height: ${height}px"
title="${months[i]}: ${factor.toFixed(2)}x">
</div>
`;
})}
</div>
<div class="seasonal-legend">
<span class="legend-item"><span class="dot low"></span> ${t("shorter", lang) || "Kürzer"}</span>
<span class="legend-item"><span class="dot normal"></span> ${t("normal", lang) || "Normal"}</span>
<span class="legend-item"><span class="dot high"></span> ${t("longer", lang) || "Länger"}</span>
</div>
</div>
`;
}
export function renderSeasonalCardExpanded(task: MaintenanceTask, lang: string) {
return renderSeasonalChart(task, lang);
}
// Module-private — only the `renderSeasonalCardExpanded` wrapper above is part
// of the renderers/ public surface. Drop the keyword so esbuild sees the
// symbol as internal and tree-shakes it cleanly when the wrapper changes.
function renderSeasonalChart(task: MaintenanceTask, lang: string) {
const factors = task.seasonal_factors
?? task.interval_analysis?.seasonal_factors;
if (!factors || factors.length !== 12) return nothing;
const reason = task.interval_analysis?.seasonal_reason;
const currentMonth = new Date().getMonth();
const W = 300, H = 100;
const PAD_T = 8, PAD_B = 4;
const chartH = H - PAD_T - PAD_B;
const maxFactor = Math.max(...factors, 1.5);
const barW = W / 12;
const barInner = barW * 0.65;
const baselineY = PAD_T + chartH - (1.0 / maxFactor) * chartH;
return html`
<div class="seasonal-chart">
<div class="seasonal-chart-title">
<ha-svg-icon aria-hidden="true" path="M17.75 4.09L15.22 6.03L16.13 9.09L13.5 7.28L10.87 9.09L11.78 6.03L9.25 4.09L12.44 4L13.5 1L14.56 4L17.75 4.09M21.25 11L19.61 12.25L20.2 14.23L18.5 13.06L16.8 14.23L17.39 12.25L15.75 11L17.81 10.95L18.5 9L19.19 10.95L21.25 11M18.97 15.95C19.8 15.87 20.69 17.05 20.16 17.8C19.84 18.25 19.5 18.67 19.08 19.07C15.17 23 8.84 23 4.94 19.07C1.03 15.17 1.03 8.83 4.94 4.93C5.34 4.53 5.76 4.17 6.21 3.85C6.96 3.32 8.14 4.21 8.06 5.04C7.79 7.9 8.75 10.87 10.95 13.06C13.14 15.26 16.1 16.22 18.97 15.95Z"></ha-svg-icon>
${t("seasonal_chart_title", lang)}
${reason ? html`<span class="source-tag">${reason === "learned" ? t("seasonal_learned", lang) : t("seasonal_manual", lang)}</span>` : nothing}
</div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${t("chart_seasonal", lang)}">
<line x1="0" y1="${baselineY.toFixed(1)}" x2="${W}" y2="${baselineY.toFixed(1)}"
stroke="var(--divider-color)" stroke-width="1" stroke-dasharray="4,3" />
${factors.map((f, i) => {
const barH = (f / maxFactor) * chartH;
const x = i * barW + (barW - barInner) / 2;
const y = PAD_T + chartH - barH;
const isCurrent = i === currentMonth;
const color = f < 1.0
? "var(--success-color, #4caf50)"
: f > 1.0
? "var(--warning-color, #ff9800)"
: "var(--secondary-text-color)";
return svg`
<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}"
width="${barInner.toFixed(1)}" height="${barH.toFixed(1)}"
fill="${color}" opacity="${isCurrent ? 1 : 0.5}" rx="2" />
`;
})}
</svg>
<div class="seasonal-labels">
${MONTH_KEYS.map((key, i) =>
html`<span class="seasonal-label ${i === currentMonth ? "active-month" : ""}">${t(key, lang)}</span>`
)}
</div>
</div>
`;
}
@@ -0,0 +1,361 @@
/** 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 <maintenance-trigger-chart> 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<string, StatisticsPoint[]>;
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.53). 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`
<h3>${t("trigger", L)}</h3>
<div class="trigger-card">
<div class="trigger-header">
<ha-icon icon="mdi:pulse" style="color: var(--primary-color); --mdc-icon-size: 20px;"></ha-icon>
<div>
${isMultiEntity ? html`
<div class="trigger-entity-name">${entityIds.length} ${t("entities", L)} (${tc.entity_logic || "any"})</div>
<div class="trigger-entity-id">${entityIds.map((eid, i) => html`${i > 0 ? ", " : ""}<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, eid)}>${eid}</span>`)}${tc.attribute ? `${tc.attribute}` : ""}</div>
` : html`
<div class="trigger-entity-name">${friendlyName}</div>
<div class="trigger-entity-id">${entityId ? html`<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, entityId)}>${entityId}</span>` : ""}${tc.attribute ? `${tc.attribute}` : ""}</div>
`}
</div>
<span class="status-badge ${task.trigger_active ? "triggered" : "ok"}" style="margin-left: auto;">
${task.trigger_active ? t("triggered", L) : t("ok", L)}
</span>
</div>
${spec
? renderProgress(spec, L)
: currentVal !== null && currentVal !== undefined
? html`
<div class="trigger-value-row">
<span class="trigger-current ${task.trigger_active ? "active" : ""}">${typeof currentVal === "number" ? fmtVal(currentVal, "", L) : currentVal}</span>
${unit ? html`<span class="trigger-unit">${unit}</span>` : nothing}
</div>
`
: nothing}
<div class="trigger-limits">
${triggerType === "threshold" ? html`
${tc.trigger_above != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("threshold_above", L)}: ${tc.trigger_above} ${unit}</span>` : nothing}
${tc.trigger_below != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("threshold_below", L)}: ${tc.trigger_below} ${unit}</span>` : nothing}
${tc.trigger_for_minutes ? html`<span class="trigger-limit-item"><span class="dot range" aria-hidden="true"></span> ${t("for_minutes", L)}: ${tc.trigger_for_minutes}</span>` : nothing}
` : nothing}
${triggerType === "state_change" ? html`
${tc.trigger_target_changes != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("target_changes", L)}: ${tc.trigger_target_changes}</span>` : nothing}
` : nothing}
${triggerType === "runtime" ? html`
${tc.trigger_runtime_hours != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("runtime_hours", L)}: ${tc.trigger_runtime_hours}h</span>` : nothing}
` : nothing}
${triggerType === "compound" ? html`
<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("compound_logic", L)}: ${tc.compound_logic || (tc as any).operator || "AND"}</span>
${(tc.conditions || []).map((cond: any, i: number) => html`
<span class="trigger-limit-item"><span class="dot range" aria-hidden="true"></span> ${i + 1}. ${t(cond.type || "unknown", L)}: ${cond.entity_id ? html`<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, cond.entity_id)}>${cond.entity_id}</span>` : ""}</span>
`)}
` : nothing}
</div>
${infos && infos.length > 1 ? html`
<div class="trigger-entity-list">
${infos.map(info => html`
<span class="trigger-entity-id">${info.friendly_name} (<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, info.entity_id)}>${info.entity_id}</span>)</span>
`)}
</div>
` : nothing}
${renderChart(task, unit, ctx)}
</div>
`;
}
/** 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`
<div class="counter-progress">
<div class="counter-progress-nums">
<span class="counter-progress-main">${fmtVal(spec.progress, "", L)}<span class="counter-progress-target"> / ${fmtVal(spec.target, spec.unit, L)}</span></span>
<span class="counter-progress-pct ${level}">${pct} %</span>
</div>
<div class="counter-progress-bar" role="progressbar" aria-valuenow=${pct} aria-valuemin="0" aria-valuemax="100">
<div class="counter-progress-fill ${level}" style="width:${Math.min(100, pct)}%"></div>
</div>
<div class="counter-progress-caption">
${t("chart_since_service", L)}${spec.meter != null ? html` · ${t("current", L)}: ${fmtVal(spec.meter, spec.unit, L)}` : nothing}
</div>
</div>
`;
}
/** 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`
<maintenance-trigger-chart
.points=${loading ? [] : points}
.events=${events}
.unit=${unit}
.lang=${ctx.lang}
.thresholdAbove=${triggerType === "threshold" ? tc.trigger_above ?? null : null}
.thresholdBelow=${triggerType === "threshold" ? tc.trigger_below ?? null : null}
.targetValue=${targetValue}
.forceZero=${forceZero}
.projection=${projection}
.rangeDays=${ctx.rangeDays}
.hideOutliers=${ctx.hideOutliers}
.busy=${loading}
@range-change=${(e: CustomEvent<{ days: number }>) => ctx.setRangeDays(e.detail.days)}
@outlier-toggle=${(e: CustomEvent<{ hide: boolean }>) => ctx.setHideOutliers(e.detail.hide)}
></maintenance-trigger-chart>
${statsFetchedEmpty && !loading
? html`<div class="chart-note">
<ha-icon icon="mdi:information-outline"></ha-icon>
${t("chart_no_stats", ctx.lang)}
</div>`
: nothing}
`;
}
@@ -0,0 +1,442 @@
/** Task-detail sub-view: header, tab bar, overview tab (KPIs, meta, charts,
* analysis cards), history tab, and the documents section.
*
* Extracted from the panel (renderers/ pattern, like history.ts). State the
* panel owns — the active tab, collapsed sections, feature flags — plus the
* ~20 action callbacks (complete/skip/archive/QR/edit/…) are passed in via
* TaskDetailContext. Dialog ownership deliberately STAYS in the panel: every
* dialog-opening callback runs panel-side against the panel's shadow root, so
* this module renders into the panel's root and never needs its own dialogs.
*/
import { html, nothing } from "lit";
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask } from "../types";
import { renderTriggerSection, type SparklineContext } from "./sparkline";
import { renderPredictionSection } from "./prediction";
import { renderWeibullSection } from "./weibull";
import { renderRecommendationBars } from "./recommendation";
import { renderSeasonalCardCompact, renderSeasonalCardExpanded } from "./seasonal";
import { renderCostDurationCard } from "./charts";
import { renderDaysProgress } from "./progress";
import { renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history";
import "../components/task-documents";
export interface TaskDetailContext {
lang: string;
hass: HomeAssistant;
entryId: string;
taskId: string;
/** Parent object's display name (breadcrumb + object-manual label). */
objectName: string;
/** Parent object's documentation_url (raw; sanitised here). */
objectDocUrl: string | null | undefined;
isOperator: boolean;
actionLoading: boolean;
moreMenuOpen: boolean;
activeTab: "overview" | "history";
features: AdvancedFeatures;
currencySymbol: string;
collapsedSections: Set<string>;
costDurationToggle: "cost" | "duration" | "both";
/** Whether the interval suggestion for THIS task was dismissed this session. */
suggestionDismissed: boolean;
/** Sub-contexts for the chart + history renderers. */
sparkline: SparklineContext;
history: HistoryContext;
getUserName: (userId: string) => string | null;
// ── Panel-owned state mutations ────────────────────────────────────────
setActiveTab: (tab: "overview" | "history") => void;
toggleSection: (key: string) => void;
setCostDurationToggle: (v: "cost" | "duration" | "both") => void;
showTaskView: () => void;
showObject: () => void;
toggleMoreMenu: () => void;
closeMoreMenu: () => void;
// ── Panel-owned actions (dialogs live in the panel's shadow root) ──────
openEdit: (task: MaintenanceTask) => void;
openComplete: (task: MaintenanceTask) => void;
promptSkip: () => void;
toggleArchive: (archived: boolean) => void;
openQr: (taskName: string) => void;
duplicateTask: () => void;
promptReset: () => void;
snoozeTask: () => void;
/** v2.21: open the printable one-pager for this task. */
printWorksheet: () => void;
deleteTask: () => void;
applySuggestion: (interval: number) => void;
reanalyze: () => void;
dismissSuggestion: () => void;
openSeasonalOverrides: (task: MaintenanceTask) => void;
}
/** User badge for a task (if a responsible user is assigned). Also used by
* the object-detail task list, so it takes the lookup directly. */
export function renderUserBadge(
task: MaintenanceTask,
getUserName: (userId: string) => string | null,
) {
if (!task.responsible_user_id) return nothing;
const userName = getUserName(task.responsible_user_id);
if (!userName) return nothing;
return html`
<span class="user-badge">
<ha-icon icon="mdi:account"></ha-icon>
${userName}
</span>
`;
}
function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
const isOperator = ctx.isOperator;
// Determine status chip — use the backend-computed status. A completed
// one-time task is shown as archived ("done") rather than its raw "ok".
const statusClass = task.archived ? "archived" : (task.is_done ? "done" : (task.status === "due_soon" ? "warning" : (task.status || "ok")));
const statusText = task.archived ? t("archived", L) : (task.is_done ? t("completed", L) : t(task.status || "ok", L));
return html`
<div class="task-header">
<div class="task-header-title">
<span class="task-name-breadcrumb" @click=${() => ctx.showTaskView()}>${task.name}</span>
<span class="breadcrumb-separator">·</span>
<span class="object-name-breadcrumb" @click=${() => ctx.showObject()}>${ctx.objectName}</span>
<span class="status-chip ${statusClass}">${statusText}</span>
${renderUserBadge(task, ctx.getUserName)}
${task.nfc_tag_id
? html`<span class="nfc-badge" title="${t("nfc_tag_id", L)}: ${task.nfc_tag_id}"><ha-icon icon="mdi:nfc-variant"></ha-icon> NFC</span>`
: !isOperator ? html`<span class="nfc-badge unlinked" title="${t("nfc_link_hint", L)}"
@click=${() => ctx.openEdit(task)}>
<ha-icon icon="mdi:nfc-variant"></ha-icon>
</span>` : nothing
}
</div>
<div class="task-header-actions">
<ha-button appearance="filled" @click=${() => ctx.openComplete(task)}>${t("complete", L)}</ha-button>
<ha-button appearance="plain" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>
${!isOperator ? html`
<ha-button appearance="plain" @click=${() => ctx.toggleArchive(!!task.archived)}>
<ha-icon icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${task.archived ? t("unarchive", L) : t("archive", L)}
</ha-button>
` : nothing}
<ha-button appearance="plain" @click=${() => ctx.openQr(task.name)}><ha-icon icon="mdi:qrcode"></ha-icon> ${t("qr_code", L)}</ha-button>
${!isOperator ? html`
<div class="more-menu-wrapper">
<ha-icon-button .disabled=${ctx.actionLoading} .path=${"M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"} @click=${() => ctx.toggleMoreMenu()}></ha-icon-button>
${ctx.moreMenuOpen ? html`
<div class="popup-menu" @click=${(e: Event) => e.stopPropagation()}>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.openEdit(task); }}>${t("edit", L)}</div>
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.printWorksheet(); }}>${t("worksheet", L)}</div>
<div class="popup-menu-divider"></div>
<div class="popup-menu-item danger" @click=${() => { ctx.closeMoreMenu(); ctx.deleteTask(); }}>${t("delete", L)}</div>
</div>
` : nothing}
</div>
` : nothing}
</div>
</div>
`;
}
function renderTabBar(ctx: TaskDetailContext) {
const L = ctx.lang;
return html`
<div class="tab-bar">
<div class="tab ${ctx.activeTab === "overview" ? "active" : ""}" @click=${() => ctx.setActiveTab("overview")}>
${t("overview", L)}
</div>
<div class="tab ${ctx.activeTab === "history" ? "active" : ""}" @click=${() => ctx.setActiveTab("history")}>
${t("history", L)}
</div>
</div>
`;
}
/** Wrap a task-detail analysis card in a collapsible section. The section
* header owns the title (the wrapped card's own title is hidden via CSS —
* `.collapsible-body` — so it isn't shown twice). Remembered per key. */
function collapsible(key: string, titleKey: string, body: unknown, ctx: TaskDetailContext) {
const collapsed = ctx.collapsedSections.has(key);
return html`
<div class="collapsible ${collapsed ? "collapsed" : ""}">
<button class="collapsible-head" @click=${() => ctx.toggleSection(key)}
aria-expanded=${collapsed ? "false" : "true"}>
<ha-icon icon="${collapsed ? "mdi:chevron-right" : "mdi:chevron-down"}"></ha-icon>
<span>${t(titleKey, ctx.lang)}</span>
</button>
${collapsed ? nothing : html`<div class="collapsible-body">${body}</div>`}
</div>
`;
}
/** Read-only preview of the configured checklist steps so users can see
* the steps without having to open the Edit or Complete dialog. Only
* rendered when the Checklists feature is enabled and steps are set. */
function renderChecklistCard(task: MaintenanceTask, ctx: TaskDetailContext) {
if (!ctx.features.checklists) return nothing;
const items = task.checklist || [];
if (items.length === 0) return nothing;
const L = ctx.lang;
return html`
<div class="checklist-preview-card">
<div class="checklist-preview-header">
<ha-icon icon="mdi:format-list-checks"></ha-icon>
<span>${t("checklist", L)} (${items.length})</span>
</div>
<ol class="checklist-preview-list">
${items.map((item) => html`<li>${item}</li>`)}
</ol>
</div>
`;
}
/** Task notes, task documentation URL, and (since v1.4.1) the parent object's
* documentation_url for quick access to the device manual without having to
* navigate back to the object detail. */
function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
const safeTaskUrl = task.documentation_url && /^https?:\/\//i.test(task.documentation_url)
? task.documentation_url : null;
const safeObjUrl = ctx.objectDocUrl && /^https?:\/\//i.test(ctx.objectDocUrl) ? ctx.objectDocUrl : null;
if (!task.notes && !safeTaskUrl && !safeObjUrl) return nothing;
const L = ctx.lang;
return html`
<div class="task-meta-card">
${task.notes ? html`
<div class="task-meta-row">
<ha-icon icon="mdi:note-text-outline"></ha-icon>
<span class="task-meta-notes">${task.notes}</span>
</div>
` : nothing}
${safeTaskUrl ? html`
<div class="task-meta-row task-meta-link">
<ha-icon icon="mdi:open-in-new"></ha-icon>
<a href="${safeTaskUrl}" target="_blank" rel="noopener noreferrer">${t("documentation_label", L)}</a>
</div>
` : nothing}
${safeObjUrl ? html`
<div class="task-meta-row task-meta-link">
<ha-icon icon="mdi:book-open-variant"></ha-icon>
<a href="${safeObjUrl}" target="_blank" rel="noopener noreferrer">${t("documentation_url_label", L)} (${ctx.objectName})</a>
</div>
` : nothing}
</div>
`;
}
/** KPI bar with 7 cards. */
function renderKPIBar(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
const avgCost = task.times_performed > 0 ? task.total_cost / task.times_performed : 0;
const daysClass = task.days_until_due !== null && task.days_until_due !== undefined
? (task.days_until_due < 0 ? "overdue" : (task.days_until_due <= task.warning_days ? "warning" : ""))
: "";
return html`
<div class="kpi-bar">
<div class="kpi-card">
<div class="kpi-label">${t("next_due", L)}</div>
<div class="kpi-value">${task.next_due ? formatDate(task.next_due, L) : "—"}</div>
${ctx.features.schedule_time && task.schedule_time
? html`<div class="kpi-subtext">${t("at_time", L)} ${task.schedule_time}</div>`
: nothing}
</div>
<div class="kpi-card ${daysClass}">
<div class="kpi-label">${t("days_until_due", L)}</div>
<div class="kpi-value-large">${task.days_until_due !== null && task.days_until_due !== undefined ? task.days_until_due : "—"}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${t("interval", L)}</div>
<div class="kpi-value">${formatRecurrence(task, L)}</div>
${ctx.features.adaptive && task.suggested_interval && task.suggested_interval !== task.interval_days ? html`
<div class="kpi-subtext">${t("recommended", L)}: ${task.suggested_interval}${task.interval_analysis?.confidence_interval_low != null ? ` (${task.interval_analysis.confidence_interval_low}${task.interval_analysis.confidence_interval_high})` : ""}</div>
` : nothing}
</div>
<div class="kpi-card">
<div class="kpi-label">${t("warning", L)}</div>
<div class="kpi-value">${task.warning_days} ${t("days", L)}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${t("last_performed", L)}</div>
<div class="kpi-value">${task.last_performed ? formatDate(task.last_performed, L) : "—"}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${t("avg_cost", L)}</div>
<div class="kpi-value">${avgCost.toFixed(0)} ${ctx.currencySymbol}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${t("avg_duration", L)}</div>
<div class="kpi-value">${task.average_duration ? task.average_duration.toFixed(0) : "—"} min</div>
</div>
</div>
`;
}
function renderRecommendationCard(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
if (!ctx.features.adaptive || !task.suggested_interval
|| task.suggested_interval === task.interval_days) {
return nothing;
}
if (ctx.suggestionDismissed) return nothing;
const suggested = task.suggested_interval;
return html`
<div class="recommendation-card">
<h4>${t("suggested_interval", L)}</h4>
${renderRecommendationBars(
task.interval_days, suggested,
task.interval_confidence || "medium", L,
)}
<div class="recommendation-actions">
<ha-button appearance="filled"
@click=${() => ctx.applySuggestion(suggested)}>
${t("apply_suggestion", L)}
</ha-button>
<ha-button appearance="plain"
@click=${() => ctx.reanalyze()}>
${t("reanalyze", L)}
</ha-button>
<ha-button appearance="plain"
@click=${() => ctx.dismissSuggestion()}>
${t("dismiss_suggestion", L)}
</ha-button>
</div>
</div>
`;
}
function renderRecentActivities(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
const recent = task.history.slice(-3).reverse();
if (recent.length === 0) {
return nothing;
}
const getIcon = (type: string) => {
switch (type) {
case "completed": return "✓";
case "triggered": return "⊗";
case "skipped": return "↷";
case "reset": return "↺";
default: return "·";
}
};
return html`
<div class="recent-activities">
<h3>${t("recent_activities", L)}</h3>
${recent.map(entry => html`
<div class="activity-item">
<span class="activity-icon">${getIcon(entry.type)}</span>
<span class="activity-date">${formatDateTime(entry.timestamp, L)}</span>
<span class="activity-note">${entry.notes || "—"}</span>
${entry.cost ? html`<span class="activity-badge">${entry.cost.toFixed(0)}${ctx.currencySymbol}</span>` : nothing}
${entry.duration ? html`<span class="activity-badge">${entry.duration}min</span>` : nothing}
</div>
`)}
<div class="activity-show-all">
<ha-button appearance="plain" @click=${() => ctx.setActiveTab("history")}>${t("show_all", L)} →</ha-button>
</div>
</div>
`;
}
export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
// Check if we have recommendation / seasonal content
const hasRecommendation = ctx.features.adaptive && task.suggested_interval && task.suggested_interval !== task.interval_days;
const hasSeasonal = ctx.features.seasonal && task.seasonal_factor && task.seasonal_factor !== 1.0;
const hasLeftColumn = hasRecommendation || hasSeasonal;
// Analysis content: Weibull/Seasonal expanded (only when data is available)
const hasWeibullData = ctx.features.adaptive
&& task.interval_analysis?.weibull_beta != null
&& task.interval_analysis?.weibull_eta != null;
const hasSeasonalData = ctx.features.seasonal
&& (task.seasonal_factors?.length === 12
|| task.interval_analysis?.seasonal_factors?.length === 12);
return html`
<div class="tab-content overview-tab">
${renderKPIBar(task, ctx)}
${renderTaskMeta(task, ctx)}
${renderDaysProgress(task, ctx.lang)}
${renderTriggerSection(task, ctx.sparkline)}
${renderPredictionSection(task, L, ctx.features)}
<div class="two-column-layout ${hasLeftColumn ? '' : 'single-column'}">
${hasLeftColumn ? html`
<div class="left-column">
${renderRecommendationCard(task, ctx)}
${renderSeasonalCardCompact(task, L, ctx.features)}
</div>
` : nothing}
<div class="right-column">
${renderCostDurationCard(task, L, ctx.costDurationToggle, (v) => ctx.setCostDurationToggle(v))}
</div>
</div>
${hasWeibullData
? collapsible("weibull", "weibull_reliability_curve", renderWeibullSection(task, L), ctx)
: nothing}
${hasSeasonalData
? collapsible("seasonal", "seasonal_chart_title", html`
${renderSeasonalCardExpanded(task, L)}
<div class="seasonal-actions">
<ha-button appearance="plain" @click=${() => ctx.openSeasonalOverrides(task)}>
${t("edit_seasonal_overrides", L)}
</ha-button>
</div>
`, ctx)
: nothing}
${renderChecklistCard(task, ctx)}
${renderRecentActivities(task, ctx)}
</div>
`;
}
function renderHistoryTab(task: MaintenanceTask, ctx: TaskDetailContext) {
return html`
<div class="tab-content history-tab">
${renderHistoryFilters(task, ctx.history)}
${renderHistoryList(task, ctx.history)}
</div>
`;
}
function renderTabContent(task: MaintenanceTask, ctx: TaskDetailContext) {
switch (ctx.activeTab) {
case "overview":
return renderOverviewTab(task, ctx);
case "history":
return renderHistoryTab(task, ctx);
default:
return nothing;
}
}
/** The complete task-detail view. Renders into the PANEL's shadow root (this
* is a render function, not a component) so the panel's dialogs and styles
* keep working unchanged. */
export function renderTaskDetail(task: MaintenanceTask, ctx: TaskDetailContext) {
return html`
<div class="detail-section">
${renderTaskHeader(task, ctx)}
${renderTabBar(ctx)}
${renderTabContent(task, ctx)}
<maintenance-task-documents
.hass=${ctx.hass}
.entryId=${ctx.entryId}
.taskId=${ctx.taskId}
.canWrite=${!ctx.isOperator}
></maintenance-task-documents>
</div>
`;
}
@@ -0,0 +1,187 @@
/** Weibull reliability analysis renderers. */
import { html, svg, nothing } from "lit";
import { t } from "../styles";
import type { MaintenanceTask } from "../types";
export function renderWeibullSection(task: MaintenanceTask, lang: string) {
const analysis = task.interval_analysis;
const beta = analysis?.weibull_beta;
const eta = analysis?.weibull_eta;
if (beta == null || eta == null || eta <= 0) return nothing;
const currentInterval = task.interval_days ?? 0;
const rec = task.suggested_interval ?? currentInterval;
return html`
<div class="weibull-section">
<div class="weibull-title">
<ha-svg-icon aria-hidden="true" path="M3,14L3.5,14.07L8.07,9.5C7.89,8.85 8.06,8.11 8.59,7.59C9.37,6.8 10.63,6.8 11.41,7.59C11.94,8.11 12.11,8.85 11.93,9.5L14.5,12.07L15,12C15.18,12 15.35,12 15.5,12.07L19.07,8.5C19,8.35 19,8.18 19,8A2,2 0 0,1 21,6A2,2 0 0,1 23,8A2,2 0 0,1 21,10C20.82,10 20.65,10 20.5,9.93L16.93,13.5C17,13.65 17,13.82 17,14A2,2 0 0,1 15,16A2,2 0 0,1 13,14L13.07,13.5L10.5,10.93C10.18,11 9.82,11 9.5,10.93L4.93,15.5L5,16A2,2 0 0,1 3,18A2,2 0 0,1 1,16A2,2 0 0,1 3,14Z"></ha-svg-icon>
${t("weibull_reliability_curve", lang)}
${renderBetaBadge(beta, lang)}
</div>
${renderWeibullChart(beta, eta, currentInterval, rec, lang)}
${renderWeibullInfo(analysis!, lang)}
${analysis?.confidence_interval_low != null ? renderConfidenceInterval(analysis!, task, lang) : nothing}
</div>
`;
}
function renderBetaBadge(beta: number, lang: string) {
let cls: string;
let icon: string;
let key: string;
if (beta < 0.8) {
cls = "early_failures";
icon = "M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z";
key = "beta_early_failures";
} else if (beta <= 1.2) {
cls = "random_failures";
icon = "M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,17H11V15H13V17M13,13H11V7H13V13Z";
key = "beta_random_failures";
} else if (beta <= 3.5) {
cls = "wear_out";
icon = "M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M12,6A6,6 0 0,1 18,12H12V6Z";
key = "beta_wear_out";
} else {
cls = "highly_predictable";
icon = "M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M11,16.5L18,9.5L16.59,8.09L11,13.67L7.91,10.59L6.5,12L11,16.5Z";
key = "beta_highly_predictable";
}
return html`
<span class="beta-badge ${cls}">
<ha-svg-icon path="${icon}"></ha-svg-icon>
${t(key, lang)} (\u03B2=${beta.toFixed(2)})
</span>
`;
}
function renderWeibullChart(beta: number, eta: number, currentInterval: number, recommended: number, lang: string) {
const W = 300, H = 160;
const PAD_L = 32, PAD_R = 8, PAD_T = 8, PAD_B = 24;
const chartW = W - PAD_L - PAD_R;
const chartH = H - PAD_T - PAD_B;
const maxT = Math.max(currentInterval, recommended, eta, 1) * 1.3;
const N = 50;
const points: Array<[number, number]> = [];
for (let i = 0; i <= N; i++) {
const t_val = (i / N) * maxT;
const cdf = 1.0 - Math.exp(-Math.pow(t_val / eta, beta));
const x = PAD_L + (t_val / maxT) * chartW;
const y = PAD_T + chartH - cdf * chartH;
points.push([x, y]);
}
const polyline = points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
const areaPath = `M${PAD_L},${PAD_T + chartH} ` +
points.map(([x, y]) => `L${x.toFixed(1)},${y.toFixed(1)}`).join(" ") +
` L${points[N][0].toFixed(1)},${PAD_T + chartH} Z`;
const curX = PAD_L + (currentInterval / maxT) * chartW;
const curCdf = 1.0 - Math.exp(-Math.pow(currentInterval / eta, beta));
const curY = PAD_T + chartH - curCdf * chartH;
const reliability = ((1.0 - curCdf) * 100).toFixed(0);
const recX = PAD_L + (recommended / maxT) * chartW;
const yTicks = [0, 0.25, 0.5, 0.75, 1.0];
return html`
<div class="weibull-chart">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${t("chart_weibull", lang)}">
${yTicks.map(tick => {
const y = PAD_T + chartH - tick * chartH;
return svg`
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}"
stroke="var(--divider-color)" stroke-width="0.5" stroke-dasharray="${tick === 0.5 ? '4,3' : nothing}" />
<text x="${PAD_L - 4}" y="${(y + 3).toFixed(1)}" fill="var(--secondary-text-color)"
font-size="8" text-anchor="end">${(tick * 100).toFixed(0)}%</text>
`;
})}
<text x="${PAD_L}" y="${H - 4}" fill="var(--secondary-text-color)" font-size="8" text-anchor="middle">0</text>
<text x="${(PAD_L + W - PAD_R) / 2}" y="${H - 4}" fill="var(--secondary-text-color)" font-size="8" text-anchor="middle">${Math.round(maxT / 2)}</text>
<text x="${W - PAD_R}" y="${H - 4}" fill="var(--secondary-text-color)" font-size="8" text-anchor="middle">${Math.round(maxT)}</text>
<path d="${areaPath}" fill="var(--primary-color, #03a9f4)" opacity="0.08" />
<polyline points="${polyline}" fill="none"
stroke="var(--primary-color, #03a9f4)" stroke-width="2" />
${currentInterval > 0 ? svg`
<line x1="${curX.toFixed(1)}" y1="${PAD_T}" x2="${curX.toFixed(1)}" y2="${(PAD_T + chartH).toFixed(1)}"
stroke="var(--primary-color, #03a9f4)" stroke-width="1.5" stroke-dasharray="4,3" />
<circle cx="${curX.toFixed(1)}" cy="${curY.toFixed(1)}" r="3"
fill="var(--primary-color, #03a9f4)" />
<text x="${(curX + 4).toFixed(1)}" y="${(curY - 6).toFixed(1)}" fill="var(--primary-color, #03a9f4)"
font-size="9" font-weight="600">R=${reliability}%</text>
` : nothing}
${recommended > 0 && recommended !== currentInterval ? svg`
<line x1="${recX.toFixed(1)}" y1="${PAD_T}" x2="${recX.toFixed(1)}" y2="${(PAD_T + chartH).toFixed(1)}"
stroke="var(--success-color, #4caf50)" stroke-width="1.5" stroke-dasharray="4,3" />
` : nothing}
<line x1="${PAD_L}" y1="${PAD_T}" x2="${PAD_L}" y2="${PAD_T + chartH}"
stroke="var(--secondary-text-color)" stroke-width="1" />
<line x1="${PAD_L}" y1="${PAD_T + chartH}" x2="${W - PAD_R}" y2="${PAD_T + chartH}"
stroke="var(--secondary-text-color)" stroke-width="1" />
</svg>
</div>
<div class="chart-legend">
<span class="legend-item"><span class="legend-swatch" style="background:var(--primary-color, #03a9f4)"></span> ${t("weibull_failure_probability", lang)}</span>
${currentInterval > 0 ? html`<span class="legend-item"><span class="legend-swatch" style="background:var(--primary-color, #03a9f4); opacity:0.5"></span> ${t("current_interval_marker", lang)}</span>` : nothing}
${recommended > 0 && recommended !== currentInterval ? html`<span class="legend-item"><span class="legend-swatch" style="background:var(--success-color, #4caf50)"></span> ${t("recommended_marker", lang)}</span>` : nothing}
</div>
`;
}
function renderWeibullInfo(analysis: NonNullable<MaintenanceTask["interval_analysis"]>, lang: string) {
return html`
<div class="weibull-info-row">
<div class="weibull-info-item">
<span>${t("characteristic_life", lang)}</span>
<span class="weibull-info-value">${Math.round(analysis.weibull_eta!)} ${t("days", lang)}</span>
</div>
${analysis.weibull_r_squared != null ? html`
<div class="weibull-info-item">
<span>${t("weibull_r_squared", lang)}</span>
<span class="weibull-info-value">${analysis.weibull_r_squared!.toFixed(3)}</span>
</div>
` : nothing}
</div>
`;
}
function renderConfidenceInterval(analysis: NonNullable<MaintenanceTask["interval_analysis"]>, task: MaintenanceTask, lang: string) {
const low = analysis.confidence_interval_low!;
const high = analysis.confidence_interval_high!;
const rec = task.suggested_interval ?? task.interval_days ?? 0;
const current = task.interval_days ?? 0;
const barMin = Math.max(0, low - 5);
const barMax = high + 5;
const range = barMax - barMin;
const fillLeft = ((low - barMin) / range) * 100;
const fillWidth = ((high - low) / range) * 100;
const recPos = ((rec - barMin) / range) * 100;
const curPos = current > 0 ? ((current - barMin) / range) * 100 : -1;
return html`
<div class="confidence-range">
<div class="confidence-range-title">
${t("confidence_interval", lang)}: ${rec} ${t("days", lang)} (${low}\u2013${high})
</div>
<div class="confidence-bar">
<div class="confidence-fill" style="left:${fillLeft.toFixed(1)}%;width:${fillWidth.toFixed(1)}%"></div>
${curPos >= 0 ? html`<div class="confidence-marker current" style="left:${curPos.toFixed(1)}%"></div>` : nothing}
<div class="confidence-marker recommended" style="left:${recPos.toFixed(1)}%"></div>
</div>
<div class="confidence-labels">
<span class="confidence-text low">${t("confidence_conservative", lang)} (${low}${t("days", lang).charAt(0)})</span>
<span class="confidence-text high">${t("confidence_aggressive", lang)} (${high}${t("days", lang).charAt(0)})</span>
</div>
</div>
`;
}