189 files
This commit is contained in:
@@ -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)}"
|
||||
|
||||
Reference in New Issue
Block a user