278 files
This commit is contained in:
@@ -4,28 +4,36 @@
|
||||
* 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, STATUS_ICONS } from "../styles";
|
||||
import type { MaintenanceTask, HistoryEntry, HomeAssistant } from "../types";
|
||||
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";
|
||||
|
||||
export interface HistoryContext {
|
||||
/** 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;
|
||||
/** 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;
|
||||
@@ -38,6 +46,26 @@ export interface HistoryContext {
|
||||
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
|
||||
@@ -74,11 +102,15 @@ export function renderHistoryList(task: MaintenanceTask, ctx: HistoryContext) {
|
||||
? task.history.filter((h) => h.type === ctx.filter)
|
||||
: task.history;
|
||||
|
||||
// Apply search filter
|
||||
// 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) => h.notes?.toLowerCase().includes(search) || (entryRefOf(ctx, h) ?? "").toLowerCase().endsWith(search));
|
||||
filtered = filtered.filter((h) => matchesQuery(h.notes, ctx.search) || (entryRefOf(ctx, h) ?? "").toLowerCase().endsWith(search));
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
@@ -93,30 +125,66 @@ export function renderHistoryList(task: MaintenanceTask, ctx: HistoryContext) {
|
||||
}
|
||||
|
||||
/** "8.3-2" for a numbered completion, null otherwise. */
|
||||
function entryRefOf(ctx: HistoryContext, entry: HistoryEntry): string | null {
|
||||
function entryRefOf(ctx: HistoryEntryContext, entry: HistoryEntry): string | null {
|
||||
return ctx.taskRef && entry.ref_no ? `${ctx.taskRef}-${entry.ref_no}` : null;
|
||||
}
|
||||
|
||||
export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryContext) {
|
||||
/** 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;
|
||||
// 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);
|
||||
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">
|
||||
<div class="history-icon ${entry.type}">
|
||||
<ha-icon .icon=${STATUS_ICONS[entry.type] || "mdi:circle"}></ha-icon>
|
||||
</div>
|
||||
<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>
|
||||
${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}
|
||||
${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) || "Edit entry"}
|
||||
title=${t("history_edit_button", L)}
|
||||
@click=${() => ctx.openEdit(entry)}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>`
|
||||
@@ -124,41 +192,15 @@ 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}
|
||||
${(() => {
|
||||
// #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}
|
||||
${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 ? "+" : ""}${formatNumber(d, L, { maximumFractionDigits: 3 })})`;
|
||||
})()}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${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>
|
||||
`;
|
||||
|
||||
@@ -45,9 +45,9 @@ export function renderSeasonalCardCompact(task: MaintenanceTask, lang: string, f
|
||||
})}
|
||||
</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>
|
||||
<span class="legend-item"><span class="dot low"></span> ${t("shorter", lang)}</span>
|
||||
<span class="legend-item"><span class="dot normal"></span> ${t("normal", lang)}</span>
|
||||
<span class="legend-item"><span class="dot high"></span> ${t("longer", lang)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 { t, formatDate, formatRecurrence, formatCost, formatDuration } from "../styles";
|
||||
import { renderRefChip } from "../helpers/reference";
|
||||
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask, ManualDocRef } from "../types";
|
||||
import { renderTriggerSection, type SparklineContext } from "./sparkline";
|
||||
@@ -23,7 +23,7 @@ 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 { renderHistoryEntry, renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history";
|
||||
import { clampPhaseCursor, effectivePhase, hasPhases } from "../helpers/phases";
|
||||
import "../components/task-documents";
|
||||
|
||||
@@ -369,11 +369,11 @@ function renderKPIBar(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${t("avg_cost", L)}</div>
|
||||
<div class="kpi-value">${formatCost(avgCost, ctx.currencySymbol, L, 0)}</div>
|
||||
<div class="kpi-value">${formatCost(avgCost, ctx.currencySymbol, L)}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${t("avg_duration", L)}</div>
|
||||
<div class="kpi-value">${task.average_duration ? formatNumber(task.average_duration, L, 0) : "—"} min</div>
|
||||
<div class="kpi-value">${task.average_duration ? formatDuration(Math.round(task.average_duration), L) : "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -422,28 +422,13 @@ function renderRecentActivities(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "completed": return "✓";
|
||||
case "triggered": return "⊗";
|
||||
case "skipped": return "↷";
|
||||
case "reset": return "↺";
|
||||
default: return "·";
|
||||
}
|
||||
};
|
||||
|
||||
// The shared history row (compact, no pencil — editing lives on the
|
||||
// History tab); the panel's history context carries the deltas, phase
|
||||
// names and the task ref for the "#8.3-2" chip.
|
||||
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">${formatCost(entry.cost, ctx.currencySymbol, L, 0)}</span>` : nothing}
|
||||
${entry.duration ? html`<span class="activity-badge">${entry.duration}min</span>` : nothing}
|
||||
</div>
|
||||
`)}
|
||||
${recent.map((entry) => renderHistoryEntry(entry, ctx.history, { compact: true, showEdit: false }))}
|
||||
<div class="activity-show-all">
|
||||
<ha-button appearance="plain" @click=${() => ctx.setActiveTab("history")}>${t("show_all", L)} →</ha-button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user