393 files
This commit is contained in:
@@ -26,6 +26,24 @@ import { matchesQuery } from "../helpers/search-match";
|
||||
* a different concept: what belongs in the object's record.) */
|
||||
export const EDITABLE_HISTORY_TYPES: readonly HistoryEntryType[] = ["completed", "reset", "skipped"];
|
||||
|
||||
/** Newest first BY TIMESTAMP. The store appends, so array order is entry
|
||||
* order — a backdated completion (#133) lands last although it happened
|
||||
* before the ones above it, and every "reverse the array" view showed it
|
||||
* as the newest (bug audit 2026-09-26). Stable: entries with the same (or
|
||||
* an unparsable) timestamp keep "later appended = newer". */
|
||||
export function newestFirst<T extends { timestamp?: string | null }>(history: readonly T[] | null | undefined): T[] {
|
||||
const ms = (e: T): number => {
|
||||
const raw = e.timestamp ?? "";
|
||||
// Date-only strings are local days (like formatDate) — not UTC midnight.
|
||||
const v = Date.parse(raw.length === 10 ? `${raw}T00:00:00` : raw);
|
||||
return Number.isNaN(v) ? -Infinity : v;
|
||||
};
|
||||
return (history ?? [])
|
||||
.map((entry, index) => ({ entry, index, at: ms(entry) }))
|
||||
.sort((a, b) => (b.at - a.at) || (b.index - a.index))
|
||||
.map((x) => x.entry);
|
||||
}
|
||||
|
||||
/** What one history row needs — the panel's HistoryContext is a superset;
|
||||
* the dialog and the overview card build just this. */
|
||||
export interface HistoryEntryContext {
|
||||
@@ -34,7 +52,10 @@ export interface HistoryEntryContext {
|
||||
hass: HomeAssistant;
|
||||
/** Currency symbol for cost display (defaults to €). */
|
||||
currencySymbol: string;
|
||||
openEdit: (entry: HistoryEntry) => void;
|
||||
/** Opens the history-edit dialog. Absent = the viewer may not edit
|
||||
* (task/history/update is WRITE tier): no pencil at all instead of one
|
||||
* whose Save the server refuses (bug audit 2026-09-26 #2). */
|
||||
openEdit?: (entry: HistoryEntry) => void;
|
||||
/** v2.20 (#83): unit + delta-vs-previous for reading-task entries. */
|
||||
readingUnit?: string | null;
|
||||
readingDelta?: (entry: HistoryEntry) => number | null;
|
||||
@@ -119,7 +140,7 @@ export function renderHistoryList(task: MaintenanceTask, ctx: HistoryContext) {
|
||||
|
||||
return html`
|
||||
<div class="history-timeline">
|
||||
${[...filtered].reverse().map((entry: HistoryEntry) => renderHistoryEntry(entry, ctx))}
|
||||
${newestFirst(filtered).map((entry: HistoryEntry) => renderHistoryEntry(entry, ctx))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -168,7 +189,8 @@ function renderHistoryReadings(entry: HistoryEntry, ctx: HistoryEntryContext) {
|
||||
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);
|
||||
const openEdit = ctx.openEdit;
|
||||
const editable = showEdit && !!openEdit && EDITABLE_HISTORY_TYPES.includes(entry.type);
|
||||
return html`
|
||||
<div class="history-entry${compact ? " compact" : ""}">
|
||||
${compact
|
||||
@@ -185,7 +207,7 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryEntryContext
|
||||
${editable
|
||||
? html`<button class="history-edit-btn"
|
||||
title=${t("history_edit_button", L)}
|
||||
@click=${() => ctx.openEdit(entry)}>
|
||||
@click=${() => openEdit!(entry)}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
|
||||
@@ -23,8 +23,10 @@ import { renderRecommendationBars } from "./recommendation";
|
||||
import { renderSeasonalCardCompact, renderSeasonalCardExpanded } from "./seasonal";
|
||||
import { renderCostDurationCard } from "./charts";
|
||||
import { renderDaysProgress } from "./progress";
|
||||
import { renderHistoryEntry, renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history";
|
||||
import { newestFirst, renderHistoryEntry, renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history";
|
||||
import { renderStatusBadge } from "./status";
|
||||
import { clampPhaseCursor, effectivePhase, hasPhases } from "../helpers/phases";
|
||||
import { renderEventTitles } from "../helpers/event-titles";
|
||||
import "../components/task-documents";
|
||||
|
||||
export interface TaskDetailContext {
|
||||
@@ -118,19 +120,14 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
const L = ctx.lang;
|
||||
const isOperator = ctx.isOperator;
|
||||
|
||||
// Determine status chip — use the backend-computed status. A completed
|
||||
// one-time task is shown as archived ("done") rather than its raw "ok".
|
||||
const statusClass = task.archived ? "archived" : (task.is_done ? "done" : (task.status === "due_soon" ? "warning" : (task.status || "ok")));
|
||||
const statusText = task.archived ? t("archived", L) : (task.is_done ? t("completed", L) : t(task.status || "ok", L));
|
||||
|
||||
return html`
|
||||
<div class="task-header">
|
||||
<div class="task-header-title">
|
||||
<span class="task-name-breadcrumb" @click=${() => ctx.showTaskView()}>${task.name}</span>
|
||||
<span class="task-name-breadcrumb" @click=${() => ctx.showTaskView()}>${task.name}${renderEventTitles(task.next_event_titles)}</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>
|
||||
${renderStatusBadge(task, L, "chip")}
|
||||
${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}
|
||||
@@ -167,9 +164,14 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
${!isOperator ? html`
|
||||
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => ctx.moveTask()}>${t("move_task", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}…</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
|
||||
` : nothing}
|
||||
<!-- Reset / Postpone / Snooze are household actions (read tier,
|
||||
helpers/permissions HOUSEHOLD_ACTIONS): offered to everyone,
|
||||
like Complete / Skip and the Lovelace quick-actions Reset. -->
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}…</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
|
||||
${!isOperator ? html`
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.toggleArchive(!!task.archived); }}>${task.archived ? t("unarchive", L) : t("archive", L)}</div>
|
||||
<div class="popup-menu-divider"></div>
|
||||
<div class="popup-menu-item danger" @click=${() => { ctx.closeMoreMenu(); ctx.deleteTask(); }}>${t("delete", L)}</div>
|
||||
@@ -401,10 +403,12 @@ function renderRecommendationCard(task: MaintenanceTask, ctx: TaskDetailContext)
|
||||
task.interval_confidence || "medium", L,
|
||||
)}
|
||||
<div class="recommendation-actions">
|
||||
<ha-button appearance="filled"
|
||||
@click=${() => ctx.applySuggestion(suggested)}>
|
||||
${t("apply_suggestion", L)}
|
||||
</ha-button>
|
||||
${ctx.isOperator
|
||||
? nothing /* task/apply_suggestion is write tier — the server refuses a read-only member */
|
||||
: html`<ha-button appearance="filled" class="apply-suggestion"
|
||||
@click=${() => ctx.applySuggestion(suggested)}>
|
||||
${t("apply_suggestion", L)}
|
||||
</ha-button>`}
|
||||
<ha-button appearance="plain"
|
||||
@click=${() => ctx.reanalyze()}>
|
||||
${t("reanalyze", L)}
|
||||
@@ -420,7 +424,9 @@ function renderRecommendationCard(task: MaintenanceTask, ctx: TaskDetailContext)
|
||||
|
||||
function renderRecentActivities(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
const L = ctx.lang;
|
||||
const recent = task.history.slice(-3).reverse();
|
||||
// By timestamp, not array order: a backdated completion (#133) is appended
|
||||
// last but belongs further down (bug audit 2026-09-26).
|
||||
const recent = newestFirst(task.history).slice(0, 3);
|
||||
|
||||
if (recent.length === 0) {
|
||||
return nothing;
|
||||
|
||||
Reference in New Issue
Block a user