Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/renderers/history.ts
T

208 lines
9.7 KiB
TypeScript

/** 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.
*
* `renderHistoryEntry` is THE history row: the panel timeline, the
* quick-actions dialog and the task-detail "recent activities" card all
* render it (DRY round 2026-09 — the dialog and the card carried their
* own subsets, without badges / ref chips / photos / per-slot deltas).
*/
import { html, nothing } from "lit";
import { t, formatDateTime, formatNumber, formatCost, formatDuration, STATUS_ICONS } from "../styles";
import type { MaintenanceTask, HistoryEntry, HistoryEntryType, HomeAssistant } from "../types";
import "../components/history-photo";
import { historyPhotoIds } from "../helpers/history-photos";
import { renderRefChip } from "../helpers/reference";
import { entryReadingValues } from "../helpers/reading-slots";
import { matchesQuery } from "../helpers/search-match";
/** v2.2.0: only "lifecycle + cost-bearing" entries are user-editable.
* Triggers are auto-generated by sensors and shouldn't be retroactively
* rewritten. (helpers/object-history LIFECYCLE_TYPES = this + "missed",
* a different concept: what belongs in the object's record.) */
export const EDITABLE_HISTORY_TYPES: readonly HistoryEntryType[] = ["completed", "reset", "skipped"];
/** What one history row needs — the panel's HistoryContext is a superset;
* the dialog and the overview card build just this. */
export interface HistoryEntryContext {
lang: string;
/** Home Assistant object — used to sign completion-photo URLs. */
hass: HomeAssistant;
/** Currency symbol for cost display (defaults to €). */
currencySymbol: string;
openEdit: (entry: HistoryEntry) => void;
/** 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;
}
export interface HistoryContext extends HistoryEntryContext {
/** Active type filter, or null for "all". */
filter: string | null;
/** Free-text search over entry notes. */
search: string;
setFilter: (filter: string | null) => void;
setSearch: (search: string) => void;
}
export interface HistoryEntryOptions {
/** Dense row without the status-icon column (dialog / overview card). */
compact?: boolean;
/** The "#8.3-2" chip (default on; needs ctx.taskRef to show anything). */
showRef?: boolean;
/** Phase + "automatic" badges (default on). */
showBadges?: boolean;
/** The pencil on editable entries (default on). */
showEdit?: boolean;
}
// Every HistoryEntryType is filterable (drift audit 2026-08: the repair-flow
// types were missing, so their entries could not be isolated). Chips only
// render for types present in the task's history, so the two rare ones cost
// nothing on normal tasks.
const _FILTER_TYPES = ["completed", "skipped", "missed", "reset", "triggered", "trigger_replaced", "trigger_removed"] 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. Notes match through the same tolerant matcher the
// global search and the server use (fold, digraphs, AND over words in any
// order) — the palette pre-fills this box from a SERVER history hit, and a
// plain includes() landed on an empty tab for "spuelung" vs "Spülung
// durchgeführt" (bug audit 2026-09-12).
if (ctx.search) {
const search = ctx.search.toLowerCase();
// #170: "8.3-2" (or just "-2") in the notes filter finds the entry by number.
filtered = filtered.filter((h) => matchesQuery(h.notes, ctx.search) || (entryRefOf(ctx, h) ?? "").toLowerCase().endsWith(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>
`;
}
/** "8.3-2" for a numbered completion, null otherwise. */
function entryRefOf(ctx: HistoryEntryContext, entry: HistoryEntry): string | null {
return ctx.taskRef && entry.ref_no ? `${ctx.taskRef}-${entry.ref_no}` : null;
}
/** The `<maintenance-history-photo>` strip of an entry (#161: photo_doc_ids,
* or the pre-multi-photo photo_doc_id scalar); `nothing` without photos. */
export function renderHistoryPhotos(hass: HomeAssistant, entry: HistoryEntry | Record<string, unknown>) {
const photos = historyPhotoIds(entry);
return photos.length > 0
? html`<div class="history-photos">
${photos.map((docId) => html`<maintenance-history-photo .hass=${hass} .docId=${docId}></maintenance-history-photo>`)}
</div>`
: nothing;
}
/** The entry's readings — one line per recorded slot (#161 phase 2) with the
* delta against the previous entry carrying that slot, or the single value
* of a scalar reading task (v2.20, #83) with its delta. */
function renderHistoryReadings(entry: HistoryEntry, ctx: HistoryEntryContext) {
const L = ctx.lang;
const fmt = (n: number) => formatNumber(n, L, { maximumFractionDigits: 3 });
const delta = (d: number | null | undefined) => (d == null ? "" : ` (${d >= 0 ? "+" : ""}${fmt(d)})`);
const values = entryReadingValues(entry);
if (values.length > 0) {
return html`<div class="history-readings">
${values.map((v) => html`<span class="history-reading">
<span class="history-reading-name">${v.name}</span>
<span class="history-reading-value">${fmt(v.value)}${v.unit ? ` ${v.unit}` : ""}${delta(ctx.readingSlotDelta?.(entry, v.id))}</span>
</span>`)}
</div>`;
}
if (entry.reading_value != null) {
return html`<div class="history-readings"><span class="history-reading">
<span class="history-reading-name">${t("reading_label", L)}</span>
<span class="history-reading-value">${fmt(entry.reading_value)}${ctx.readingUnit ? ` ${ctx.readingUnit}` : ""}${delta(ctx.readingDelta?.(entry))}</span>
</span></div>`;
}
return nothing;
}
export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryEntryContext, opts: HistoryEntryOptions = {}) {
const L = ctx.lang;
const { compact = false, showRef = true, showBadges = true, showEdit = true } = opts;
const editable = showEdit && EDITABLE_HISTORY_TYPES.includes(entry.type);
return html`
<div class="history-entry${compact ? " compact" : ""}">
${compact
? nothing
: html`<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>
${showRef ? renderRefChip(entryRefOf(ctx, entry), t("ref_number", L)) : nothing}
${showBadges && entry.phase_id ? html`<span class="history-phase-badge">${ctx.phaseNames?.[entry.phase_id] || entry.phase_id}</span>` : nothing}
${showBadges && entry.auto ? html`<span class="history-auto-badge">${t("history_auto", L)}</span>` : nothing}
${editable
? html`<button class="history-edit-btn"
title=${t("history_edit_button", L)}
@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}
${renderHistoryPhotos(ctx.hass, entry)}
${renderHistoryReadings(entry, ctx)}
${entry.cost != null || entry.duration != null || entry.trigger_value != null
? html`<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)}: ${formatDuration(entry.duration, L)}</span>` : nothing}
${entry.trigger_value != null ? html`<span>${t("trigger_val", L)}: ${entry.trigger_value}</span>` : nothing}
</div>`
: nothing}
</div>
</div>
`;
}