147 files

This commit is contained in:
Home Assistant Version Control
2026-08-27 20:54:53 +00:00
parent 52cbcb4187
commit 69f4dc3f17
149 changed files with 13415 additions and 1156 deletions
@@ -27,6 +27,8 @@ export interface HistoryContext {
/** v2.20 (#83): unit + delta-vs-previous for reading-task entries. */
readingUnit?: string | null;
readingDelta?: (entry: HistoryEntry) => number | null;
/** #139: phase id → display name, for entries stamped with a phase_id. */
phaseNames?: Record<string, string>;
}
// Every HistoryEntryType is filterable (drift audit 2026-08: the repair-flow
@@ -96,6 +98,7 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryContext) {
<div class="history-content">
<div class="history-row">
<strong>${t(entry.type, L)}</strong>
${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
? html`<button class="history-edit-btn"
@@ -22,6 +22,11 @@ export interface SparklineContext {
lang: string;
detailStatsData: Map<string, StatisticsPoint[]>;
hasStatsService: boolean;
/** #141: entities whose series is recorder STATE HISTORY (no long-term
* statistics exist — binary sensors etc.). Changes the footnote and
* suppresses the trigger_current_value tail point (for a state_change
* trigger that is the change COUNTER, not the plotted state). */
historyFallbackIds?: Set<string>;
isCounterEntity: (tc: TriggerConfig) => boolean;
rangeDays: number;
setRangeDays: (days: number) => void;
@@ -252,7 +257,11 @@ function rawStatsPoints(task: MaintenanceTask, ctx: SparklineContext): ChartPoin
}
}
}
if (task.trigger_current_value != null) {
// #141: a history-fallback series already runs to "now" and plots the
// STATE — appending trigger_current_value (a change counter on
// state_change triggers) would graft a wrong-dimension point onto it.
const fromHistory = !!entityId && !!ctx.historyFallbackIds?.has(entityId) && statsPoints.length >= 2;
if (task.trigger_current_value != null && !fromHistory) {
points.push({ ts: Date.now(), val: task.trigger_current_value });
}
points.sort((a, b) => a.ts - b.ts);
@@ -364,11 +373,23 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
@range-change=${(e: CustomEvent<{ days: number }>) => ctx.setRangeDays(e.detail.days)}
@outlier-toggle=${(e: CustomEvent<{ hide: boolean }>) => ctx.setHideOutliers(e.detail.hide)}
></maintenance-trigger-chart>
${statsFetchedEmpty && !loading
? html`<div class="chart-note">
${(() => {
if (loading) return nothing;
// #141: the series came from recorder state history (binary sensors
// never have long-term statistics) — say so, incl. the shorter window.
if (entityId && ctx.historyFallbackIds?.has(entityId) && !statsFetchedEmpty) {
return html`<div class="chart-note">
<ha-icon icon="mdi:information-outline"></ha-icon>
${t("chart_history_fallback", ctx.lang)}
</div>`;
}
if (statsFetchedEmpty) {
return html`<div class="chart-note">
<ha-icon icon="mdi:information-outline"></ha-icon>
${t("chart_no_stats", ctx.lang)}
</div>`
: nothing}
</div>`;
}
return nothing;
})()}
`;
}
@@ -21,6 +21,7 @@ import { renderSeasonalCardCompact, renderSeasonalCardExpanded } from "./seasona
import { renderCostDurationCard } from "./charts";
import { renderDaysProgress } from "./progress";
import { renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history";
import { clampPhaseCursor, effectivePhase, hasPhases } from "../helpers/phases";
import "../components/task-documents";
export interface TaskDetailContext {
@@ -39,6 +40,8 @@ export interface TaskDetailContext {
openManualDoc: (doc: ManualDocRef) => void;
/** #73: persist one checklist tick (panel sends the full state via WS). */
setChecklistItem: (item: string, done: boolean) => void;
/** #139: re-point the phase cursor (operator repair / mid-cycle adoption). */
setPhaseCursor: (cursor: number) => void;
isOperator: boolean;
actionLoading: boolean;
moreMenuOpen: boolean;
@@ -190,9 +193,51 @@ function collapsible(key: string, titleKey: string, body: unknown, ctx: TaskDeta
* reloads, prefills the complete dialog) and resets when the task is
* completed or skipped. Only rendered when the Checklists feature is
* enabled and steps are set. */
/** #139: the cycle strip — every step of the phase sequence as a chip, the
* step currently due highlighted, each phase's last completion underneath.
* Operators can re-point the cursor (mis-click repair, mid-cycle adoption). */
function renderPhasesCard(task: MaintenanceTask, ctx: TaskDetailContext) {
if (!hasPhases(task)) return nothing;
const L = ctx.lang;
const seq = task.phase_sequence!;
const cursor = clampPhaseCursor(task.phase_cursor, seq.length);
// Last completion per phase id — history is append-ordered, so walk it
// backwards and keep the first hit.
const lastByPhase = new Map<string, string>();
for (let i = task.history.length - 1; i >= 0; i--) {
const h = task.history[i];
if (h.phase_id && h.type === "completed" && !lastByPhase.has(h.phase_id)) {
lastByPhase.set(h.phase_id, h.timestamp);
}
}
return html`
<div class="phases-card">
<div class="phases-card-header">
<ha-icon icon="mdi:rotate-right"></ha-icon>
<span>${t("phase_sequence_label", L)}</span>
</div>
<div class="phases-strip">
${seq.map((id, i) => {
const name = task.phases?.[id]?.name || id;
const last = lastByPhase.get(id);
return html`
<div class="phase-step ${i === cursor ? "current" : ""}"
title=${i === cursor ? t("phase_current", L) : (ctx.isOperator ? t("phase_set", L) : "")}
@click=${() => { if (ctx.isOperator && i !== cursor) ctx.setPhaseCursor(i); }}>
<span class="phase-step-name">${i + 1}. ${name}</span>
${last ? html`<span class="phase-step-last">${formatDate(last, L)}</span>` : nothing}
</div>
`;
})}
</div>
</div>
`;
}
function renderChecklistCard(task: MaintenanceTask, ctx: TaskDetailContext) {
if (!ctx.features.checklists) return nothing;
const items = task.checklist || [];
// #139: a phased task shows the checklist of the phase currently due.
const items = effectivePhase(task)?.checklist ?? (task.checklist || []);
if (items.length === 0) return nothing;
const L = ctx.lang;
const progress = task.checklist_progress || {};
@@ -439,6 +484,7 @@ export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext)
</div>
`, ctx)
: nothing}
${renderPhasesCard(task, ctx)}
${renderChecklistCard(task, ctx)}
${renderRecentActivities(task, ctx)}
</div>