147 files
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user