147 files
This commit is contained in:
@@ -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;
|
||||
|
||||
+13
-4
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user