/** 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; /** #170: the task's reference ("8.3") — completions render "#8.3-". */ 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`
${_FILTER_TYPES.map((type) => { const count = task.history.filter((h) => h.type === type).length; if (count === 0) return nothing; return html` ctx.setFilter(ctx.filter === type ? null : type)}> ${t(type, L)} (${count}) `; })} ${ctx.filter ? html` ctx.setFilter(null)}>${t("show_all", L)}` : nothing}
ctx.setSearch((e.target as HTMLInputElement).value)} />
`; } 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`

${t("no_history", L)}

`; } return html`
${[...filtered].reverse().map((entry: HistoryEntry) => renderHistoryEntry(entry, ctx))}
`; } /** "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 `` 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) { const photos = historyPhotoIds(entry); return photos.length > 0 ? html`
${photos.map((docId) => html``)}
` : 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`
${values.map((v) => html` ${v.name} ${fmt(v.value)}${v.unit ? ` ${v.unit}` : ""}${delta(ctx.readingSlotDelta?.(entry, v.id))} `)}
`; } if (entry.reading_value != null) { return html`
${t("reading_label", L)} ${fmt(entry.reading_value)}${ctx.readingUnit ? ` ${ctx.readingUnit}` : ""}${delta(ctx.readingDelta?.(entry))}
`; } 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`
${compact ? nothing : html`
`}
${t(entry.type, L)} ${showRef ? renderRefChip(entryRefOf(ctx, entry), t("ref_number", L)) : nothing} ${showBadges && entry.phase_id ? html`${ctx.phaseNames?.[entry.phase_id] || entry.phase_id}` : nothing} ${showBadges && entry.auto ? html`${t("history_auto", L)}` : nothing} ${editable ? html`` : nothing}
${formatDateTime(entry.timestamp, L)}
${entry.notes ? html`
${entry.notes}
` : nothing} ${renderHistoryPhotos(ctx.hass, entry)} ${renderHistoryReadings(entry, ctx)} ${entry.cost != null || entry.duration != null || entry.trigger_value != null ? html`
${entry.cost != null ? html`${t("cost", L)}: ${formatCost(entry.cost, ctx.currencySymbol, L)}` : nothing} ${entry.duration != null ? html`${t("duration", L)}: ${formatDuration(entry.duration, L)}` : nothing} ${entry.trigger_value != null ? html`${t("trigger_val", L)}: ${entry.trigger_value}` : nothing}
` : nothing}
`; }