189 files

This commit is contained in:
Home Assistant Version Control
2026-09-08 18:05:32 +00:00
parent 79fefc0a7c
commit 303886f212
189 changed files with 21402 additions and 3514 deletions
@@ -10,6 +10,9 @@ import { html, nothing } from "lit";
import { t, formatDateTime, formatNumber, formatCost, STATUS_ICONS } from "../styles";
import type { MaintenanceTask, HistoryEntry, HomeAssistant } from "../types";
import "../components/history-photo";
import { historyPhotoIds } from "../helpers/history-photos";
import { renderRefChip } from "../helpers/reference";
import { entryReadingValues } from "../helpers/reading-slots";
export interface HistoryContext {
lang: string;
@@ -27,8 +30,12 @@ export interface HistoryContext {
/** v2.20 (#83): unit + delta-vs-previous for reading-task entries. */
readingUnit?: string | null;
readingDelta?: (entry: HistoryEntry) => number | null;
/** #161 phase 2: delta of one slot vs. the previous entry carrying it. */
readingSlotDelta?: (entry: HistoryEntry, slotId: string) => number | null;
/** #139: phase id → display name, for entries stamped with a phase_id. */
phaseNames?: Record<string, string>;
/** #170: the task's reference ("8.3") — completions render "#8.3-<n>". */
taskRef?: string | null;
}
// Every HistoryEntryType is filterable (drift audit 2026-08: the repair-flow
@@ -70,7 +77,8 @@ export function renderHistoryList(task: MaintenanceTask, ctx: HistoryContext) {
// Apply search filter
if (ctx.search) {
const search = ctx.search.toLowerCase();
filtered = filtered.filter((h) => h.notes?.toLowerCase().includes(search));
// #170: "8.3-2" (or just "-2") in the notes filter finds the entry by number.
filtered = filtered.filter((h) => h.notes?.toLowerCase().includes(search) || (entryRefOf(ctx, h) ?? "").toLowerCase().endsWith(search));
}
if (filtered.length === 0) {
@@ -84,6 +92,11 @@ export function renderHistoryList(task: MaintenanceTask, ctx: HistoryContext) {
`;
}
/** "8.3-2" for a numbered completion, null otherwise. */
function entryRefOf(ctx: HistoryContext, entry: HistoryEntry): string | null {
return ctx.taskRef && entry.ref_no ? `${ctx.taskRef}-${entry.ref_no}` : null;
}
export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryContext) {
const L = ctx.lang;
// v2.2.0: only "lifecycle + cost-bearing" entries are user-editable.
@@ -98,6 +111,7 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryContext) {
<div class="history-content">
<div class="history-row">
<strong>${t(entry.type, L)}</strong>
${renderRefChip(entryRefOf(ctx, entry), t("ref_number", L))}
${entry.phase_id ? html`<span class="history-phase-badge">${ctx.phaseNames?.[entry.phase_id] || entry.phase_id}</span>` : nothing}
${entry.auto ? html`<span class="history-auto-badge">${t("history_auto", L)}</span>` : nothing}
${editable
@@ -110,9 +124,30 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryContext) {
</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}
${(() => {
// #161: photo_doc_ids (new) or the pre-multi-photo photo_doc_id scalar.
const photos = historyPhotoIds(entry);
return photos.length > 0
? html`<div class="history-photos">
${photos.map((docId) => html`<maintenance-history-photo .hass=${ctx.hass} .docId=${docId}></maintenance-history-photo>`)}
</div>`
: nothing;
})()}
${(() => {
// #161 phase 2: one line per recorded slot, delta vs. the previous
// completion that carried the same slot.
const values = entryReadingValues(entry);
if (values.length === 0) return nothing;
return html`<div class="history-readings">
${values.map((v) => {
const d = ctx.readingSlotDelta?.(entry, v.id);
return html`<span class="history-reading">
<span class="history-reading-name">${v.name}</span>
<span class="history-reading-value">${formatNumber(v.value, L, { maximumFractionDigits: 3 })}${v.unit ? ` ${v.unit}` : ""}${d == null ? "" : ` (${d >= 0 ? "+" : ""}${formatNumber(d, L, { maximumFractionDigits: 3 })})`}</span>
</span>`;
})}
</div>`;
})()}
<div class="history-details">
${entry.cost != null ? html`<span>${t("cost", L)}: ${formatCost(entry.cost, ctx.currencySymbol, L)}</span>` : nothing}
${entry.duration != null ? html`<span>${t("duration", L)}: ${entry.duration} min</span>` : nothing}
@@ -48,12 +48,20 @@ export function computeTrend(
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 }))
: (row.history || [])
.filter((h) => h.trigger_value != null)
.map((h) => ({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value as number }));
: 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);
@@ -63,9 +71,17 @@ export function computeTrend(
// 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" ? tc.trigger_target_value
: 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;
@@ -88,18 +104,35 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { t
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;
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 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.
// 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 ?? ((below * 2) || 100);
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}`;
@@ -12,7 +12,9 @@
import { html, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
import { renderPersonAvatar, type PersonDisplay } from "../helpers/person";
import { t, formatDate, formatDateTime, formatRecurrence, formatNumber, formatCost } from "../styles";
import { renderRefChip } from "../helpers/reference";
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask, ManualDocRef } from "../types";
import { renderTriggerSection, type SparklineContext } from "./sparkline";
import { renderPredictionSection } from "./prediction";
@@ -32,6 +34,8 @@ export interface TaskDetailContext {
taskId: string;
/** Parent object's display name (breadcrumb + object-manual label). */
objectName: string;
/** #170: the task's reference ("8.3"), null until the backend numbered it. */
taskRef?: string | null;
/** Parent object's documentation_url (raw; sanitised here). */
objectDocUrl: string | null | undefined;
/** Parent object's manual-tagged documents — the fallback for the manual
@@ -57,6 +61,8 @@ export interface TaskDetailContext {
sparkline: SparklineContext;
history: HistoryContext;
getUserName: (userId: string) => string | null;
/** #169 follow-up: avatar + name of a member (null = unknown / not loaded). */
getPerson?: (userId: string) => PersonDisplay | null;
// ── Panel-owned state mutations ────────────────────────────────────────
setActiveTab: (tab: "overview" | "history") => void;
toggleSection: (key: string) => void;
@@ -89,8 +95,13 @@ export interface TaskDetailContext {
export function renderUserBadge(
task: MaintenanceTask,
getUserName: (userId: string) => string | null,
getPerson?: (userId: string) => PersonDisplay | null,
) {
if (!task.responsible_user_id) return nothing;
const person = getPerson?.(task.responsible_user_id) ?? null;
if (person) {
return html`<span class="user-badge">${renderPersonAvatar(person)}${person.name}</span>`;
}
const userName = getUserName(task.responsible_user_id);
if (!userName) return nothing;
return html`
@@ -114,13 +125,14 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
<div class="task-header">
<div class="task-header-title">
<span class="task-name-breadcrumb" @click=${() => ctx.showTaskView()}>${task.name}</span>
${renderRefChip(ctx.taskRef ?? null, t("ref_number", L))}
<span class="breadcrumb-separator">·</span>
<span class="object-name-breadcrumb" @click=${() => ctx.showObject()}>${ctx.objectName}</span>
<span class="status-chip ${statusClass}">${statusText}</span>
${task.due_override ? html`<span class="postponed-badge" title="${t("postponed_to", L)}">
<ha-icon icon="mdi:calendar-arrow-right"></ha-icon>${formatDate(task.due_override, L)}
</span>` : nothing}
${renderUserBadge(task, ctx.getUserName)}
${renderUserBadge(task, ctx.getUserName, ctx.getPerson)}
${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)}"