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
@@ -40,6 +40,9 @@ export class MaintenanceCompleteDialog extends LitElement {
* enforces the same list at every completion surface; blocking Save here
* just means the user never has to meet that rejection. */
@property({ type: Array }) public requiredFields: string[] = [];
/** #139: "2/4 · Flip blades" — which phase of the cycle this completion
* records. Purely informative; the backend advances the cursor itself. */
@property() public phaseLabel = "";
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@@ -286,6 +289,7 @@ export class MaintenanceCompleteDialog extends LitElement {
return html`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${t("complete_title", L)}${this.taskName}</div>
${this.phaseLabel ? html`<div class="phase-line">${t("phase_current", L)}: ${this.phaseLabel}</div>` : nothing}
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this.checklist.length > 0 ? html`
@@ -473,6 +477,12 @@ export class MaintenanceCompleteDialog extends LitElement {
font-weight: 500;
padding-bottom: 12px;
}
.phase-line {
margin-top: -8px;
padding-bottom: 12px;
font-size: 13px;
color: var(--secondary-text-color);
}
.content {
display: flex;
flex-direction: column;
@@ -260,6 +260,18 @@ export class MaintenanceTaskDialog extends LitElement {
// Checklist (newline-separated steps, one per line)
@state() private _checklistText = "";
// Task phases (#139): editable defs + the cycle order. The v1 editor keeps
// each phase to name + checklist + at most ONE own-object part (the mower
// "14 new blades" case); richer per-phase config exists at the WS level.
// `carry` holds def fields the editor has no form for (notes,
// required_completion_fields, future keys) and `extraParts` any links
// beyond the first — both re-merged on save so a no-op edit never wipes
// them (#106 pattern; same passthrough idea as trigger_config's carry).
@state() private _phaseDefs: Array<{
id: string; name: string; checklistText: string; partId: string; partQty: string;
extraParts: TaskPartLink[]; carry: Record<string, unknown>;
}> = [];
@state() private _phaseSeq: string[] = [];
/** Details this task demands on completion — enforced by the backend on
* every surface, so a button press or voice command cannot bypass it. */
@state() private _requiredCompletion: string[] = [];
@@ -388,6 +400,19 @@ export class MaintenanceTaskDialog extends LitElement {
this._rotationStrategy = task.rotation_strategy || "";
this._checklistText = (task.checklist || []).join("\n");
this._phaseDefs = Object.entries(task.phases || {}).map(([id, def]) => {
const { name: _n, checklist: _c, consumes_parts: _p, ...carry } = def as unknown as Record<string, unknown>;
return {
id,
name: def.name || id,
checklistText: (def.checklist || []).join("\n"),
partId: def.consumes_parts?.[0]?.part_id || "",
partQty: def.consumes_parts?.[0]?.quantity != null ? String(def.consumes_parts[0].quantity) : "",
extraParts: (def.consumes_parts || []).slice(1),
carry,
};
});
this._phaseSeq = [...(task.phase_sequence || [])];
this._requiredCompletion = [...(task.required_completion_fields || [])];
this._scheduleTime = task.schedule_time || "";
@@ -506,6 +531,8 @@ export class MaintenanceTaskDialog extends LitElement {
this._assigneePool = [];
this._rotationStrategy = "";
this._checklistText = "";
this._phaseDefs = [];
this._phaseSeq = [];
this._requiredCompletion = [];
this._scheduleTime = "";
this._environmentalEntity = "";
@@ -993,6 +1020,103 @@ export class MaintenanceTaskDialog extends LitElement {
this._requiredCompletion = [...next];
}
// ── Task phases (#139) ───────────────────────────────────────────────
private _phaseSlug(name: string): string {
const base = name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24) || "phase";
let slug = base;
let n = 2;
while (this._phaseDefs.some((d) => d.id === slug)) slug = `${base}-${n++}`;
return slug;
}
private _addPhaseDef(): void {
const id = this._phaseSlug(`phase-${this._phaseDefs.length + 1}`);
this._phaseDefs = [...this._phaseDefs, { id, name: "", checklistText: "", partId: "", partQty: "", extraParts: [], carry: {} }];
}
private _removePhaseDef(id: string): void {
this._phaseDefs = this._phaseDefs.filter((d) => d.id !== id);
this._phaseSeq = this._phaseSeq.filter((s) => s !== id);
}
private _patchPhaseDef(id: string, patch: Partial<{ name: string; checklistText: string; partId: string; partQty: string }>): void {
this._phaseDefs = this._phaseDefs.map((d) => (d.id === id ? { ...d, ...patch } : d));
}
private _renderPhasesEditor(L: string) {
const defName = (id: string) => this._phaseDefs.find((d) => d.id === id)?.name || id;
return html`
<h3>${t("phases_section", L)}</h3>
<div class="field-help">${t("phases_hint", L)}</div>
${this._phaseDefs.map((d) => html`
<div class="phase-def">
<div class="phase-def-head">
<ms-textfield
label="${t("phase_name", L)}"
.value=${d.name}
@input=${(e: Event) => this._patchPhaseDef(d.id, { name: (e.target as HTMLInputElement).value })}
></ms-textfield>
${this.parts.length ? html`
<select
class="phase-part"
.value=${d.partId}
@change=${(e: Event) => this._patchPhaseDef(d.id, { partId: (e.target as HTMLSelectElement).value })}
>
<option value="">—</option>
${this.parts.map((p) => html`<option value=${p.id} ?selected=${p.id === d.partId}>${p.name}</option>`)}
</select>
${d.partId ? html`
<input class="phase-qty" type="number" min="0.01" step="0.01" .value=${d.partQty || "1"}
@input=${(e: Event) => this._patchPhaseDef(d.id, { partQty: (e.target as HTMLInputElement).value })} />
` : nothing}
` : nothing}
<mwc-icon-button class="phase-remove" @click=${() => this._removePhaseDef(d.id)}>
<ha-icon icon="mdi:delete-outline"></ha-icon>
</mwc-icon-button>
</div>
${this.checklistsEnabled ? html`
<textarea
class="checklist-textarea phase-checklist"
rows="2"
placeholder="${t("checklist_placeholder", L)}"
.value=${d.checklistText}
@input=${(e: Event) => this._patchPhaseDef(d.id, { checklistText: (e.target as HTMLTextAreaElement).value })}
></textarea>
` : nothing}
</div>
`)}
<ha-button appearance="plain" @click=${this._addPhaseDef}>
<ha-icon icon="mdi:plus"></ha-icon> ${t("phase_add", L)}
</ha-button>
${this._phaseDefs.some((d) => d.name.trim()) ? html`
<div class="phase-seq-label">${t("phase_sequence_label", L)}</div>
<div class="phase-seq">
${this._phaseSeq.map((id, i) => html`
<span class="phase-chip">
${i + 1}. ${defName(id)}
<button class="phase-chip-x" @click=${() => {
this._phaseSeq = this._phaseSeq.filter((_, idx) => idx !== i);
}}>✕</button>
</span>
`)}
<select
class="phase-seq-add"
.value=${""}
@change=${(e: Event) => {
const id = (e.target as HTMLSelectElement).value;
if (id) this._phaseSeq = [...this._phaseSeq, id];
(e.target as HTMLSelectElement).value = "";
}}
>
<option value="">+ ${t("phase_sequence_add_step", L)}</option>
${this._phaseDefs.filter((d) => d.name.trim()).map((d) => html`<option value=${d.id}>${d.name}</option>`)}
</select>
</div>
` : nothing}
`;
}
private async _save(): Promise<void> {
if (this._loading) return; // synchronous re-entry guard (double-click)
if (!this._name.trim()) return;
@@ -1073,6 +1197,27 @@ export class MaintenanceTaskDialog extends LitElement {
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.reading_unit = this._readingUnit.trim() || null;
// Task phases (#139): always sent — null clears a removed cycle.
{
const defs: Record<string, unknown> = {};
for (const d of this._phaseDefs) {
if (!d.name.trim()) continue;
const def: Record<string, unknown> = { ...d.carry, name: d.name.trim() };
const items = d.checklistText.split("\n").map((s) => s.trim()).filter(Boolean);
if (items.length) def.checklist = items;
const links: TaskPartLink[] = [];
if (d.partId) {
const qty = parseFloat(d.partQty);
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
}
links.push(...d.extraParts);
if (links.length) def.consumes_parts = links;
defs[d.id] = def;
}
const seq = this._phaseSeq.filter((id) => id in defs);
data.phases = Object.keys(defs).length && seq.length ? defs : null;
data.phase_sequence = data.phases ? seq : null;
}
// Only send when a picker was actually rendered. A failed parts load
// leaves both lists empty, and sending [] then would silently wipe links
// the user never saw. entry_id is written ONLY for a foreign pick, so an
@@ -2463,6 +2608,7 @@ export class MaintenanceTaskDialog extends LitElement {
></textarea>
<div class="field-help">${t("checklist_help", L)}</div>
` : nothing}
${this._renderPhasesEditor(L)}
<h3>${t("require_on_completion", L)}</h3>
<div class="required-completion">
${REQUIRED_COMPLETION_KEYS.map((field) => html`
@@ -2742,6 +2888,82 @@ export class MaintenanceTaskDialog extends LitElement {
gap: 8px;
padding: 2px 0;
}
.phase-def {
border: 1px solid var(--divider-color);
border-radius: 6px;
padding: 8px;
margin: 6px 0;
}
.phase-def-head {
display: flex;
align-items: center;
gap: 8px;
}
.phase-def-head ms-textfield {
flex: 1;
min-width: 0;
}
.phase-part {
max-width: 160px;
padding: 6px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color);
color: var(--primary-text-color);
}
.phase-qty {
width: 64px;
padding: 6px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color);
color: var(--primary-text-color);
}
.phase-checklist {
min-height: 56px;
margin-top: 6px;
}
.phase-remove {
--mdc-icon-button-size: 36px;
color: var(--secondary-text-color);
}
.phase-seq-label {
font-size: 12px;
color: var(--secondary-text-color);
margin-top: 8px;
}
.phase-seq {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 4px 0;
}
.phase-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
background: var(--secondary-background-color);
font-size: 13px;
}
.phase-chip-x {
border: none;
background: none;
color: var(--secondary-text-color);
cursor: pointer;
padding: 0 2px;
font-size: 12px;
}
.phase-seq-add {
padding: 4px 8px;
border: 1px dashed var(--divider-color);
border-radius: 12px;
background: transparent;
color: var(--secondary-text-color);
font-size: 13px;
}
.consumes-check {
display: flex;
align-items: center;
@@ -18,6 +18,7 @@ import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInter
import { describeWsError } from "../ws-errors";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { partsForCompletion } from "../helpers/shared-parts";
import { effectivePhase, phaseLabel } from "../helpers/phases";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
@@ -158,13 +159,17 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
import("../dialog-mount").then(async ({ openCompleteDialog }) => {
const task = this._task!;
const isBuy = !!(task as { part_ref?: string }).part_ref;
// #139: a phased task completes the phase currently due, so the phase's
// checklist/parts/required fields replace the task-level ones here.
const phase = effectivePhase(task);
const links = phase ? phase.consumesParts : (task.consumes_parts || []);
let parts: Parameters<typeof openCompleteDialog>[0]["parts"] = [];
if (!isBuy) {
try {
const r = await this.hass.connection.sendMessagePromise<{
objects: MaintenanceObjectResponse[];
}>({ type: "maintenance_supporter/objects", compact: true });
parts = partsForCompletion(task, this._entryId!, r.objects || [], this._lang);
parts = partsForCompletion({ consumes_parts: links }, this._entryId!, r.objects || [], this._lang);
} catch {
// Parts stay empty — the dialog still completes without them.
}
@@ -173,13 +178,14 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
entry_id: this._entryId!,
task_id: this._taskId!,
task_name: task.name,
checklist: task.checklist || [],
checklist: phase ? phase.checklist : (task.checklist || []),
adaptive_enabled: !!task.adaptive_config?.enabled,
required_completion_fields: task.required_completion_fields || [],
required_completion_fields: phase ? phase.requiredFields : (task.required_completion_fields || []),
task_type: task.type || "",
reading_unit: (task as { reading_unit?: string }).reading_unit || "",
parts,
consumes_parts: isBuy ? [] : (task.consumes_parts || []),
consumes_parts: isBuy ? [] : links,
phase_label: phaseLabel(task),
});
if (ok) {
this._notifyChanged("complete");
@@ -542,6 +548,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
${(task.schedule?.kind && !["manual", "one_time"].includes(task.schedule.kind)) || task.interval_days != null
? html`<span><strong>${t("interval", L) || "Interval"}:</strong> ${formatRecurrence(task, L)}</span>`
: nothing}
${phaseLabel(task)
? html`<span><strong>${t("phase_current", L)}:</strong> ${phaseLabel(task)}</span>`
: nothing}
</div>
</div>
@@ -289,6 +289,8 @@ export function openCompleteDialog(args: {
/** #99/#111: per-completion parts selection incl. shared pools. */
parts?: MaintenanceCompleteDialog["parts"];
consumes_parts?: MaintenanceCompleteDialog["consumesParts"];
/** #139: "2/4 · Flip blades" — the phase this completion records. */
phase_label?: string;
}): boolean {
const dlg = getOrCreate<MaintenanceCompleteDialog>(COMPLETE_DIALOG_TAG);
if (!syncHass(dlg)) return false;
@@ -304,6 +306,7 @@ export function openCompleteDialog(args: {
dlg.readingUnit = args.reading_unit ?? "";
dlg.parts = args.parts ?? [];
dlg.consumesParts = args.consumes_parts ?? [];
dlg.phaseLabel = args.phase_label ?? "";
dlg.lang = (getHass()?.language) || "en";
dlg.open();
return true;
@@ -889,5 +889,14 @@
"service_record_entries": "záznamů",
"completed_by": "Dokončil",
"date_from": "Od",
"date_to": "Do"
"date_to": "Do",
"phases_section": "Fáze cyklu (volitelné)",
"phases_hint": "Různé práce ve stejném rytmu — každé dokončení posune na další krok (např. malý, malý, velký servis).",
"phase_add": "Přidat fázi",
"phase_name": "Název fáze",
"phase_sequence_label": "Pořadí cyklu",
"phase_sequence_add_step": "Přidat krok",
"phase_current": "Aktuální fáze",
"phase_set": "Nastavit jako aktuální",
"chart_history_fallback": "Pro tuto entitu nejsou dlouhodobé statistiky — zobrazuje se historie stavů recorderu (obvykle posledních ~10 dní)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "poster",
"completed_by": "Udført af",
"date_from": "Fra",
"date_to": "Til"
"date_to": "Til",
"phases_section": "Cyklusfaser (valgfrit)",
"phases_hint": "Forskelligt arbejde i samme rytme — hver udførelse går videre til næste trin (f.eks. lille, lille, stor service).",
"phase_add": "Tilføj fase",
"phase_name": "Fasenavn",
"phase_sequence_label": "Cyklusrækkefølge",
"phase_sequence_add_step": "Tilføj trin",
"phase_current": "Aktuel fase",
"phase_set": "Sæt som aktuel",
"chart_history_fallback": "Ingen langtidsstatistik for denne enhed — recorderens tilstandshistorik vises (typisk de seneste ~10 dage)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "Einträge",
"completed_by": "Erledigt von",
"date_from": "Von",
"date_to": "Bis"
"date_to": "Bis",
"phases_section": "Zyklus-Phasen (optional)",
"phases_hint": "Unterschiedliche Arbeiten im gleichen Takt — jede Erledigung schaltet zum nächsten Schritt (z. B. klein, klein, groß).",
"phase_add": "Phase hinzufügen",
"phase_name": "Phasenname",
"phase_sequence_label": "Zyklus-Reihenfolge",
"phase_sequence_add_step": "Schritt anhängen",
"phase_current": "Aktuelle Phase",
"phase_set": "Als aktuell setzen",
"chart_history_fallback": "Keine Langzeitstatistik für diese Entität — angezeigt wird der Recorder-Zustandsverlauf (üblicherweise die letzten ~10 Tage)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "entries",
"completed_by": "Completed by",
"date_from": "From",
"date_to": "To"
"date_to": "To",
"phases_section": "Cycle phases (optional)",
"phases_hint": "Different work on one shared schedule — each completion moves to the next step (e.g. small service, small service, big service).",
"phase_add": "Add phase",
"phase_name": "Phase name",
"phase_sequence_label": "Cycle order",
"phase_sequence_add_step": "Add step",
"phase_current": "Current phase",
"phase_set": "Set as current",
"chart_history_fallback": "No long-term statistics for this entity — showing recorder state history (typically the last ~10 days)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "entradas",
"completed_by": "Realizado por",
"date_from": "Desde",
"date_to": "Hasta"
"date_to": "Hasta",
"phases_section": "Fases del ciclo (opcional)",
"phases_hint": "Trabajos distintos con un mismo ritmo: cada finalización pasa al siguiente paso (p. ej. pequeño, pequeño, grande).",
"phase_add": "Añadir fase",
"phase_name": "Nombre de la fase",
"phase_sequence_label": "Orden del ciclo",
"phase_sequence_add_step": "Añadir paso",
"phase_current": "Fase actual",
"phase_set": "Establecer como actual",
"chart_history_fallback": "Sin estadísticas a largo plazo para esta entidad: se muestra el historial de estados del recorder (normalmente los últimos ~10 días)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "merkintää",
"completed_by": "Suorittanut",
"date_from": "Alkaen",
"date_to": "Asti"
"date_to": "Asti",
"phases_section": "Syklin vaiheet (valinnainen)",
"phases_hint": "Eri työt samassa tahdissa — jokainen suoritus siirtyy seuraavaan vaiheeseen (esim. pieni, pieni, iso huolto).",
"phase_add": "Lisää vaihe",
"phase_name": "Vaiheen nimi",
"phase_sequence_label": "Syklin järjestys",
"phase_sequence_add_step": "Lisää askel",
"phase_current": "Nykyinen vaihe",
"phase_set": "Aseta nykyiseksi",
"chart_history_fallback": "Tälle entiteetille ei ole pitkän aikavälin tilastoja — näytetään recorderin tilahistoria (yleensä viimeiset ~10 päivää)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "entrées",
"completed_by": "Réalisé par",
"date_from": "Du",
"date_to": "Au"
"date_to": "Au",
"phases_section": "Phases du cycle (optionnel)",
"phases_hint": "Des travaux différents sur un même rythme — chaque réalisation passe à l'étape suivante (p. ex. petit, petit, grand entretien).",
"phase_add": "Ajouter une phase",
"phase_name": "Nom de la phase",
"phase_sequence_label": "Ordre du cycle",
"phase_sequence_add_step": "Ajouter une étape",
"phase_current": "Phase actuelle",
"phase_set": "Définir comme actuelle",
"chart_history_fallback": "Pas de statistiques à long terme pour cette entité — affichage de l'historique d'états du recorder (généralement les ~10 derniers jours)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "प्रविष्टियाँ",
"completed_by": "द्वारा पूर्ण",
"date_from": "से",
"date_to": "तक"
"date_to": "तक",
"phases_section": "चक्र चरण (वैकल्पिक)",
"phases_hint": "एक ही लय में अलग-अलग कार्य — प्रत्येक पूर्णता अगले चरण पर ले जाती है (जैसे छोटा, छोटा, बड़ा)।",
"phase_add": "चरण जोड़ें",
"phase_name": "चरण का नाम",
"phase_sequence_label": "चक्र क्रम",
"phase_sequence_add_step": "कदम जोड़ें",
"phase_current": "वर्तमान चरण",
"phase_set": "वर्तमान बनाएँ",
"chart_history_fallback": "इस एंटिटी के लिए दीर्घकालिक आँकड़े नहीं हैं — recorder की स्थिति का इतिहास दिखाया जा रहा है (आमतौर पर पिछले ~10 दिन)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "bejegyzés",
"completed_by": "Elvégezte",
"date_from": "Ettől",
"date_to": "Eddig"
"date_to": "Eddig",
"phases_section": "Ciklusfázisok (opcionális)",
"phases_hint": "Különböző munkák azonos ütemben — minden elvégzés a következő lépésre vált (pl. kis, kis, nagy szerviz).",
"phase_add": "Fázis hozzáadása",
"phase_name": "Fázis neve",
"phase_sequence_label": "Ciklus sorrendje",
"phase_sequence_add_step": "Lépés hozzáadása",
"phase_current": "Aktuális fázis",
"phase_set": "Beállítás aktuálisként",
"chart_history_fallback": "Ehhez az entitáshoz nincs hosszú távú statisztika — a recorder állapotelőzményei jelennek meg (jellemzően az utolsó ~10 nap)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "voci",
"completed_by": "Completato da",
"date_from": "Dal",
"date_to": "Al"
"date_to": "Al",
"phases_section": "Fasi del ciclo (opzionale)",
"phases_hint": "Lavori diversi sullo stesso ritmo — ogni completamento passa al passo successivo (es. piccolo, piccolo, grande).",
"phase_add": "Aggiungi fase",
"phase_name": "Nome della fase",
"phase_sequence_label": "Ordine del ciclo",
"phase_sequence_add_step": "Aggiungi passo",
"phase_current": "Fase attuale",
"phase_set": "Imposta come attuale",
"chart_history_fallback": "Nessuna statistica a lungo termine per questa entità — viene mostrata la cronologia degli stati del recorder (di solito gli ultimi ~10 giorni)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "件",
"completed_by": "実施者",
"date_from": "開始",
"date_to": "終了"
"date_to": "終了",
"phases_section": "サイクルフェーズ(任意)",
"phases_hint": "同じ周期で異なる作業を行います。完了するたびに次のステップへ進みます(例:小・小・大整備)。",
"phase_add": "フェーズを追加",
"phase_name": "フェーズ名",
"phase_sequence_label": "サイクルの順序",
"phase_sequence_add_step": "ステップを追加",
"phase_current": "現在のフェーズ",
"phase_set": "現在に設定",
"chart_history_fallback": "このエンティティには長期統計がありません — レコーダーの状態履歴を表示しています(通常は直近約10日分)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "건",
"completed_by": "수행자",
"date_from": "시작",
"date_to": "종료"
"date_to": "종료",
"phases_section": "주기 단계(선택)",
"phases_hint": "같은 주기에 다른 작업을 수행합니다. 완료할 때마다 다음 단계로 넘어갑니다(예: 소·소·대 정비).",
"phase_add": "단계 추가",
"phase_name": "단계 이름",
"phase_sequence_label": "주기 순서",
"phase_sequence_add_step": "스텝 추가",
"phase_current": "현재 단계",
"phase_set": "현재로 설정",
"chart_history_fallback": "이 엔티티에는 장기 통계가 없습니다 — 레코더 상태 기록을 표시합니다(보통 최근 약 10일)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "oppføringer",
"completed_by": "Utført av",
"date_from": "Fra",
"date_to": "Til"
"date_to": "Til",
"phases_section": "Syklusfaser (valgfritt)",
"phases_hint": "Ulikt arbeid i samme rytme — hver fullføring går videre til neste trinn (f.eks. liten, liten, stor service).",
"phase_add": "Legg til fase",
"phase_name": "Fasenavn",
"phase_sequence_label": "Syklusrekkefølge",
"phase_sequence_add_step": "Legg til trinn",
"phase_current": "Gjeldende fase",
"phase_set": "Sett som gjeldende",
"chart_history_fallback": "Ingen langtidsstatistikk for denne enheten — recorderens tilstandshistorikk vises (vanligvis de siste ~10 dagene)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "items",
"completed_by": "Voltooid door",
"date_from": "Van",
"date_to": "Tot"
"date_to": "Tot",
"phases_section": "Cyclusfasen (optioneel)",
"phases_hint": "Verschillend werk op één ritme — elke afronding schuift naar de volgende stap (bijv. klein, klein, groot).",
"phase_add": "Fase toevoegen",
"phase_name": "Fasenaam",
"phase_sequence_label": "Cyclusvolgorde",
"phase_sequence_add_step": "Stap toevoegen",
"phase_current": "Huidige fase",
"phase_set": "Instellen als huidige",
"chart_history_fallback": "Geen langetermijnstatistieken voor deze entiteit — de recorder-statusgeschiedenis wordt getoond (meestal de laatste ~10 dagen)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "wpisów",
"completed_by": "Wykonane przez",
"date_from": "Od",
"date_to": "Do"
"date_to": "Do",
"phases_section": "Fazy cyklu (opcjonalnie)",
"phases_hint": "Różne prace w jednym rytmie — każde ukończenie przechodzi do następnego kroku (np. mały, mały, duży).",
"phase_add": "Dodaj fazę",
"phase_name": "Nazwa fazy",
"phase_sequence_label": "Kolejność cyklu",
"phase_sequence_add_step": "Dodaj krok",
"phase_current": "Bieżąca faza",
"phase_set": "Ustaw jako bieżącą",
"chart_history_fallback": "Brak statystyk długoterminowych dla tej encji — wyświetlana jest historia stanów recordera (zwykle ostatnie ~10 dni)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "registros",
"completed_by": "Concluído por",
"date_from": "De",
"date_to": "Até"
"date_to": "Até",
"phases_section": "Fases do ciclo (opcional)",
"phases_hint": "Trabalhos diferentes no mesmo ritmo — cada conclusão avança para o próximo passo (ex.: pequeno, pequeno, grande).",
"phase_add": "Adicionar fase",
"phase_name": "Nome da fase",
"phase_sequence_label": "Ordem do ciclo",
"phase_sequence_add_step": "Adicionar passo",
"phase_current": "Fase atual",
"phase_set": "Definir como atual",
"chart_history_fallback": "Sem estatísticas de longo prazo para esta entidade — mostrando o histórico de estados do recorder (normalmente os últimos ~10 dias)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "registos",
"completed_by": "Concluído por",
"date_from": "De",
"date_to": "Até"
"date_to": "Até",
"phases_section": "Fases do ciclo (opcional)",
"phases_hint": "Trabalhos diferentes no mesmo ritmo — cada conclusão avança para o passo seguinte (ex.: pequeno, pequeno, grande).",
"phase_add": "Adicionar fase",
"phase_name": "Nome da fase",
"phase_sequence_label": "Ordem do ciclo",
"phase_sequence_add_step": "Adicionar passo",
"phase_current": "Fase atual",
"phase_set": "Definir como atual",
"chart_history_fallback": "Sem estatísticas de longo prazo para esta entidade — a mostrar o histórico de estados do recorder (normalmente os últimos ~10 dias)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "записей",
"completed_by": "Выполнил",
"date_from": "С",
"date_to": "По"
"date_to": "По",
"phases_section": "Фазы цикла (необязательно)",
"phases_hint": "Разные работы в одном ритме — каждое выполнение переходит к следующему шагу (напр., малое, малое, большое ТО).",
"phase_add": "Добавить фазу",
"phase_name": "Название фазы",
"phase_sequence_label": "Порядок цикла",
"phase_sequence_add_step": "Добавить шаг",
"phase_current": "Текущая фаза",
"phase_set": "Сделать текущей",
"chart_history_fallback": "Для этой сущности нет долгосрочной статистики — показана история состояний recorder (обычно последние ~10 дней)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "poster",
"completed_by": "Utförd av",
"date_from": "Från",
"date_to": "Till"
"date_to": "Till",
"phases_section": "Cykelfaser (valfritt)",
"phases_hint": "Olika arbeten i samma rytm — varje slutförande går vidare till nästa steg (t.ex. liten, liten, stor service).",
"phase_add": "Lägg till fas",
"phase_name": "Fasnamn",
"phase_sequence_label": "Cykelordning",
"phase_sequence_add_step": "Lägg till steg",
"phase_current": "Aktuell fas",
"phase_set": "Ange som aktuell",
"chart_history_fallback": "Ingen långtidsstatistik för denna entitet — recorderns tillståndshistorik visas (vanligtvis de senaste ~10 dagarna)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "kayıt",
"completed_by": "Tamamlayan",
"date_from": "Başlangıç",
"date_to": "Bitiş"
"date_to": "Bitiş",
"phases_section": "Döngü aşamaları (isteğe bağlı)",
"phases_hint": "Aynı ritimde farklı işler — her tamamlama bir sonraki adıma geçer (örn. küçük, küçük, büyük bakım).",
"phase_add": "Aşama ekle",
"phase_name": "Aşama adı",
"phase_sequence_label": "Döngü sırası",
"phase_sequence_add_step": "Adım ekle",
"phase_current": "Geçerli aşama",
"phase_set": "Geçerli olarak ayarla",
"chart_history_fallback": "Bu varlık için uzun dönem istatistik yok — recorder durum geçmişi gösteriliyor (genellikle son ~10 gün)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "записів",
"completed_by": "Виконав",
"date_from": "Від",
"date_to": "До"
"date_to": "До",
"phases_section": "Фази циклу (необов'язково)",
"phases_hint": "Різні роботи в одному ритмі — кожне виконання переходить до наступного кроку (напр., мале, мале, велике ТО).",
"phase_add": "Додати фазу",
"phase_name": "Назва фази",
"phase_sequence_label": "Порядок циклу",
"phase_sequence_add_step": "Додати крок",
"phase_current": "Поточна фаза",
"phase_set": "Зробити поточною",
"chart_history_fallback": "Для цієї сутності немає довгострокової статистики — показано історію станів recorder (зазвичай останні ~10 днів)"
}
@@ -889,5 +889,14 @@
"service_record_entries": "条记录",
"completed_by": "完成人",
"date_from": "从",
"date_to": "至"
"date_to": "至",
"phases_section": "循环阶段(可选)",
"phases_hint": "同一节奏下的不同工作——每次完成后进入下一步(例如:小保养、小保养、大保养)。",
"phase_add": "添加阶段",
"phase_name": "阶段名称",
"phase_sequence_label": "循环顺序",
"phase_sequence_add_step": "添加步骤",
"phase_current": "当前阶段",
"phase_set": "设为当前",
"chart_history_fallback": "该实体没有长期统计数据——显示 recorder 状态历史(通常为最近约 10 天)"
}
@@ -19,6 +19,7 @@ import type {
} from "./types";
import { UserService } from "./user-service";
import { partsForCompletion } from "./helpers/shared-parts";
import { effectivePhase, phaseLabel } from "./helpers/phases";
import "./maintenance-card-editor";
import "./components/complete-dialog";
// The Battery Fleet card ships in this globally-loaded bundle so it is
@@ -469,7 +470,7 @@ export class MaintenanceSupporterCard extends LitElement {
</div>
${!compact
? html`<div class="task-meta">
${object_name} · ${t(task.type, L)}${this._assigneeName(task)
${object_name} · ${t(task.type, L)}${phaseLabel(task) ? html` · ${phaseLabel(task)}` : nothing}${this._assigneeName(task)
? html` · <span class="assignee"
><ha-icon icon="mdi:account"></ha-icon>${this._assigneeName(task)}</span
>`
@@ -514,14 +515,19 @@ export class MaintenanceSupporterCard extends LitElement {
// Stop the row's open-task handler from also firing
e.stopPropagation();
const dlg = this.shadowRoot!.querySelector("maintenance-complete-dialog") as any;
// #139: a phased task completes the phase
// currently due — its fields override the
// task-level ones (fallthrough included).
const phase = effectivePhase(task);
dlg.entryId = entry_id;
dlg.taskId = task.id;
dlg.taskName = task.name;
dlg.checklist = task.checklist || [];
dlg.checklist = phase ? phase.checklist : (task.checklist || []);
dlg.adaptiveEnabled = !!task.adaptive_config?.enabled;
dlg.taskType = task.type || "";
dlg.readingUnit = (task as any).reading_unit || "";
dlg.requiredFields = task.required_completion_fields || [];
dlg.requiredFields = phase ? phase.requiredFields : (task.required_completion_fields || []);
dlg.phaseLabel = phaseLabel(task);
dlg.lang = L;
// #99: editable per-completion parts selection
// (skip on buy tasks — those restock instead).
@@ -530,8 +536,9 @@ export class MaintenanceSupporterCard extends LitElement {
// owner — resolving against the object's own
// parts alone left a foreign link invisible.
const isBuy = !!(task as any).part_ref;
dlg.parts = isBuy ? [] : partsForCompletion(task, entry_id, this._objects, L);
dlg.consumesParts = isBuy ? [] : (task.consumes_parts || []);
const links = phase ? phase.consumesParts : (task.consumes_parts || []);
dlg.parts = isBuy ? [] : partsForCompletion({ consumes_parts: links }, entry_id, this._objects, L);
dlg.consumesParts = isBuy ? [] : links;
dlg.open();
}}
>
@@ -17,6 +17,7 @@ import { OBJECT_COLUMNS, DEFAULT_OBJECTS_TABLE_COLUMNS, sanitizeColumns } from "
import { downloadTextFile } from "./helpers/download";
import { buildTaskWorksheetHtml, type WorksheetExcerpt, type WorksheetLabels } from "./helpers/worksheet";
import { describePartLink, partsForCompletion } from "./helpers/shared-parts";
import { effectivePhase, phaseLabel } from "./helpers/phases";
import { describeWsError } from "./ws-errors";
import { panelStyles } from "./panel-styles";
import type {
@@ -1861,7 +1862,9 @@ export class MaintenanceSupporterPanel extends LitElement {
const task = obj?.tasks.find((x) => x.id === taskId);
if (!task) return;
const state: Record<string, boolean> = {};
for (const step of task.checklist || []) {
// #139: a phased task ticks the checklist of the phase currently due.
const steps = effectivePhase(task)?.checklist ?? (task.checklist || []);
for (const step of steps) {
const current = task.checklist_progress?.[step] ?? false;
state[step] = step === item ? done : current;
}
@@ -1888,11 +1891,20 @@ export class MaintenanceSupporterPanel extends LitElement {
const tk = this._objects
.find((o) => o.entry_id === entryId)
?.tasks.find((tsk) => tsk.id === taskId);
// #139: a phased task completes the phase currently due — its checklist,
// parts and required fields override the task-level values (fallthrough
// is already baked into effectivePhase). Resolved HERE so every call
// site of _openCompleteDialog rotates for free.
const phase = tk ? effectivePhase(tk) : null;
dlg.phaseLabel = phase ? phaseLabel(tk) : "";
dlg.taskType = tk?.type || "";
dlg.readingUnit = tk?.reading_unit || "";
// #73: ticks recorded during the cycle prefill the dialog's checklist.
dlg.checklistPrefill = tk?.checklist_progress || {};
dlg.requiredFields = tk?.required_completion_fields || [];
dlg.requiredFields = phase ? phase.requiredFields : (tk?.required_completion_fields || []);
if (phase) {
dlg.checklist = this._features.checklists ? phase.checklist : [];
}
// Spare parts: a buy task gets an editable restock-qty field; a consuming
// task shows what it will decrement (incl. the storage location).
const objParts = this._objects.find((o) => o.entry_id === entryId)?.parts || [];
@@ -1904,15 +1916,16 @@ export class MaintenanceSupporterPanel extends LitElement {
dlg.currencySymbol = this._currencySymbol;
// #111: a link may point at another object's pool — name that object, and
// never drop a line that fails to resolve (the old .filter(Boolean) hid it).
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
const links = phase ? phase.consumesParts : (tk?.consumes_parts || []);
dlg.consumesInfo = links.map((link) =>
describePartLink(link, entryId, this._objects, this._lang),
);
// #99: editable per-completion parts selection (not on buy tasks — those
// RESTOCK via the qty field instead of consuming). The list carries the
// object's own parts plus the shared pools this task draws on, so a foreign
// link is visible and untickable rather than silently absent.
dlg.parts = tk?.part_ref ? [] : partsForCompletion(tk, entryId, this._objects, this._lang);
dlg.consumesParts = tk?.part_ref ? [] : (tk?.consumes_parts || []);
dlg.parts = tk?.part_ref ? [] : partsForCompletion({ consumes_parts: links }, entryId, this._objects, this._lang);
dlg.consumesParts = tk?.part_ref ? [] : links;
dlg.open();
}
@@ -3535,6 +3548,7 @@ export class MaintenanceSupporterPanel extends LitElement {
lang: this._lang,
detailStatsData: this._detailStatsData,
hasStatsService: !!this._statsService,
historyFallbackIds: this._statsService?.historyFallbackIds,
isCounterEntity: (tc) => this._isCounterEntity(tc),
rangeDays: this._chartRangeDays,
setRangeDays: (days) => this._setChartRange(days),
@@ -3579,6 +3593,10 @@ export class MaintenanceSupporterPanel extends LitElement {
setSearch: (s) => { this._historySearch = s; },
openEdit: (entry) => this._openHistoryEdit(entry),
readingUnit: task?.reading_unit ?? null,
// #139: name the phase badge on entries stamped with a phase_id.
phaseNames: Object.fromEntries(
Object.entries(task?.phases || {}).map(([id, def]) => [id, def.name]),
),
readingDelta: (entry) => {
const idx = readings.findIndex((h) => h.timestamp === entry.timestamp);
if (idx <= 0) return null;
@@ -3604,6 +3622,12 @@ export class MaintenanceSupporterPanel extends LitElement {
objectManualDocs: obj?.object?.manual_docs ?? [],
openManualDoc: (doc) => this._openManualDoc(doc),
setChecklistItem: (item, done) => this._setChecklistItem(entryId, taskId, item, done),
setPhaseCursor: (cursor) => {
void this._runAction({
type: "maintenance_supporter/task/set_phase",
entry_id: entryId, task_id: taskId, cursor,
});
},
isOperator: this._isOperator,
actionLoading: this._actionLoading,
moreMenuOpen: this._moreMenuOpen,
@@ -12,12 +12,16 @@
"playwright": "1.62"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
"@open-wc/testing": "^5.0.0",
"@types/react": "^19.2.18",
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-playwright": "^1.0.0",
"esbuild": "^0.28.1",
"lit": "^3.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"typescript": "^7.0.2"
}
},
@@ -550,6 +554,13 @@
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@mdn/browser-compat-data": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz",
@@ -1586,6 +1597,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -2921,6 +2942,13 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
@@ -4981,6 +5009,29 @@
"node": ">= 0.8"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -5221,6 +5272,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -9,12 +9,16 @@
"test:watch": "web-test-runner --config web-test-runner.config.mjs --watch"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
"@open-wc/testing": "^5.0.0",
"@types/react": "^19.2.18",
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-playwright": "^1.0.0",
"esbuild": "^0.28.1",
"lit": "^3.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"typescript": "^7.0.2"
},
"dependencies": {
@@ -1053,6 +1053,53 @@ export const panelStyles = css`
border-color: var(--primary-color);
}
/* #139: cycle-phase strip in the task overview */
.phases-card {
background: var(--card-background-color, #fff);
border-radius: 8px;
padding: 12px 16px;
border: 1px solid var(--divider-color);
margin-top: 8px;
}
.phases-card-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
}
.phases-card-header ha-icon {
--mdc-icon-size: 18px;
color: var(--secondary-text-color);
}
.phases-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.phase-step {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--divider-color);
font-size: 13px;
cursor: pointer;
}
.phase-step.current {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
font-weight: 500;
cursor: default;
}
.phase-step-last {
font-size: 11px;
color: var(--secondary-text-color);
font-weight: 400;
}
/* Checklist preview card (read-only display in task overview) */
.checklist-preview-card {
background: var(--card-background-color, #fff);
@@ -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>
@@ -8,11 +8,27 @@ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const DETAIL_DAYS = 30;
const MINI_DAYS = 14;
/** history/history_during_period rows — compressed keys over WS ({s, lu});
* the uncompressed REST shape is tolerated defensively. */
interface HAHistoryRow {
s?: string;
lu?: number;
state?: string;
last_updated?: string | number;
last_changed?: string | number;
}
export class StatisticsService {
private _hass: HomeAssistant;
private _cache = new Map<string, EntityStatisticsCache>();
private _pending = new Map<string, Promise<StatisticsPoint[]>>();
/** #141: entities whose DETAIL series came from recorder state history
* instead of long-term statistics (binary sensors and other entities
* without a state_class never have statistics) — the chart footnote
* tells the two apart. */
public readonly historyFallbackIds = new Set<string>();
constructor(hass: HomeAssistant) {
this._hass = hass;
}
@@ -141,7 +157,23 @@ export class StatisticsService {
})) as Record<string, HAStatisticsRow[]>;
const rows = result[entityId] || [];
const points = this._normalizeRows(rows, isCounter);
let points = this._normalizeRows(rows, isCounter);
// #141: entities without long-term statistics (binary sensors, sensors
// without a state_class) always come back empty here — fall back to
// recorder STATE HISTORY (typically ~10 days) so a problem sensor's
// on/off timeline draws as a 0/1 step line instead of a flat value.
if (points.length < 2) {
const historyPoints = await this._fetchHistoryFallback(entityId, startTime);
if (historyPoints.length >= 2) {
points = historyPoints;
this.historyFallbackIds.add(entityId);
} else {
this.historyFallbackIds.delete(entityId);
}
} else {
this.historyFallbackIds.delete(entityId);
}
this._cache.set(cacheKey, {
entityId,
@@ -157,6 +189,60 @@ export class StatisticsService {
}
}
/** Recorder state history as chart points. Binary states map to 0/1, and
* every value CHANGE gets a doubled point ((t, old), (t, new)) so the
* line renderer draws a step instead of a slope between samples. */
private async _fetchHistoryFallback(entityId: string, startTime: string): Promise<StatisticsPoint[]> {
try {
const result = (await this._hass.connection.sendMessagePromise({
type: "history/history_during_period",
start_time: startTime,
end_time: new Date().toISOString(),
entity_ids: [entityId],
minimal_response: true,
no_attributes: true,
})) as Record<string, HAHistoryRow[]>;
let rows = result?.[entityId] || [];
// A chatty numeric entity can return thousands of rows — stride-sample
// BEFORE the step expansion (the expansion preserves the edges that
// survive sampling).
if (rows.length > 1000) {
const stride = Math.ceil(rows.length / 500);
rows = rows.filter((_, i) => i % stride === 0 || i === rows.length - 1);
}
const points: StatisticsPoint[] = [];
let prev: number | null = null;
for (const row of rows) {
const state = row.s ?? row.state;
if (state == null || state === "unknown" || state === "unavailable") continue;
let val: number;
if (state === "on" || state === "open" || state === "true") val = 1;
else if (state === "off" || state === "closed" || state === "false") val = 0;
else {
val = parseFloat(state);
if (!Number.isFinite(val)) continue;
}
const raw = row.lu ?? row.last_updated ?? row.last_changed;
const ts = typeof raw === "number" ? raw * 1000 : raw != null ? Date.parse(raw) : NaN;
if (!Number.isFinite(ts)) continue;
if (prev != null && prev !== val) points.push({ ts, val: prev });
points.push({ ts, val });
prev = val;
}
points.sort((a, b) => a.ts - b.ts);
// Extend the last known state to "now" so the line reaches the right
// edge (the caller must NOT append trigger_current_value here — for a
// state_change trigger that is the change COUNTER, not the state).
if (points.length && prev != null) points.push({ ts: Date.now(), val: prev });
return points;
} catch (err) {
console.warn(`[maintenance-supporter] History fallback failed for ${entityId}:`, err);
return [];
}
}
private async _fetchBatch(
entityIds: string[],
period: "hour" | "day",
@@ -604,6 +604,21 @@ export const sharedStyles = css`
color: var(--secondary-text-color);
white-space: nowrap;
}
/* #139 — names the cycle phase a completion recorded. Shares the auto-badge
look; carries the left-pinning margin itself unless the auto badge (which
already has it) follows. */
.history-phase-badge {
margin-right: auto;
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
background: var(--secondary-background-color);
color: var(--secondary-text-color);
white-space: nowrap;
}
.history-phase-badge:has(+ .history-auto-badge) {
margin-right: 0;
}
.history-edit-btn {
background: transparent;
color: var(--secondary-text-color);
@@ -103,6 +103,9 @@ export interface HistoryEntry {
/** v2.37: completion recorded by the system itself (trigger recovered),
* not performed by a user in the UI. */
auto?: boolean;
/** #139: which cycle phase this LATEST completion recorded (backfills
* carry none — they never advanced the cursor). */
phase_id?: string | null;
}
export interface AdaptiveConfig {
@@ -269,6 +272,22 @@ export interface MaintenanceTask {
consumes_parts?: TaskPartLink[] | null;
/** Present on an auto-created "buy" reminder: the owning part. */
part_ref?: { part_id: string } | null;
/** Task phases (#139): cyclic content rotation on one shared cadence. */
phases?: Record<string, TaskPhaseDef> | null;
phase_sequence?: string[] | null;
phase_cursor?: number;
/** Backend-resolved phase currently due (null for phase-less tasks). */
current_phase?: { id: string; name: string; index: number; count: number } | null;
}
/** One phase definition (#139). A set field OVERRIDES the task-level field
* for completions performed while this phase is due; unset falls through. */
export interface TaskPhaseDef {
name: string;
notes?: string;
checklist?: string[];
consumes_parts?: TaskPartLink[];
required_completion_fields?: string[];
}
/** One entry of a task's `consumes_parts`.