429 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-30 02:43:31 +00:00
1 parent 472c19bceb
commit 963cce81a7
566 files changed
+50311 -47616

No files matched your search

@@ -273,3 +273,27 @@ describe("complete-dialog cost suggestion from parts (#104 follow-up)", () => {
expect(chip(el)).to.equal(null);
});
});
describe("complete-dialog require_tag_scan note", () => {
it("shows the scan hint when the task demands proof of presence", async () => {
const { hass } = createMockHass({});
const el = await fixture<MaintenanceCompleteDialog>(html`
<maintenance-complete-dialog
.hass=${hass}
.entryId=${"entry1"}
.taskId=${"task1"}
.taskName=${"Filter Wechsel"}
.lang=${"en"}
.requireTagScan=${true}
></maintenance-complete-dialog>
`);
el.open();
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".scan-required-note")).to.exist;
});
it("stays silent for ordinary tasks", async () => {
const { el } = await mount();
expect(el.shadowRoot!.querySelector(".scan-required-note")).to.equal(null);
});
});
@@ -5,7 +5,7 @@
import { expect } from "@open-wc/testing";
import { formatDate, formatDateTime, setDateTimePrefs } from "../styles";
const reset = () => setDateTimePrefs({ date_format: undefined, time_format: undefined });
const reset = () => setDateTimePrefs({ date_format: undefined, time_format: undefined }, null);
describe("formatDate with HA profile date_format (#97)", () => {
afterEach(reset);
@@ -66,3 +66,39 @@ describe("formatDateTime with HA profile time_format (#97)", () => {
expect(out.toLowerCase()).to.match(/pm|nachm/);
});
});
describe("server country regionalizes the language default (#140)", () => {
afterEach(reset);
it('"en" + AU renders dd/mm/yyyy without any profile setting', () => {
setDateTimePrefs({ date_format: "language" }, "AU");
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
});
it('"en" + US keeps mm/dd/yyyy; "de" + AT stays dd.mm.yyyy', () => {
setDateTimePrefs({ date_format: "language" }, "US");
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
setDateTimePrefs({ date_format: "language" }, "AT");
expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026");
});
it("an explicit profile format always beats the country", () => {
setDateTimePrefs({ date_format: "MDY" }, "AU");
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
setDateTimePrefs({ date_format: "YMD" }, "AU");
expect(formatDate("2026-08-10", "en")).to.equal("2026-08-10");
});
it("garbage/unset country falls back to the language mapping", () => {
setDateTimePrefs({ date_format: "language" }, "!!");
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
setDateTimePrefs({ date_format: "language" }, null);
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
});
it("country survives a later prefs update that omits it (undefined ≠ clear)", () => {
setDateTimePrefs({ date_format: "language" }, "AU");
setDateTimePrefs({ date_format: "language" });
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
});
});
@@ -114,6 +114,83 @@ describe("panel deep links (QR scan routing)", () => {
expect(dlg.taskName).to.equal("No Defaults");
});
it("quick_complete refused for missing details falls back to the dialog WITH the scan flag", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
const { el, sent } = await mountPanel(
[obj("e1", [task({ name: "Needs Notes", require_tag_scan: true })])],
{
"maintenance_supporter/task/quick_complete": () => {
throw { code: "completion_details_required", message: "notes required" };
},
},
);
await settleRaf(el);
await settleRaf(el);
await waitForOpenCompleteDialog(el);
const dlg = completeDialog(el)!;
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "fallback dialog open").to.exist;
expect(dlg.taskName).to.equal("Needs Notes");
// The scan already happened: the gate is met, the note is not shown …
expect(dlg.viaTagScan).to.equal(true);
expect(dlg.requireTagScan).to.equal(true);
expect(dlg.shadowRoot!.querySelector(".scan-required-note")).to.be.null;
// … and the completion carries via_tag_scan for the backend's gate.
const buttons = [...dlg.shadowRoot!.querySelectorAll(".dialog-actions ha-button")];
(buttons[buttons.length - 1] as HTMLElement).click();
await new Promise((r) => setTimeout(r, 20));
const complete = sent.find((m) => m.type === "maintenance_supporter/task/complete");
expect(complete, "task/complete sent").to.exist;
expect(complete!.via_tag_scan).to.equal(true);
});
it("a plain complete (no scan) opens the dialog WITHOUT the scan flag", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=complete");
const { el } = await mountPanel([obj("e1", [task({ name: "Gated", require_tag_scan: true })])]);
await settleRaf(el);
await waitForOpenCompleteDialog(el);
const dlg = completeDialog(el)!;
expect(dlg.viaTagScan).to.equal(false);
expect(dlg.shadowRoot!.querySelector(".scan-required-note"), "note shown").to.exist;
});
it("any other quick_complete refusal relays the server's message", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
const { el } = await mountPanel(
[obj("e1", [task({ name: "Early" })])],
{
"maintenance_supporter/task/quick_complete": () => {
throw { code: "too_early", message: "Earliest completion is 2026-09-01" };
},
},
);
await settleRaf(el);
await settleRaf(el);
expect((el as unknown as { _toastMessage: string })._toastMessage).to.equal(
"Earliest completion is 2026-09-01",
);
const dlg = completeDialog(el);
expect(dlg?.shadowRoot?.querySelector("ha-dialog") ?? null, "no dialog").to.be.null;
});
it("a silent quick_complete success reloads the data", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
const { el, sent } = await mountPanel(
[obj("e1", [task({ name: "Quick" })])],
{ "maintenance_supporter/task/quick_complete": () => ({ success: true, via: "quick" }) },
);
await settleRaf(el);
await settleRaf(el);
// The deep link is only routed once the objects are known, so every
// objects fetch AFTER the quick_complete is the reload it triggered.
const atQuick = sent.findIndex((m) => m.type === "maintenance_supporter/task/quick_complete");
expect(atQuick, "quick_complete fired").to.be.greaterThan(-1);
const reloaded = sent
.slice(atQuick + 1)
.some((m) => m.type === "maintenance_supporter/objects");
expect(reloaded, "objects refetched after the silent complete").to.equal(true);
});
it("an unknown entry_id lands safely on the overview", async () => {
setDeepLink("entry_id=does-not-exist&task_id=t1&action=complete");
const { el } = await mountPanel([obj("e1", [task({ name: "Real" })])]);
@@ -215,6 +215,19 @@ describe("task-detail renderer", () => {
expect(host2.querySelector(".kpi-bar")).to.be.null;
});
it("history tab offers 'add past completion' routing to openComplete (#142)", () => {
let completed: MaintenanceTask | null = null;
const t2 = task();
const host = mount(t2, ctx({
activeTab: "history",
openComplete: (tk) => { completed = tk; },
}));
const btn = host.querySelector(".history-add-past-btn") as HTMLElement;
expect(btn, "add-past button").to.exist;
btn.click();
expect(completed).to.equal(t2);
});
it("object-manual row falls back to an attached manual when the URL is empty", () => {
let opened: unknown = null;
const host = mount(task(), ctx({
@@ -43,6 +43,15 @@ export class MaintenanceCompleteDialog extends LitElement {
/** #139: "2/4 · Flip blades" — which phase of the cycle this completion
* records. Purely informative; the backend advances the cursor itself. */
@property() public phaseLabel = "";
/** Proof of presence: the task completes only via NFC/QR scan — the
* dialog says so up front instead of relaying the server's refusal. */
@property({ type: Boolean }) public requireTagScan = false;
/** This open is the fallback of a QR/NFC scan (quick-complete refused for
* missing defaults/details): the completion carries `via_tag_scan` so the
* tag-scan gate counts the scan that got the user here, and the "scan
* required" note stays hidden — they just did. Set by open(); never
* survives into the next open. */
@property({ type: Boolean }) public viaTagScan = false;
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@@ -66,9 +75,12 @@ export class MaintenanceCompleteDialog extends LitElement {
* prefill the dialog so nobody re-ticks what is already done. */
@property({ attribute: false }) public checklistPrefill: Record<string, boolean> = {};
public open(): void {
public open(opts: { viaTagScan?: boolean } = {}): void {
if (this._open) return;
this._open = true;
// Reset on every open — a plain open after a scan fallback must not
// keep claiming proof of presence.
this.viaTagScan = !!opts.viaTagScan;
this._notes = "";
this._cost = "";
this._duration = "";
@@ -173,6 +185,12 @@ export class MaintenanceCompleteDialog extends LitElement {
if (this._photoDocId) {
data.photo_doc_id = this._photoDocId;
}
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
// require_tag_scan task can still be finished from the dialog the scan
// opened. Only ever sent when a scan actually happened.
if (this.viaTagScan) {
data.via_tag_scan = true;
}
if (this._completedAt) {
// Client-side guard mirrors the backend rule — a picked future moment
// fails fast with a localized message instead of a WS roundtrip.
@@ -290,6 +308,7 @@ export class MaintenanceCompleteDialog extends LitElement {
<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}
${this.requireTagScan && !this.viaTagScan ? html`<div class="scan-required-note">${t("require_tag_scan_hint", L)}</div>` : nothing}
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this.checklist.length > 0 ? html`
@@ -477,6 +496,14 @@ export class MaintenanceCompleteDialog extends LitElement {
font-weight: 500;
padding-bottom: 12px;
}
.scan-required-note {
margin: -4px 0 12px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255, 152, 0, 0.12);
color: var(--primary-text-color);
font-size: 13px;
}
.phase-line {
margin-top: -8px;
padding-bottom: 12px;
@@ -39,6 +39,10 @@ export class MsTextfield extends LitElement {
@property() public max?: string;
@property() public pattern?: string;
@property() public helper?: string;
/** Render a <textarea> instead of an <input> (free-text notes). The
* `input` event contract is identical — target.value works for both. */
@property({ type: Boolean }) public multiline = false;
@property({ type: Number }) public rows = 3;
/** Forwards the native input's value into our `value` property and
* re-fires as a bubbling `input` event so consumers reading
@@ -56,6 +60,16 @@ export class MsTextfield extends LitElement {
return html`
<label class="field">
${this.label ? html`<span class="label">${this.label}${this.required ? html`<span class="req">*</span>` : nothing}</span>` : nothing}
${this.multiline ? html`
<textarea
.value=${this.value ?? ""}
rows=${this.rows}
?required=${this.required}
?disabled=${this.disabled}
placeholder=${this.placeholder}
@input=${this._onInput}
@change=${this._onInput}
></textarea>` : html`
<input
.value=${this.value ?? ""}
.type=${this.type}
@@ -68,7 +82,7 @@ export class MsTextfield extends LitElement {
pattern=${this.pattern ?? nothing}
@input=${this._onInput}
@change=${this._onInput}
/>
/>`}
${this.helper ? html`<span class="helper">${this.helper}</span>` : nothing}
</label>
`;
@@ -103,6 +117,21 @@ export class MsTextfield extends LitElement {
border-color: var(--primary-color);
}
input:disabled { opacity: 0.5; cursor: not-allowed; }
textarea {
padding: 8px 10px;
font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color, rgba(255,255,255,0.12));
border-radius: 6px;
font-family: inherit;
width: 100%;
box-sizing: border-box;
outline: none;
resize: vertical;
}
textarea:focus { border-color: var(--primary-color); }
textarea:disabled { opacity: 0.5; cursor: not-allowed; }
.helper {
font-size: 11px;
color: var(--secondary-text-color);
@@ -217,6 +217,7 @@ export class MaintenanceObjectDialog extends LitElement {
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLTextAreaElement).value)}
></textarea>
<span class="md-hint">${t("notes_markdown_hint", L)}</span>
</label>
</div>
<div class="dialog-actions">
@@ -271,6 +272,9 @@ export class MaintenanceObjectDialog extends LitElement {
.textarea-field textarea:focus {
outline: none; border-color: var(--primary-color);
}
.md-hint {
font-size: 11px; color: var(--secondary-text-color); font-style: italic;
}
.parent-select {
padding: 8px 10px; font-size: 14px; font-family: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
@@ -10,6 +10,7 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
import { property, state } from "lit/decorators.js";
import { t, STATUS_COLORS, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
@@ -186,7 +187,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
? html`
<div class="notes-section">
<strong>${t("object_notes_label", L)}</strong>
<div class="notes-body">${obj.notes}</div>
<div class="notes-body">${renderNotesMarkdown(obj.notes)}</div>
</div>
`
: nothing}
@@ -294,6 +295,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
.task-name { flex: 1; font-size: 14px; }
.task-status { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; }
.notes-body { white-space: pre-wrap; font-size: 13px; padding: 8px; background: var(--secondary-background-color); border-radius: 6px; }
.notes-body ha-markdown { white-space: normal; }
.actions { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid var(--divider-color); }
.actions .btn { flex: 1; }
.btn {
@@ -3,6 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { live } from "lit/directives/live.js";
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
import { t, langOf } from "../styles";
import { signApiPath } from "../helpers/document-url";
@@ -36,6 +37,8 @@ interface SettingsResponse {
// so the picker matches the options-flow dropdown exactly. Optional for
// backward-compat with an older backend that didn't send it.
notify_targets?: string[];
/** v2.67: todo.* entity the buy-task shopping sync mirrors into ("" = off). */
shopping_list_entity?: string;
panel_enabled: boolean;
panel_title: string;
/** Opt-in copy of the shipped Assist sentences into the config dir. */
@@ -249,6 +252,11 @@ export class MaintenanceSettingsView extends LitElement {
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch {
this._showToast(t("action_error", this._lang));
// The control already shows the rejected value (the browser changed it
// before we asked the server). `_settings` is unchanged, so a plain
// re-render would not touch the DOM — the selects bind their value
// through `live()` precisely so this re-render snaps them back.
this.requestUpdate();
}
}
@@ -579,15 +587,16 @@ export class MaintenanceSettingsView extends LitElement {
<h3>${t("settings_general", L)}</h3>
<label class="setting-row">
<span class="setting-label">${t("settings_default_warning", L)}</span>
<input type="number" min="1" max="365" .value=${String(g.default_warning_days)}
<input type="number" min="0" max="365" .value=${String(g.default_warning_days)}
@change=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
if (v >= 1 && v <= 365) this._updateSetting("default_warning_days", v);
// 0 = no warning window (due soon only on the due date) — #145.
if (v >= 0 && v <= 365) this._updateSetting("default_warning_days", v);
}} />
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_currency", L)}</span>
<select @change=${(e: Event) => this._updateSetting("budget_currency", (e.target as HTMLSelectElement).value)}>
<select .value=${live(b.currency)} @change=${(e: Event) => this._updateSetting("budget_currency", (e.target as HTMLSelectElement).value)}>
${CURRENCIES.map((c) => html`<option value=${c} ?selected=${b.currency === c}>${c}</option>`)}
</select>
</label>
@@ -652,10 +661,29 @@ export class MaintenanceSettingsView extends LitElement {
</div>
` : nothing}
` : nothing}
<label class="setting-row">
<span class="setting-label" title=${t("settings_shopping_list_help", L)}>${t("settings_shopping_list", L)}</span>
<select .value=${live(g.shopping_list_entity || "")}
@change=${(e: Event) => this._updateSetting("shopping_list_entity", (e.target as HTMLSelectElement).value)}>
<option value="" ?selected=${!g.shopping_list_entity}>${t("shopping_list_none", L)}</option>
${this._todoEntities(g.shopping_list_entity || "").map((id) => html`
<option value=${id} ?selected=${g.shopping_list_entity === id}>${id}</option>
`)}
</select>
</label>
</div>
`;
}
/** All todo.* entities, plus the saved value even when its integration
* hasn't loaded (so an offline pick isn't silently dropped from the UI). */
private _todoEntities(current: string): string[] {
const ids = Object.keys(this.hass?.states || {}).filter((id) => id.startsWith("todo.")).sort();
if (current && !ids.includes(current)) ids.unshift(current);
return ids;
}
// --- Section: Notifications ---
private _renderNotifications(L: string) {
@@ -762,7 +790,7 @@ export class MaintenanceSettingsView extends LitElement {
<label class="setting-row">
<span class="setting-label">${t("settings_notify_scope", L)}</span>
<select
.value=${n.scope_view_id || ""}
.value=${live(n.scope_view_id || "")}
@change=${(e: Event) => this._updateSetting("notify_scope_view_id", (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!n.scope_view_id}>${t("settings_notify_scope_all", L)}</option>
@@ -244,6 +244,7 @@ export class MaintenanceTaskDialog extends LitElement {
// NFC
@state() private _lastPerformed = "";
@state() private _nfcTagId = "";
@state() private _requireTagScan = false;
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
@state() private _readingUnit = "";
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
@@ -263,12 +264,16 @@ export class MaintenanceTaskDialog extends LitElement {
// 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).
// `carry` holds def fields the editor has no form for (notes, 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). Required completion fields are a real
// form now (#139 follow-up): `reqOverride` distinguishes "inherit the
// task's list" (off) from an explicit per-phase list — including the
// meaningful EMPTY override "this phase demands nothing".
@state() private _phaseDefs: Array<{
id: string; name: string; checklistText: string; partId: string; partQty: string;
reqOverride: boolean; reqFields: string[];
extraParts: TaskPartLink[]; carry: Record<string, unknown>;
}> = [];
@state() private _phaseSeq: string[] = [];
@@ -348,6 +353,11 @@ export class MaintenanceTaskDialog extends LitElement {
this._entryId = entryId;
this._taskId = task.id;
this._error = "";
// The object picker belongs to CREATE only. openCreate("", objects) left
// the choices behind, so Create → Cancel → Edit showed the dropdown in
// edit mode — and picking another object re-pointed _entryId while
// _taskId kept the old task (audit 2026-08-29).
this._objectChoices = [];
this._name = task.name;
this._type = task.type;
this._scheduleType = task.schedule_type;
@@ -389,6 +399,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._enabled = task.enabled !== false;
this._lastPerformed = task.last_performed || "";
this._nfcTagId = task.nfc_tag_id || "";
this._requireTagScan = !!task.require_tag_scan;
this._readingUnit = task.reading_unit || "";
// Whole link, entry_id included — hydrating only part_id would turn every
// shared-pool link into an own-part link on the next save (#111).
@@ -401,14 +412,28 @@ export class MaintenanceTaskDialog extends LitElement {
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>;
const {
name: _n, checklist: _c, consumes_parts: _p, required_completion_fields: _r,
...carry
} = def as unknown as Record<string, unknown>;
// The editor's single part field is the first OWN link (no entry_id —
// the picker only lists the object's own parts). Every other link,
// shared pools (#111) first among them, rides along whole in
// `extraParts` and is re-emitted on save: hydrating index 0 blindly
// turned a phase whose first link is foreign into an own-part link
// (or lost it) on the next save (audit 2026-08-29).
const links = def.consumes_parts || [];
const ownIdx = links.findIndex((l) => !l.entry_id);
const own = ownIdx >= 0 ? links[ownIdx] : undefined;
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),
partId: own?.part_id || "",
partQty: own?.quantity != null ? String(own.quantity) : "",
reqOverride: def.required_completion_fields !== undefined,
reqFields: [...(def.required_completion_fields || [])],
extraParts: links.filter((_, i) => i !== ownIdx).map((l) => ({ ...l })),
carry,
};
});
@@ -525,6 +550,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._enabled = true;
this._lastPerformed = "";
this._nfcTagId = "";
this._requireTagScan = false;
this._readingUnit = "";
this._consumesParts = {};
this._responsibleUserId = null;
@@ -1032,7 +1058,7 @@ export class MaintenanceTaskDialog extends LitElement {
private _addPhaseDef(): void {
const id = this._phaseSlug(`phase-${this._phaseDefs.length + 1}`);
this._phaseDefs = [...this._phaseDefs, { id, name: "", checklistText: "", partId: "", partQty: "", extraParts: [], carry: {} }];
this._phaseDefs = [...this._phaseDefs, { id, name: "", checklistText: "", partId: "", partQty: "", reqOverride: false, reqFields: [], extraParts: [], carry: {} }];
}
private _removePhaseDef(id: string): void {
@@ -1040,7 +1066,10 @@ export class MaintenanceTaskDialog extends LitElement {
this._phaseSeq = this._phaseSeq.filter((s) => s !== id);
}
private _patchPhaseDef(id: string, patch: Partial<{ name: string; checklistText: string; partId: string; partQty: string }>): void {
private _patchPhaseDef(
id: string,
patch: Partial<{ name: string; checklistText: string; partId: string; partQty: string; reqOverride: boolean; reqFields: string[] }>,
): void {
this._phaseDefs = this._phaseDefs.map((d) => (d.id === id ? { ...d, ...patch } : d));
}
@@ -1084,6 +1113,33 @@ export class MaintenanceTaskDialog extends LitElement {
@input=${(e: Event) => this._patchPhaseDef(d.id, { checklistText: (e.target as HTMLTextAreaElement).value })}
></textarea>
` : nothing}
<label class="req-option phase-req-toggle">
<input
type="checkbox"
.checked=${d.reqOverride}
@change=${(e: Event) => this._patchPhaseDef(d.id, { reqOverride: (e.target as HTMLInputElement).checked })}
/>
<span>${t("phase_require_override", L)}</span>
</label>
${d.reqOverride ? html`
<div class="required-completion phase-req-fields">
${REQUIRED_COMPLETION_KEYS.map((field) => html`
<label class="req-option">
<input
type="checkbox"
.checked=${d.reqFields.includes(field)}
@change=${(e: Event) => {
const on = (e.target as HTMLInputElement).checked;
const next = new Set(d.reqFields);
if (on) next.add(field); else next.delete(field);
this._patchPhaseDef(d.id, { reqFields: [...next] });
}}
/>
<span>${t(REQUIRED_COMPLETION_LABELS[field], L)}</span>
</label>
`)}
</div>
` : nothing}
</div>
`)}
<ha-button appearance="plain" @click=${this._addPhaseDef}>
@@ -1196,6 +1252,7 @@ export class MaintenanceTaskDialog extends LitElement {
data.enabled = this._enabled;
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.require_tag_scan = this._requireTagScan;
data.reading_unit = this._readingUnit.trim() || null;
// Task phases (#139): always sent — null clears a removed cycle.
{
@@ -1210,8 +1267,19 @@ export class MaintenanceTaskDialog extends LitElement {
const qty = parseFloat(d.partQty);
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
}
links.push(...d.extraParts);
// entry_id travels only when present (a shared pool, #111) — an
// own-part link stays byte-identical to what shipped before.
for (const l of d.extraParts) {
links.push(
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
if (links.length) def.consumes_parts = links;
// Override on → send the list, EMPTY included ("demands nothing");
// off → omit the key so the task-level list falls through.
if (d.reqOverride) def.required_completion_fields = [...d.reqFields];
defs[d.id] = def;
}
const seq = this._phaseSeq.filter((id) => id in defs);
@@ -2432,7 +2500,7 @@ export class MaintenanceTaskDialog extends LitElement {
<div class="dialog-title">${title}</div>
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this._objectChoices.length > 0 ? html`
${this._taskId === null && this._objectChoices.length > 0 ? html`
<div class="select-row">
<label>${t("object", L)}</label>
<select
@@ -2703,6 +2771,9 @@ export class MaintenanceTaskDialog extends LitElement {
${this._renderAdaptiveSection(L)}
<ms-textfield
label="${t("notes_optional", L)}"
multiline
.rows=${3}
.helper=${t("notes_markdown_hint", L)}
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)}
></ms-textfield>
@@ -2751,6 +2822,15 @@ export class MaintenanceTaskDialog extends LitElement {
</div>
`
}
<label class="req-option">
<input
type="checkbox"
.checked=${this._requireTagScan}
@change=${(e: Event) => (this._requireTagScan = (e.target as HTMLInputElement).checked)}
/>
<span>${t("require_tag_scan", L)}</span>
</label>
${this._requireTagScan ? html`<div class="field-help">${t("require_tag_scan_help", L)}</div>` : nothing}
<label class="toggle-row">
<input
type="checkbox"
@@ -2923,6 +3003,15 @@ export class MaintenanceTaskDialog extends LitElement {
min-height: 56px;
margin-top: 6px;
}
.phase-req-toggle {
margin-top: 6px;
font-size: 13px;
color: var(--secondary-text-color);
}
.phase-req-fields {
margin: 2px 0 0 22px;
font-size: 13px;
}
.phase-remove {
--mdc-icon-button-size: 36px;
color: var(--secondary-text-color);
@@ -17,8 +17,8 @@ import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { partsForCompletion } from "../helpers/shared-parts";
import { effectivePhase, phaseLabel } from "../helpers/phases";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
import { phaseLabel } from "../helpers/phases";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
@@ -63,6 +63,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
};
@state() private _toast = "";
private _featuresLoaded = false;
/** Currency symbol for the complete dialog's cost suggestion — read off
* the same settings response as the feature flags. */
private _currencySymbol = "";
private get _lang(): string {
return langOf(this.hass);
@@ -93,10 +96,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
try {
const r = await this.hass.connection.sendMessagePromise<{
features?: Partial<AdvancedFeatures>;
budget?: { currency_symbol?: string };
}>({ type: "maintenance_supporter/settings" });
if (r?.features) {
this._features = { ...this._features, ...r.features };
}
this._currencySymbol = r?.budget?.currency_symbol || "";
this._featuresLoaded = true;
} catch {
// Settings endpoint unavailable — leave defaults (all false), the
@@ -158,35 +163,34 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
// (bug audit 2026-08-22).
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({ consumes_parts: links }, this._entryId!, r.objects || [], this._lang);
} catch {
// Parts stay empty — the dialog still completes without them.
}
// The object list resolves the parts picker (own inventory + shared
// pools, #99/#111), the "consumes" hint lines and a buy task's restock
// default — fetched for every task kind, since a buy task needs its
// part's restock quantity + unit cost too.
let objects: MaintenanceObjectResponse[] = [];
try {
const r = await this.hass.connection.sendMessagePromise<{
objects: MaintenanceObjectResponse[];
}>({ type: "maintenance_supporter/objects", compact: true });
objects = r.objects || [];
} catch {
// Parts stay empty — the dialog still completes without them.
}
const ok = openCompleteDialog({
entry_id: this._entryId!,
task_id: this._taskId!,
task_name: task.name,
checklist: phase ? phase.checklist : (task.checklist || []),
adaptive_enabled: !!task.adaptive_config?.enabled,
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 ? [] : links,
phase_label: phaseLabel(task),
});
// Same derivation as the panel and the card (phase override, tag-scan
// gate, checklist ticks …) — see helpers/complete-dialog-args.
const ok = openCompleteDialog(
buildCompleteDialogArgs({
entryId: this._entryId!,
taskId: this._taskId!,
taskName: task.name,
task,
objects,
lang: this._lang,
checklist: task.checklist || [],
adaptiveEnabled: !!task.adaptive_config?.enabled,
currencySymbol: this._currencySymbol,
}),
);
if (ok) {
this._notifyChanged("complete");
this.close();
@@ -45,6 +45,7 @@ import type { MaintenanceTaskQuickActionsDialog } from "./components/task-quick-
import type { MaintenanceObjectQuickActionsDialog } from "./components/object-quick-actions-dialog";
import type { HomeAssistant, MaintenanceObject } from "./types";
import { ensureLocale, isLocaleLoaded, langOf } from "./styles";
import { fillAndOpenCompleteDialog, type CompleteDialogArgs } from "./helpers/complete-dialog-args";
const OBJECT_DIALOG_TAG = "maintenance-object-dialog";
const TASK_DIALOG_TAG = "maintenance-task-dialog";
@@ -274,41 +275,16 @@ export function openHistoryEditDialog(draft: HistoryEntryDraft): boolean {
return true;
}
/** v2.3.0: open the rich complete-dialog from any Lovelace context. */
export function openCompleteDialog(args: {
entry_id: string;
task_id: string;
task_name: string;
checklist?: string[];
adaptive_enabled?: boolean;
/** Details the task demands before it counts as done (v2.44). */
required_completion_fields?: string[];
/** Reading tasks need type+unit or the value field never renders. */
task_type?: string;
reading_unit?: string;
/** #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 {
/** v2.3.0: open the rich complete-dialog from any Lovelace context.
*
* Takes the full argument bag (`buildCompleteDialogArgs` derives it from a
* task + object list). Every dialog field is assigned — the dialog is a
* singleton, so an omitted field must reset, never leak the previous
* task's value (tag-scan gate, restock default, checklist ticks included). */
export function openCompleteDialog(args: CompleteDialogArgs): boolean {
const dlg = getOrCreate<MaintenanceCompleteDialog>(COMPLETE_DIALOG_TAG);
if (!syncHass(dlg)) return false;
dlg.entryId = args.entry_id;
dlg.taskId = args.task_id;
dlg.taskName = args.task_name;
dlg.checklist = args.checklist ?? [];
dlg.adaptiveEnabled = !!args.adaptive_enabled;
dlg.requiredFields = args.required_completion_fields ?? [];
// Always assign — the dialog is a singleton, so an omitted field must not
// leak the previous task's value.
dlg.taskType = args.task_type ?? "";
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();
fillAndOpenCompleteDialog(dlg, args, (getHass()?.language) || "en");
return true;
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Prázdné = půlnoc (výchozí). Časové pásmo HA.",
"at_time": "v",
"notes_optional": "Poznámky (volitelné)",
"notes_markdown_hint": "Markdown je podporován — **tučně**, seznamy, [odkazy](…)",
"cost_optional": "Náklady (volitelné)",
"duration_minutes": "Doba trvání v minutách (volitelné)",
"completed_at_optional": "Dokončeno dne (volitelné, prázdné = nyní)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Název panelu",
"settings_notifications": "Oznámení",
"settings_notify_service": "Služba oznámení",
"settings_shopping_list": "Nákupní seznam (nákupní úkoly)",
"settings_shopping_list_help": "Upozornění na nákup docházejících dílů se objeví v tomto seznamu; odškrtnutí doplní zásobu.",
"shopping_list_none": "Vypnuto — žádný nákupní seznam",
"settings_install_assist_sentences": "Nainstalovat věty pro Assist",
"settings_install_assist_sentences_hint": "Zkopíruje hlasové věty do vaší konfigurace, aby je klasický agent Assist rozpoznal. Soubor, který jste upravili, nebude nikdy přepsán.",
"test_notification": "Testovací oznámení",
@@ -898,5 +902,13 @@
"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í)"
"chart_history_fallback": "Pro tuto entitu nejsou dlouhodobé statistiky — zobrazuje se historie stavů recorderu (obvykle posledních ~10 dní)",
"chart_history_alarm": "Pohled spouštěče z historie stavů: 1 = stav výstrahy držen po dobu přidržení, 0 = v pořádku (obvykle posledních ~10 dní)",
"chart_history_count": "Počet změn sestaven z historie stavů od posledního servisu (obvykle posledních ~10 dní)",
"prediction_cycles": "Naučeno z cyklů",
"phase_require_override": "Přepsat „Vyžadovat při dokončení“ pro tuto fázi",
"history_add_past": "Přidat dřívější dokončení",
"require_tag_scan": "Dokončit pouze naskenováním štítku",
"require_tag_scan_help": "Důkaz přítomnosti: „Hotovo“ je všude blokováno, dokud nenaskenujete NFC štítek nebo QR kód na zařízení. Automatizace mohou službě complete předat 'via_tag_scan'.",
"require_tag_scan_hint": "Tento úkol lze dokončit pouze naskenováním jeho NFC štítku nebo QR kódu na zařízení — uložení zde bude odmítnuto."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "Tom = midnat (standard). HA-tidszone.",
"at_time": "kl.",
"notes_optional": "Noter (valgfrit)",
"notes_markdown_hint": "Markdown understøttes — **fed**, lister, [links](…)",
"cost_optional": "Omkostning (valgfrit)",
"duration_minutes": "Varighed i minutter (valgfrit)",
"completed_at_optional": "Udført den (valgfrit, tomt = nu)",
@@ -453,6 +454,9 @@
"settings_panel_title": "Sidepanelets titel",
"settings_notifications": "Notifikationer",
"settings_notify_service": "Notifikationstjeneste",
"settings_shopping_list": "Indkøbsliste (købsopgaver)",
"settings_shopping_list_help": "Købspåmindelser for dele med lav beholdning vises på denne liste; afkrydsning genopfylder delen.",
"shopping_list_none": "Fra — ingen indkøbsliste",
"settings_install_assist_sentences": "Installer Assist-sætninger",
"settings_install_assist_sentences_hint": "Kopierer stemmesætningerne til din konfiguration, så den klassiske Assist-agent genkender dem. En fil, du selv har redigeret, overskrives aldrig.",
"test_notification": "Testnotifikation",
@@ -898,5 +902,13 @@
"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)"
"chart_history_fallback": "Ingen langtidsstatistik for denne enhed — recorderens tilstandshistorik vises (typisk de seneste ~10 dage)",
"chart_history_alarm": "Udløservisning fra tilstandshistorikken: 1 = alarmtilstand fastholdt i holdetiden, 0 = ok (typisk de seneste ~10 dage)",
"chart_history_count": "Antal skift genopbygget fra tilstandshistorikken siden sidste service (typisk de seneste ~10 dage)",
"prediction_cycles": "Lært af cyklusser",
"phase_require_override": "Tilsidesæt „Kræv ved udførelse“ for denne fase",
"history_add_past": "Tilføj tidligere udførelse",
"require_tag_scan": "Kan kun udføres ved tag-scanning",
"require_tag_scan_help": "Tilstedeværelsesbevis: „Udført“ er blokeret overalt, indtil NFC-tagget eller QR-koden på selve tingen er scannet. Automatiseringer kan give 'via_tag_scan' til complete-tjenesten.",
"require_tag_scan_hint": "Denne opgave udføres kun ved at scanne dens NFC-tag eller QR-kode på selve tingen — at gemme her vil blive afvist."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "Leer = Mitternacht (Default). HA-Zeitzone.",
"at_time": "um",
"notes_optional": "Notizen (optional)",
"notes_markdown_hint": "Markdown wird unterstützt — **fett**, Listen, [Links](…)",
"cost_optional": "Kosten (optional)",
"duration_minutes": "Dauer in Minuten (optional)",
"completed_at_optional": "Erledigt am (optional, leer = jetzt)",
@@ -453,6 +454,9 @@
"settings_panel_title": "Panel-Titel",
"settings_notifications": "Benachrichtigungen",
"settings_notify_service": "Benachrichtigungsdienst",
"settings_shopping_list": "Einkaufsliste (Kauf-Aufgaben)",
"settings_shopping_list_help": "Kauf-Erinnerungen für knappe Teile erscheinen in dieser To-do-Liste; Abhaken füllt den Bestand auf.",
"shopping_list_none": "Aus — keine Einkaufsliste",
"settings_install_assist_sentences": "Assist-Sätze installieren",
"settings_install_assist_sentences_hint": "Kopiert die Sprachbefehle in deine Konfiguration, damit der klassische Assist-Agent sie erkennt. Eine selbst bearbeitete Datei wird nie überschrieben.",
"test_notification": "Test-Benachrichtigung",
@@ -898,5 +902,13 @@
"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)"
"chart_history_fallback": "Keine Langzeitstatistik für diese Entität — angezeigt wird der Recorder-Zustandsverlauf (üblicherweise die letzten ~10 Tage)",
"chart_history_alarm": "Trigger-Sicht aus dem Recorder-Zustandsverlauf: 1 = Alarmzustand über die Haltezeit gehalten, 0 = in Ordnung (typisch die letzten ~10 Tage)",
"chart_history_count": "Wechselzähler aus dem Recorder-Zustandsverlauf seit dem letzten Service (typisch die letzten ~10 Tage)",
"prediction_cycles": "Gelernt aus Zyklen",
"phase_require_override": "„Bei Erledigung verlangen“ für diese Phase überschreiben",
"history_add_past": "Vergangene Erledigung nachtragen",
"require_tag_scan": "Nur per Tag-Scan abschließbar",
"require_tag_scan_help": "Anwesenheitsnachweis: „Erledigt“ ist überall blockiert, bis das NFC-Tag oder der QR-Code am Gerät gescannt wurde. Automationen können 'via_tag_scan' an den complete-Dienst übergeben.",
"require_tag_scan_hint": "Diese Aufgabe kann nur per Scan des NFC-Tags oder QR-Codes am Gerät erledigt werden — Speichern hier wird abgelehnt."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "Empty = midnight (default). HA timezone.",
"at_time": "at",
"notes_optional": "Notes (optional)",
"notes_markdown_hint": "Markdown is supported — **bold**, lists, [links](…)",
"cost_optional": "Cost (optional)",
"duration_minutes": "Duration in minutes (optional)",
"completed_at_optional": "Completed at (optional, empty = now)",
@@ -453,6 +454,9 @@
"settings_panel_title": "Sidebar panel title",
"settings_notifications": "Notifications",
"settings_notify_service": "Notification service",
"settings_shopping_list": "Shopping list (buy tasks)",
"settings_shopping_list_help": "Low-part buy reminders appear in this to-do list; checking one off restocks the part.",
"shopping_list_none": "Off — no shopping list",
"settings_install_assist_sentences": "Install Assist sentences",
"settings_install_assist_sentences_hint": "Copies the voice sentences into your configuration so the classic Assist agent recognises them. A file you edited yourself is never overwritten.",
"test_notification": "Test notification",
@@ -898,5 +902,13 @@
"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)"
"chart_history_fallback": "No long-term statistics for this entity — showing recorder state history (typically the last ~10 days)",
"chart_history_alarm": "Trigger view from recorder state history: 1 = alert state held for the hold time, 0 = fine (typically the last ~10 days)",
"chart_history_count": "Change count rebuilt from recorder state history since the last service (typically the last ~10 days)",
"prediction_cycles": "Learned from cycles",
"phase_require_override": "Override “Require on completion” for this phase",
"history_add_past": "Add past completion",
"require_tag_scan": "Only complete by scanning the tag",
"require_tag_scan_help": "Proof of presence: Done is blocked on every surface until the NFC tag or QR code on the thing itself is scanned. Automations can pass 'via_tag_scan' to the complete service.",
"require_tag_scan_hint": "This task completes only by scanning its NFC tag or QR code on the thing itself — saving here will be refused."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Vacío = medianoche (predeterminado). Zona horaria HA.",
"at_time": "a las",
"notes_optional": "Notas (opcional)",
"notes_markdown_hint": "Se admite Markdown — **negrita**, listas, [enlaces](…)",
"cost_optional": "Coste (opcional)",
"duration_minutes": "Duración en minutos (opcional)",
"completed_at_optional": "Completado el (opcional, vacío = ahora)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Título del panel lateral",
"settings_notifications": "Notificaciones",
"settings_notify_service": "Servicio de notificación",
"settings_shopping_list": "Lista de la compra (tareas de compra)",
"settings_shopping_list_help": "Los recordatorios de compra de piezas escasas aparecen en esta lista; marcar uno repone la pieza.",
"shopping_list_none": "Desactivado — sin lista de la compra",
"settings_install_assist_sentences": "Instalar frases de Assist",
"settings_install_assist_sentences_hint": "Copia las frases de voz en tu configuración para que el agente clásico de Assist las reconozca. Un archivo que hayas editado nunca se sobrescribe.",
"test_notification": "Notificación de prueba",
@@ -898,5 +902,13 @@
"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)"
"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)",
"chart_history_alarm": "Vista del disparador desde el historial de estados: 1 = estado de alerta mantenido durante el tiempo de retención, 0 = correcto (normalmente los últimos ~10 días)",
"chart_history_count": "Recuento de cambios reconstruido desde el historial de estados desde el último servicio (normalmente los últimos ~10 días)",
"prediction_cycles": "Aprendido de ciclos",
"phase_require_override": "Sobrescribir «Requerir al completar» para esta fase",
"history_add_past": "Añadir una finalización pasada",
"require_tag_scan": "Completar solo escaneando la etiqueta",
"require_tag_scan_help": "Prueba de presencia: «Hecho» queda bloqueado en todas partes hasta escanear la etiqueta NFC o el código QR del propio objeto. Las automatizaciones pueden pasar 'via_tag_scan' al servicio complete.",
"require_tag_scan_hint": "Esta tarea solo se completa escaneando su etiqueta NFC o código QR en el propio objeto; guardar aquí será rechazado."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "Tyhjä = keskiyö (oletus). HA:n aikavyöhyke.",
"at_time": "klo",
"notes_optional": "Muistiinpanot (valinnainen)",
"notes_markdown_hint": "Markdown on tuettu — **lihavointi**, listat, [linkit](…)",
"cost_optional": "Kustannus (valinnainen)",
"duration_minutes": "Kesto minuutteina (valinnainen)",
"completed_at_optional": "Suoritettu (valinnainen, tyhjä = nyt)",
@@ -453,6 +454,9 @@
"settings_panel_title": "Sivupalkin paneelin otsikko",
"settings_notifications": "Ilmoitukset",
"settings_notify_service": "Ilmoituspalvelu",
"settings_shopping_list": "Ostoslista (ostotehtävät)",
"settings_shopping_list_help": "Vähissä olevien osien ostomuistutukset näkyvät tässä luettelossa; kuittaus täydentää varaston.",
"shopping_list_none": "Pois — ei ostoslistaa",
"settings_install_assist_sentences": "Asenna Assist-lauseet",
"settings_install_assist_sentences_hint": "Kopioi äänikomennot asetuksiisi, jotta perinteinen Assist-agentti tunnistaa ne. Itse muokkaamaasi tiedostoa ei koskaan korvata.",
"test_notification": "Testi-ilmoitus",
@@ -898,5 +902,13 @@
"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ää)"
"chart_history_fallback": "Tälle entiteetille ei ole pitkän aikavälin tilastoja — näytetään recorderin tilahistoria (yleensä viimeiset ~10 päivää)",
"chart_history_alarm": "Laukaisimen näkymä tilahistoriasta: 1 = hälytystila pysyi pitoajan, 0 = kunnossa (yleensä viimeiset ~10 päivää)",
"chart_history_count": "Muutoslaskuri koottu tilahistoriasta viime huollon jälkeen (yleensä viimeiset ~10 päivää)",
"prediction_cycles": "Opittu sykleistä",
"phase_require_override": "Ohita ”Vaadi valmistuessa” tälle vaiheelle",
"history_add_past": "Lisää aiempi suoritus",
"require_tag_scan": "Suoritus vain tunnisteen skannauksella",
"require_tag_scan_help": "Läsnäolotodistus: ”Valmis” on estetty kaikkialla, kunnes laitteen NFC-tunniste tai QR-koodi on skannattu. Automaatiot voivat välittää 'via_tag_scan' complete-palvelulle.",
"require_tag_scan_hint": "Tämä tehtävä valmistuu vain skannaamalla sen NFC-tunniste tai QR-koodi itse laitteesta — tallennus tässä estetään."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Vide = minuit (défaut). Fuseau horaire HA.",
"at_time": "à",
"notes_optional": "Notes (optionnel)",
"notes_markdown_hint": "Markdown est pris en charge — **gras**, listes, [liens](…)",
"cost_optional": "Coût (optionnel)",
"duration_minutes": "Durée en minutes (optionnel)",
"completed_at_optional": "Effectué le (optionnel, vide = maintenant)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Titre du panneau latéral",
"settings_notifications": "Notifications",
"settings_notify_service": "Service de notification",
"settings_shopping_list": "Liste de courses (tâches d'achat)",
"settings_shopping_list_help": "Les rappels d'achat pour pièces en rupture apparaissent dans cette liste ; cocher un élément réapprovisionne la pièce.",
"shopping_list_none": "Désactivé — pas de liste de courses",
"settings_install_assist_sentences": "Installer les phrases Assist",
"settings_install_assist_sentences_hint": "Copie les phrases vocales dans votre configuration afin que l'agent Assist classique les reconnaisse. Un fichier que vous avez modifié n'est jamais écrasé.",
"test_notification": "Notification de test",
@@ -898,5 +902,13 @@
"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)"
"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)",
"chart_history_alarm": "Vue déclencheur issue de l'historique d'états : 1 = état d'alerte maintenu pendant le délai, 0 = normal (généralement les ~10 derniers jours)",
"chart_history_count": "Nombre de changements reconstitué à partir de l'historique d'états depuis le dernier entretien (généralement les ~10 derniers jours)",
"prediction_cycles": "Appris sur cycles",
"phase_require_override": "Remplacer « Exiger à l’achèvement » pour cette phase",
"history_add_past": "Ajouter une réalisation passée",
"require_tag_scan": "Terminer uniquement en scannant le tag",
"require_tag_scan_help": "Preuve de présence : « Terminé » est bloqué partout tant que le tag NFC ou le QR code sur l'objet n'a pas été scanné. Les automatisations peuvent passer 'via_tag_scan' au service complete.",
"require_tag_scan_hint": "Cette tâche ne se termine qu'en scannant son tag NFC ou son QR code sur l'objet — l'enregistrement ici sera refusé."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "खाली = मध्यरात्रि (डिफ़ॉल्ट)। HA समय क्षेत्र।",
"at_time": "बजे",
"notes_optional": "टिप्पणियाँ (वैकल्पिक)",
"notes_markdown_hint": "Markdown समर्थित है — **बोल्ड**, सूचियाँ, [लिंक](…)",
"cost_optional": "लागत (वैकल्पिक)",
"duration_minutes": "मिनटों में अवधि (वैकल्पिक)",
"completed_at_optional": "पूर्ण होने का समय (वैकल्पिक, खाली = अभी)",
@@ -453,6 +454,9 @@
"settings_panel_title": "साइडबार पैनल शीर्षक",
"settings_notifications": "सूचनाएँ",
"settings_notify_service": "सूचना सेवा",
"settings_shopping_list": "शॉपिंग सूची (खरीद कार्य)",
"settings_shopping_list_help": "कम स्टॉक वाले पुर्जों के खरीद अनुस्मारक इस सूची में दिखते हैं; टिक करने पर पुर्जा री-स्टॉक होता है।",
"shopping_list_none": "बंद — कोई शॉपिंग सूची नहीं",
"settings_install_assist_sentences": "Assist वाक्य इंस्टॉल करें",
"settings_install_assist_sentences_hint": "वॉइस वाक्यों को आपके कॉन्फ़िगरेशन में कॉपी करता है ताकि क्लासिक Assist एजेंट उन्हें पहचान सके। आपके द्वारा संपादित फ़ाइल कभी अधिलेखित नहीं होती।",
"test_notification": "परीक्षण सूचना",
@@ -898,5 +902,13 @@
"phase_sequence_add_step": "कदम जोड़ें",
"phase_current": "वर्तमान चरण",
"phase_set": "वर्तमान बनाएँ",
"chart_history_fallback": "इस एंटिटी के लिए दीर्घकालिक आँकड़े नहीं हैं — recorder की स्थिति का इतिहास दिखाया जा रहा है (आमतौर पर पिछले ~10 दिन)"
"chart_history_fallback": "इस एंटिटी के लिए दीर्घकालिक आँकड़े नहीं हैं — recorder की स्थिति का इतिहास दिखाया जा रहा है (आमतौर पर पिछले ~10 दिन)",
"chart_history_alarm": "स्थिति इतिहास से ट्रिगर दृश्य: 1 = चेतावनी स्थिति होल्ड समय तक बनी रही, 0 = सामान्य (आमतौर पर पिछले ~10 दिन)",
"chart_history_count": "पिछली सेवा के बाद से स्थिति इतिहास से पुनर्निर्मित परिवर्तन गणना (आमतौर पर पिछले ~10 दिन)",
"prediction_cycles": "चक्रों से सीखा",
"phase_require_override": "इस चरण के लिए “पूर्ण करते समय आवश्यक” को ओवरराइड करें",
"history_add_past": "पिछली पूर्णता जोड़ें",
"require_tag_scan": "केवल टैग स्कैन से पूर्ण करें",
"require_tag_scan_help": "उपस्थिति प्रमाण: जब तक वस्तु पर लगा NFC टैग या QR कोड स्कैन न हो, हर जगह “पूर्ण” अवरुद्ध रहता है। ऑटोमेशन complete सेवा को 'via_tag_scan' दे सकते हैं।",
"require_tag_scan_hint": "यह कार्य केवल वस्तु पर लगे NFC टैग या QR कोड को स्कैन करके ही पूर्ण होता है — यहाँ सहेजना अस्वीकार होगा।"
}
@@ -128,6 +128,7 @@
"schedule_time_help": "Üres = éjfél (alapértelmezés). HA időzóna.",
"at_time": "ekkor:",
"notes_optional": "Megjegyzések (opcionális)",
"notes_markdown_hint": "A Markdown támogatott — **félkövér**, listák, [hivatkozások](…)",
"cost_optional": "Költség (opcionális)",
"duration_minutes": "Időtartam percben (opcionális)",
"completed_at_optional": "Elvégezve ekkor (opcionális, üres = most)",
@@ -453,6 +454,9 @@
"settings_panel_title": "Oldalsáv-panel címe",
"settings_notifications": "Értesítések",
"settings_notify_service": "Értesítési szolgáltatás",
"settings_shopping_list": "Bevásárlólista (vásárlási feladatok)",
"settings_shopping_list_help": "A fogyóban lévő alkatrészek vásárlási emlékeztetői ezen a listán jelennek meg; a kipipálás feltölti a készletet.",
"shopping_list_none": "Ki — nincs bevásárlólista",
"settings_install_assist_sentences": "Assist mondatok telepítése",
"settings_install_assist_sentences_hint": "Átmásolja a hangmondatokat a konfigurációdba, hogy a klasszikus Assist ügynök felismerje őket. Az általad szerkesztett fájlt soha nem írja felül.",
"test_notification": "Tesztértesítés",
@@ -898,5 +902,13 @@
"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)"
"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)",
"chart_history_alarm": "Trigger nézet az állapotelőzményekből: 1 = riasztási állapot a tartási idő alatt fennállt, 0 = rendben (általában az utolsó ~10 nap)",
"chart_history_count": "Változásszámláló az állapotelőzményekből az utolsó szerviz óta (általában az utolsó ~10 nap)",
"prediction_cycles": "Ciklusokból tanulva",
"phase_require_override": "A „Megkövetelés elvégzéskor” felülbírálása ennél a fázisnál",
"history_add_past": "Korábbi elvégzés rögzítése",
"require_tag_scan": "Csak címke beolvasásával fejezhető be",
"require_tag_scan_help": "Jelenlét-igazolás: a „Kész” mindenhol blokkolva van, amíg be nem olvasod az eszközön lévő NFC-címkét vagy QR-kódot. Az automatizálások 'via_tag_scan' értéket adhatnak át a complete szolgáltatásnak.",
"require_tag_scan_hint": "Ez a feladat csak az eszközön lévő NFC-címke vagy QR-kód beolvasásával fejezhető be — az itteni mentés el lesz utasítva."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Vuoto = mezzanotte (default). Fuso orario HA.",
"at_time": "alle",
"notes_optional": "Note (opzionale)",
"notes_markdown_hint": "Markdown è supportato — **grassetto**, elenchi, [link](…)",
"cost_optional": "Costo (opzionale)",
"duration_minutes": "Durata in minuti (opzionale)",
"completed_at_optional": "Completato il (opzionale, vuoto = adesso)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Titolo pannello laterale",
"settings_notifications": "Notifiche",
"settings_notify_service": "Servizio di notifica",
"settings_shopping_list": "Lista della spesa (attività di acquisto)",
"settings_shopping_list_help": "I promemoria di acquisto per ricambi scarsi appaiono in questo elenco; spuntarne uno rifornisce il ricambio.",
"shopping_list_none": "Disattivato — nessuna lista della spesa",
"settings_install_assist_sentences": "Installa le frasi di Assist",
"settings_install_assist_sentences_hint": "Copia le frasi vocali nella tua configurazione affinché l'agente Assist classico le riconosca. Un file modificato da te non viene mai sovrascritto.",
"test_notification": "Notifica di test",
@@ -898,5 +902,13 @@
"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)"
"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)",
"chart_history_alarm": "Vista del trigger dallo storico stati: 1 = stato di allarme mantenuto per il tempo di attesa, 0 = ok (in genere gli ultimi ~10 giorni)",
"chart_history_count": "Conteggio dei cambi ricostruito dallo storico stati dall'ultimo intervento (in genere gli ultimi ~10 giorni)",
"prediction_cycles": "Appreso da cicli",
"phase_require_override": "Sostituisci «Richiedi al completamento» per questa fase",
"history_add_past": "Aggiungi un completamento passato",
"require_tag_scan": "Completa solo scansionando il tag",
"require_tag_scan_help": "Prova di presenza: «Fatto» è bloccato ovunque finché non viene scansionato il tag NFC o il codice QR sull'oggetto. Le automazioni possono passare 'via_tag_scan' al servizio complete.",
"require_tag_scan_hint": "Questa attività si completa solo scansionando il suo tag NFC o codice QR sull'oggetto — il salvataggio qui verrà rifiutato."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "空欄=深夜0時(既定)。HAのタイムゾーン。",
"at_time": "時刻",
"notes_optional": "メモ(任意)",
"notes_markdown_hint": "Markdownに対応 — **太字**、リスト、[リンク](…)",
"cost_optional": "費用(任意)",
"duration_minutes": "所要時間(分、任意)",
"completed_at_optional": "完了日時(任意・空欄 = 現在)",
@@ -453,6 +454,9 @@
"settings_panel_title": "サイドバーパネルのタイトル",
"settings_notifications": "通知",
"settings_notify_service": "通知サービス",
"settings_shopping_list": "買い物リスト(購入タスク)",
"settings_shopping_list_help": "在庫が少ない部品の購入リマインダーがこのリストに表示され、チェックすると部品が補充されます。",
"shopping_list_none": "オフ — 買い物リストなし",
"settings_install_assist_sentences": "Assist の文を導入する",
"settings_install_assist_sentences_hint": "音声フレーズを設定に複製し、従来の Assist エージェントが認識できるようにします。自分で編集したファイルが上書きされることはありません。",
"test_notification": "テスト通知",
@@ -898,5 +902,13 @@
"phase_sequence_add_step": "ステップを追加",
"phase_current": "現在のフェーズ",
"phase_set": "現在に設定",
"chart_history_fallback": "このエンティティには長期統計がありません — レコーダーの状態履歴を表示しています(通常は直近約10日分)"
"chart_history_fallback": "このエンティティには長期統計がありません — レコーダーの状態履歴を表示しています(通常は直近約10日分)",
"chart_history_alarm": "状態履歴からのトリガー表示:1 = 保持時間の間アラート状態が続いた、0 = 正常(通常は直近約10日)",
"chart_history_count": "前回のサービス以降の状態履歴から再構築した変化回数(通常は直近約10日)",
"prediction_cycles": "学習したサイクル数",
"phase_require_override": "このフェーズで「完了時に必須」を上書き",
"history_add_past": "過去の完了を追加",
"require_tag_scan": "タグのスキャンでのみ完了可能",
"require_tag_scan_help": "在席証明:対象物のNFCタグまたはQRコードをスキャンするまで、どの画面でも「完了」はブロックされます。オートメーションはcompleteサービスに 'via_tag_scan' を渡せます。",
"require_tag_scan_hint": "このタスクは対象物のNFCタグまたはQRコードをスキャンしないと完了できません。ここでの保存は拒否されます。"
}
@@ -128,6 +128,7 @@
"schedule_time_help": "비워두면 자정(기본값)입니다. HA 시간대 기준.",
"at_time": "시각",
"notes_optional": "메모 (선택)",
"notes_markdown_hint": "Markdown 지원 — **굵게**, 목록, [링크](…)",
"cost_optional": "비용 (선택)",
"duration_minutes": "소요 시간(분, 선택)",
"completed_at_optional": "완료 시각 (선택, 비우면 지금)",
@@ -453,6 +454,9 @@
"settings_panel_title": "사이드바 패널 제목",
"settings_notifications": "알림",
"settings_notify_service": "알림 서비스",
"settings_shopping_list": "쇼핑 목록 (구매 작업)",
"settings_shopping_list_help": "부족한 부품의 구매 알림이 이 목록에 표시되며, 체크하면 부품이 재입고됩니다.",
"shopping_list_none": "끄기 — 쇼핑 목록 없음",
"settings_install_assist_sentences": "Assist 문장 설치",
"settings_install_assist_sentences_hint": "음성 문장을 설정에 복사하여 기존 Assist 에이전트가 인식하도록 합니다. 직접 수정한 파일은 절대 덮어쓰지 않습니다.",
"test_notification": "테스트 알림",
@@ -898,5 +902,13 @@
"phase_sequence_add_step": "스텝 추가",
"phase_current": "현재 단계",
"phase_set": "현재로 설정",
"chart_history_fallback": "이 엔티티에는 장기 통계가 없습니다 — 레코더 상태 기록을 표시합니다(보통 최근 약 10일)"
"chart_history_fallback": "이 엔티티에는 장기 통계가 없습니다 — 레코더 상태 기록을 표시합니다(보통 최근 약 10일)",
"chart_history_alarm": "상태 기록 기반 트리거 보기: 1 = 유지 시간 동안 경보 상태 유지, 0 = 정상 (보통 최근 약 10일)",
"chart_history_count": "마지막 서비스 이후 상태 기록으로 재구성한 변경 횟수 (보통 최근 약 10일)",
"prediction_cycles": "학습한 주기 수",
"phase_require_override": "이 단계에서 ‘완료 시 필수’ 재정의",
"history_add_past": "지난 완료 추가",
"require_tag_scan": "태그 스캔으로만 완료 가능",
"require_tag_scan_help": "현장 확인: 기기의 NFC 태그나 QR 코드를 스캔할 때까지 모든 화면에서 '완료'가 차단됩니다. 자동화는 complete 서비스에 'via_tag_scan'을 전달할 수 있습니다.",
"require_tag_scan_hint": "이 작업은 기기의 NFC 태그나 QR 코드를 스캔해야만 완료됩니다. 여기서 저장하면 거부됩니다."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "Tom = midnatt (standard). HA-tidssone.",
"at_time": "kl.",
"notes_optional": "Notater (valgfritt)",
"notes_markdown_hint": "Markdown støttes — **fet**, lister, [lenker](…)",
"cost_optional": "Kostnad (valgfritt)",
"duration_minutes": "Varighet i minutter (valgfritt)",
"completed_at_optional": "Utført den (valgfritt, tomt = nå)",
@@ -453,6 +454,9 @@
"settings_panel_title": "Tittel på sidepanel",
"settings_notifications": "Varsler",
"settings_notify_service": "Varslingstjeneste",
"settings_shopping_list": "Handleliste (kjøpsoppgaver)",
"settings_shopping_list_help": "Kjøpspåminnelser for deler med lav beholdning vises i denne listen; avkryssing fyller på beholdningen.",
"shopping_list_none": "Av — ingen handleliste",
"settings_install_assist_sentences": "Installer Assist-setninger",
"settings_install_assist_sentences_hint": "Kopierer talesetningene til konfigurasjonen din slik at den klassiske Assist-agenten gjenkjenner dem. En fil du selv har endret, blir aldri overskrevet.",
"test_notification": "Testvarsel",
@@ -898,5 +902,13 @@
"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)"
"chart_history_fallback": "Ingen langtidsstatistikk for denne enheten — recorderens tilstandshistorikk vises (vanligvis de siste ~10 dagene)",
"chart_history_alarm": "Utløservisning fra tilstandshistorikken: 1 = alarmtilstand holdt gjennom holdetiden, 0 = ok (vanligvis de siste ~10 dagene)",
"chart_history_count": "Antall endringer gjenoppbygd fra tilstandshistorikken siden siste service (vanligvis de siste ~10 dagene)",
"prediction_cycles": "Lært av sykluser",
"phase_require_override": "Overstyr «Krev ved fullføring» for denne fasen",
"history_add_past": "Legg til tidligere fullføring",
"require_tag_scan": "Kan bare fullføres ved tag-skanning",
"require_tag_scan_help": "Tilstedeværelsesbevis: «Fullført» er blokkert overalt til NFC-taggen eller QR-koden på selve tingen er skannet. Automatiseringer kan sende 'via_tag_scan' til complete-tjenesten.",
"require_tag_scan_hint": "Denne oppgaven fullføres bare ved å skanne NFC-taggen eller QR-koden på selve tingen — lagring her vil bli avvist."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Leeg = middernacht (standaard). HA-tijdzone.",
"at_time": "om",
"notes_optional": "Notities (optioneel)",
"notes_markdown_hint": "Markdown wordt ondersteund — **vet**, lijsten, [links](…)",
"cost_optional": "Kosten (optioneel)",
"duration_minutes": "Duur in minuten (optioneel)",
"completed_at_optional": "Voltooid op (optioneel, leeg = nu)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Titel zijbalkpaneel",
"settings_notifications": "Meldingen",
"settings_notify_service": "Meldingsservice",
"settings_shopping_list": "Boodschappenlijst (kooptaken)",
"settings_shopping_list_help": "Koopherinneringen voor schaarse onderdelen verschijnen in deze lijst; afvinken vult de voorraad aan.",
"shopping_list_none": "Uit — geen boodschappenlijst",
"settings_install_assist_sentences": "Assist-zinnen installeren",
"settings_install_assist_sentences_hint": "Kopieert de spraakzinnen naar je configuratie zodat de klassieke Assist-agent ze herkent. Een bestand dat je zelf hebt bewerkt wordt nooit overschreven.",
"test_notification": "Testmelding",
@@ -898,5 +902,13 @@
"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)"
"chart_history_fallback": "Geen langetermijnstatistieken voor deze entiteit — de recorder-statusgeschiedenis wordt getoond (meestal de laatste ~10 dagen)",
"chart_history_alarm": "Triggerweergave uit de statusgeschiedenis: 1 = alarmstatus gedurende de wachttijd aangehouden, 0 = in orde (meestal de laatste ~10 dagen)",
"chart_history_count": "Aantal wisselingen opnieuw opgebouwd uit de statusgeschiedenis sinds de laatste service (meestal de laatste ~10 dagen)",
"prediction_cycles": "Geleerd uit cycli",
"phase_require_override": "„Vereisen bij afronden” voor deze fase overschrijven",
"history_add_past": "Eerdere afronding toevoegen",
"require_tag_scan": "Alleen af te ronden via tag-scan",
"require_tag_scan_help": "Aanwezigheidsbewijs: „Klaar” is overal geblokkeerd totdat de NFC-tag of QR-code op het apparaat zelf is gescand. Automatiseringen kunnen 'via_tag_scan' aan de complete-service meegeven.",
"require_tag_scan_hint": "Deze taak wordt alleen afgerond door de NFC-tag of QR-code op het apparaat te scannen — opslaan hier wordt geweigerd."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Puste = północ (domyślnie). Strefa czasowa HA.",
"at_time": "o",
"notes_optional": "Notatki (opcjonalne)",
"notes_markdown_hint": "Obsługiwany jest Markdown — **pogrubienie**, listy, [linki](…)",
"cost_optional": "Koszt (opcjonalne)",
"duration_minutes": "Czas trwania w minutach (opcjonalne)",
"completed_at_optional": "Wykonano dnia (opcjonalne, puste = teraz)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Tytuł panelu bocznego",
"settings_notifications": "Powiadomienia",
"settings_notify_service": "Usługa powiadomień",
"settings_shopping_list": "Lista zakupów (zadania zakupowe)",
"settings_shopping_list_help": "Przypomnienia o zakupie brakujących części pojawiają się na tej liście; odhaczenie uzupełnia zapas.",
"shopping_list_none": "Wyłączone — brak listy zakupów",
"settings_install_assist_sentences": "Zainstaluj zdania Assist",
"settings_install_assist_sentences_hint": "Kopiuje zdania głosowe do Twojej konfiguracji, aby klasyczny agent Assist je rozpoznawał. Plik zmieniony przez Ciebie nigdy nie zostanie nadpisany.",
"test_notification": "Powiadomienie testowe",
@@ -898,5 +902,13 @@
"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)"
"chart_history_fallback": "Brak statystyk długoterminowych dla tej encji — wyświetlana jest historia stanów recordera (zwykle ostatnie ~10 dni)",
"chart_history_alarm": "Widok wyzwalacza z historii stanów: 1 = stan alarmu utrzymany przez czas podtrzymania, 0 = w porządku (zwykle ostatnie ~10 dni)",
"chart_history_count": "Licznik zmian odtworzony z historii stanów od ostatniego serwisu (zwykle ostatnie ~10 dni)",
"prediction_cycles": "Nauczone z cykli",
"phase_require_override": "Nadpisz „Wymagaj przy ukończeniu” dla tej fazy",
"history_add_past": "Dodaj wcześniejsze ukończenie",
"require_tag_scan": "Ukończenie tylko przez skan tagu",
"require_tag_scan_help": "Dowód obecności: „Gotowe” jest wszędzie zablokowane, dopóki nie zeskanujesz tagu NFC lub kodu QR na urządzeniu. Automatyzacje mogą przekazać 'via_tag_scan' do usługi complete.",
"require_tag_scan_hint": "To zadanie można ukończyć tylko skanując jego tag NFC lub kod QR na urządzeniu — zapis tutaj zostanie odrzucony."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "Vazio = meia-noite (padrão). Fuso horário do HA.",
"at_time": "às",
"notes_optional": "Observações (opcional)",
"notes_markdown_hint": "Markdown é suportado — **negrito**, listas, [links](…)",
"cost_optional": "Custo (opcional)",
"duration_minutes": "Duração em minutos (opcional)",
"completed_at_optional": "Concluído em (opcional, vazio = agora)",
@@ -453,6 +454,9 @@
"settings_panel_title": "Título do painel na barra lateral",
"settings_notifications": "Notificações",
"settings_notify_service": "Serviço de notificação",
"settings_shopping_list": "Lista de compras (tarefas de compra)",
"settings_shopping_list_help": "Os lembretes de compra de peças em falta aparecem nesta lista; marcar um repõe a peça.",
"shopping_list_none": "Desligado — sem lista de compras",
"settings_install_assist_sentences": "Instalar frases do Assist",
"settings_install_assist_sentences_hint": "Copia as frases de voz para a sua configuração para que o agente Assist clássico as reconheça. Um arquivo que você editou nunca é sobrescrito.",
"test_notification": "Notificação de teste",
@@ -898,5 +902,13 @@
"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)"
"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)",
"chart_history_alarm": "Visão do gatilho a partir do histórico de estados: 1 = estado de alerta mantido pelo tempo de espera, 0 = normal (normalmente os últimos ~10 dias)",
"chart_history_count": "Contagem de mudanças reconstruída a partir do histórico de estados desde o último serviço (normalmente os últimos ~10 dias)",
"prediction_cycles": "Aprendido de ciclos",
"phase_require_override": "Substituir “Exigir na conclusão” para esta fase",
"history_add_past": "Adicionar conclusão passada",
"require_tag_scan": "Concluir apenas escaneando a etiqueta",
"require_tag_scan_help": "Prova de presença: “Concluído” fica bloqueado em toda parte até escanear a etiqueta NFC ou o código QR no próprio objeto. Automações podem passar 'via_tag_scan' ao serviço complete.",
"require_tag_scan_hint": "Esta tarefa só é concluída escaneando sua etiqueta NFC ou código QR no próprio objeto — salvar aqui será recusado."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Vazio = meia-noite (padrão). Fuso horário HA.",
"at_time": "às",
"notes_optional": "Notas (opcional)",
"notes_markdown_hint": "Markdown é suportado — **negrito**, listas, [ligações](…)",
"cost_optional": "Custo (opcional)",
"duration_minutes": "Duração em minutos (opcional)",
"completed_at_optional": "Concluído em (opcional, vazio = agora)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Título do painel lateral",
"settings_notifications": "Notificações",
"settings_notify_service": "Serviço de notificação",
"settings_shopping_list": "Lista de compras (tarefas de compra)",
"settings_shopping_list_help": "Os lembretes de compra de peças em falta aparecem nesta lista; marcar um repõe a peça.",
"shopping_list_none": "Desligado — sem lista de compras",
"settings_install_assist_sentences": "Instalar frases do Assist",
"settings_install_assist_sentences_hint": "Copia as frases de voz para a sua configuração para que o agente Assist clássico as reconheça. Um ficheiro que editou nunca é substituído.",
"test_notification": "Notificação de teste",
@@ -898,5 +902,13 @@
"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)"
"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)",
"chart_history_alarm": "Vista do disparador a partir do histórico de estados: 1 = estado de alerta mantido pelo tempo de espera, 0 = normal (normalmente os últimos ~10 dias)",
"chart_history_count": "Contagem de mudanças reconstruída a partir do histórico de estados desde o último serviço (normalmente os últimos ~10 dias)",
"prediction_cycles": "Aprendido de ciclos",
"phase_require_override": "Substituir «Exigir na conclusão» para esta fase",
"history_add_past": "Adicionar conclusão passada",
"require_tag_scan": "Concluir apenas lendo a etiqueta",
"require_tag_scan_help": "Prova de presença: «Concluído» fica bloqueado em todo o lado até ler a etiqueta NFC ou o código QR no próprio objeto. As automações podem passar 'via_tag_scan' ao serviço complete.",
"require_tag_scan_hint": "Esta tarefa só é concluída lendo a sua etiqueta NFC ou código QR no próprio objeto — guardar aqui será recusado."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Пусто = полночь (по умолчанию). Часовой пояс HA.",
"at_time": "в",
"notes_optional": "Примечания (опционально)",
"notes_markdown_hint": "Поддерживается Markdown — **жирный**, списки, [ссылки](…)",
"cost_optional": "Стоимость (опционально)",
"duration_minutes": "Длительность в минутах (опционально)",
"completed_at_optional": "Выполнено (необязательно, пусто = сейчас)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Заголовок панели",
"settings_notifications": "Уведомления",
"settings_notify_service": "Сервис уведомлений",
"settings_shopping_list": "Список покупок (задачи покупки)",
"settings_shopping_list_help": "Напоминания о покупке заканчивающихся деталей появляются в этом списке; отметка пополняет запас.",
"shopping_list_none": "Выкл. — без списка покупок",
"settings_install_assist_sentences": "Установить фразы Assist",
"settings_install_assist_sentences_hint": "Копирует голосовые фразы в вашу конфигурацию, чтобы классический агент Assist их распознавал. Файл, который вы изменили, никогда не перезаписывается.",
"test_notification": "Тестовое уведомление",
@@ -898,5 +902,13 @@
"phase_sequence_add_step": "Добавить шаг",
"phase_current": "Текущая фаза",
"phase_set": "Сделать текущей",
"chart_history_fallback": "Для этой сущности нет долгосрочной статистики — показана история состояний recorder (обычно последние ~10 дней)"
"chart_history_fallback": "Для этой сущности нет долгосрочной статистики — показана история состояний recorder (обычно последние ~10 дней)",
"chart_history_alarm": "Вид триггера из истории состояний: 1 = состояние тревоги удерживалось в течение времени удержания, 0 = норма (обычно последние ~10 дней)",
"chart_history_count": "Счётчик изменений восстановлен из истории состояний с момента последнего обслуживания (обычно последние ~10 дней)",
"prediction_cycles": "Изучено по циклам",
"phase_require_override": "Переопределить «Требовать при выполнении» для этой фазы",
"history_add_past": "Добавить прошлое выполнение",
"require_tag_scan": "Завершение только по скану метки",
"require_tag_scan_help": "Подтверждение присутствия: «Готово» заблокировано везде, пока не отсканирована NFC-метка или QR-код на самом устройстве. Автоматизации могут передавать 'via_tag_scan' в службу complete.",
"require_tag_scan_hint": "Эта задача завершается только сканированием её NFC-метки или QR-кода на самом устройстве — сохранение здесь будет отклонено."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Tomt = midnatt (standard). HA-tidszon.",
"at_time": "kl.",
"notes_optional": "Anteckningar (valfritt)",
"notes_markdown_hint": "Markdown stöds — **fetstil**, listor, [länkar](…)",
"cost_optional": "Kostnad (valfritt)",
"duration_minutes": "Varaktighet i minuter (valfritt)",
"completed_at_optional": "Utförd den (valfritt, tomt = nu)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Sidopanelens titel",
"settings_notifications": "Notifikationer",
"settings_notify_service": "Notifikationstjänst",
"settings_shopping_list": "Inköpslista (köpuppgifter)",
"settings_shopping_list_help": "Köppåminnelser för delar som tar slut visas i denna lista; att bocka av en fyller på lagret.",
"shopping_list_none": "Av — ingen inköpslista",
"settings_install_assist_sentences": "Installera Assist-meningar",
"settings_install_assist_sentences_hint": "Kopierar rösmeningarna till din konfiguration så att den klassiska Assist-agenten känner igen dem. En fil du själv har ändrat skrivs aldrig över.",
"test_notification": "Testnotifikation",
@@ -898,5 +902,13 @@
"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)"
"chart_history_fallback": "Ingen långtidsstatistik för denna entitet — recorderns tillståndshistorik visas (vanligtvis de senaste ~10 dagarna)",
"chart_history_alarm": "Utlösarvy från tillståndshistoriken: 1 = larmläge hållet under hålltiden, 0 = ok (vanligtvis de senaste ~10 dagarna)",
"chart_history_count": "Antal ändringar återskapat från tillståndshistoriken sedan senaste service (vanligtvis de senaste ~10 dagarna)",
"prediction_cycles": "Lärt av cykler",
"phase_require_override": "Åsidosätt ”Kräv vid slutförande” för denna fas",
"history_add_past": "Lägg till tidigare slutförande",
"require_tag_scan": "Kan endast slutföras via tagg-skanning",
"require_tag_scan_help": "Närvarobevis: ”Klar” är blockerad överallt tills NFC-taggen eller QR-koden på själva saken har skannats. Automationer kan skicka 'via_tag_scan' till complete-tjänsten.",
"require_tag_scan_hint": "Den här uppgiften slutförs endast genom att skanna dess NFC-tagg eller QR-kod på själva saken — att spara här kommer att nekas."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "Boş = gece yarısı (varsayılan). HA saat dilimi.",
"at_time": "saat",
"notes_optional": "Notlar (isteğe bağlı)",
"notes_markdown_hint": "Markdown desteklenir — **kalın**, listeler, [bağlantılar](…)",
"cost_optional": "Maliyet (isteğe bağlı)",
"duration_minutes": "Dakika cinsinden süre (isteğe bağlı)",
"completed_at_optional": "Tamamlanma zamanı (isteğe bağlı, boş = şimdi)",
@@ -453,6 +454,9 @@
"settings_panel_title": "Kenar çubuğu paneli başlığı",
"settings_notifications": "Bildirimler",
"settings_notify_service": "Bildirim servisi",
"settings_shopping_list": "Alışveriş listesi (satın alma görevleri)",
"settings_shopping_list_help": "Azalan parçalar için satın alma hatırlatıcıları bu listede görünür; işaretlemek parçayı yeniden stoklar.",
"shopping_list_none": "Kapalı — alışveriş listesi yok",
"settings_install_assist_sentences": "Assist cümlelerini yükle",
"settings_install_assist_sentences_hint": "Sesli komut cümlelerini yapılandırmanıza kopyalar, böylece klasik Assist aracısı bunları tanır. Kendi düzenlediğiniz bir dosyanın üzerine asla yazılmaz.",
"test_notification": "Test bildirimi",
@@ -898,5 +902,13 @@
"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)"
"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)",
"chart_history_alarm": "Durum geçmişinden tetikleyici görünümü: 1 = uyarı durumu bekletme süresi boyunca sürdü, 0 = normal (genellikle son ~10 gün)",
"chart_history_count": "Değişim sayısı son servisten bu yana durum geçmişinden yeniden oluşturuldu (genellikle son ~10 gün)",
"prediction_cycles": "Döngülerden öğrenildi",
"phase_require_override": "Bu aşama için “Tamamlarken zorunlu tut” ayarını geçersiz kıl",
"history_add_past": "Geçmiş tamamlama ekle",
"require_tag_scan": "Yalnızca etiket taranarak tamamlanır",
"require_tag_scan_help": "Yerinde olma kanıtı: Cihazın üzerindeki NFC etiketi veya QR kodu taranana kadar 'Tamamlandı' her yerde engellenir. Otomasyonlar complete servisine 'via_tag_scan' iletebilir.",
"require_tag_scan_hint": "Bu görev yalnızca cihazın üzerindeki NFC etiketi veya QR kodu taranarak tamamlanır — buradan kaydetme reddedilecektir."
}
@@ -126,6 +126,7 @@
"schedule_time_help": "Порожньо = опівночі (за замовчуванням). Часовий пояс HA.",
"at_time": "о",
"notes_optional": "Примітки (необов'язково)",
"notes_markdown_hint": "Підтримується Markdown — **жирний**, списки, [посилання](…)",
"cost_optional": "Вартість (необов'язково)",
"duration_minutes": "Тривалість у хвилинах (необов'язково)",
"completed_at_optional": "Виконано (необов'язково, порожньо = зараз)",
@@ -451,6 +452,9 @@
"settings_panel_title": "Заголовок панелі",
"settings_notifications": "Сповіщення",
"settings_notify_service": "Служба сповіщень",
"settings_shopping_list": "Список покупок (завдання покупки)",
"settings_shopping_list_help": "Нагадування про купівлю деталей, що закінчуються, з'являються в цьому списку; відмітка поповнює запас.",
"shopping_list_none": "Вимк. — без списку покупок",
"settings_install_assist_sentences": "Встановити фрази Assist",
"settings_install_assist_sentences_hint": "Копіює голосові фрази до вашої конфігурації, щоб класичний агент Assist їх розпізнавав. Файл, який ви редагували, ніколи не перезаписується.",
"test_notification": "Тестове сповіщення",
@@ -898,5 +902,13 @@
"phase_sequence_add_step": "Додати крок",
"phase_current": "Поточна фаза",
"phase_set": "Зробити поточною",
"chart_history_fallback": "Для цієї сутності немає довгострокової статистики — показано історію станів recorder (зазвичай останні ~10 днів)"
"chart_history_fallback": "Для цієї сутності немає довгострокової статистики — показано історію станів recorder (зазвичай останні ~10 днів)",
"chart_history_alarm": "Вигляд тригера з історії станів: 1 = стан тривоги утримувався протягом часу утримання, 0 = норма (зазвичай останні ~10 днів)",
"chart_history_count": "Лічильник змін відновлено з історії станів від останнього обслуговування (зазвичай останні ~10 днів)",
"prediction_cycles": "Вивчено з циклів",
"phase_require_override": "Перевизначити «Вимагати при виконанні» для цієї фази",
"history_add_past": "Додати минуле виконання",
"require_tag_scan": "Завершення лише скануванням мітки",
"require_tag_scan_help": "Підтвердження присутності: «Готово» заблоковано всюди, доки не відскановано NFC-мітку або QR-код на самому пристрої. Автоматизації можуть передати 'via_tag_scan' службі complete.",
"require_tag_scan_hint": "Це завдання завершується лише скануванням його NFC-мітки або QR-коду на самому пристрої — збереження тут буде відхилено."
}
@@ -128,6 +128,7 @@
"schedule_time_help": "留空 = 午夜 (默认)。基于 HA 时区。",
"at_time": "于",
"notes_optional": "备注 (可选)",
"notes_markdown_hint": "支持 Markdown — **加粗**、列表、[链接](…)",
"cost_optional": "成本 (可选)",
"duration_minutes": "耗时 (分钟, 可选)",
"completed_at_optional": "完成时间(可选,留空 = 现在)",
@@ -453,6 +454,9 @@
"settings_panel_title": "侧边栏面板标题",
"settings_notifications": "通知",
"settings_notify_service": "通知服务",
"settings_shopping_list": "购物清单(购买任务)",
"settings_shopping_list_help": "库存不足部件的购买提醒会显示在此清单中;勾选即可补充库存。",
"shopping_list_none": "关闭 — 无购物清单",
"settings_install_assist_sentences": "安装 Assist 语句",
"settings_install_assist_sentences_hint": "将语音语句复制到你的配置中,让传统 Assist 代理能够识别它们。你自己修改过的文件永远不会被覆盖。",
"test_notification": "测试通知",
@@ -898,5 +902,13 @@
"phase_sequence_add_step": "添加步骤",
"phase_current": "当前阶段",
"phase_set": "设为当前",
"chart_history_fallback": "该实体没有长期统计数据——显示 recorder 状态历史(通常为最近约 10 天)"
"chart_history_fallback": "该实体没有长期统计数据——显示 recorder 状态历史(通常为最近约 10 天)",
"chart_history_alarm": "来自状态历史的触发视图:1 = 警报状态保持了保持时间,0 = 正常(通常为最近约 10 天)",
"chart_history_count": "根据上次维护以来的状态历史重建的变化次数(通常为最近约 10 天)",
"prediction_cycles": "已学习的周期数",
"phase_require_override": "为此阶段覆盖“完成时要求”",
"history_add_past": "补录过去的完成记录",
"require_tag_scan": "仅可通过扫描标签完成",
"require_tag_scan_help": "在场证明:在扫描设备上的 NFC 标签或二维码之前,所有界面的“完成”均被阻止。自动化可向 complete 服务传递 'via_tag_scan'。",
"require_tag_scan_hint": "此任务只能通过扫描设备上的 NFC 标签或二维码来完成——在此保存将被拒绝。"
}
@@ -18,10 +18,11 @@ import type {
SavedViewFilters,
} from "./types";
import { UserService } from "./user-service";
import { partsForCompletion } from "./helpers/shared-parts";
import { effectivePhase, phaseLabel } from "./helpers/phases";
import { phaseLabel } from "./helpers/phases";
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
import "./maintenance-card-editor";
import "./components/complete-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
// The Battery Fleet card ships in this globally-loaded bundle so it is
// available on every dashboard without another extra_module_url entry.
import "./components/battery-fleet-card";
@@ -514,32 +515,26 @@ export class MaintenanceSupporterCard extends LitElement {
@click=${(e: Event) => {
// 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 = phase ? phase.checklist : (task.checklist || []);
dlg.adaptiveEnabled = !!task.adaptive_config?.enabled;
dlg.taskType = task.type || "";
dlg.readingUnit = (task as any).reading_unit || "";
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).
// #111: the list also carries the shared pools
// this task draws on, each named after its
// owner — resolving against the object's own
// parts alone left a foreign link invisible.
const isBuy = !!(task as any).part_ref;
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();
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog")!;
// The SAME derivation the panel uses (phase
// override, parts incl. shared pools, tag-scan
// gate, restock default, checklist ticks) — the
// card used to forward a subset.
fillAndOpenCompleteDialog(
dlg,
buildCompleteDialogArgs({
entryId: entry_id,
taskId: task.id,
taskName: task.name,
task,
objects: this._objects,
lang: L,
checklist: task.checklist || [],
adaptiveEnabled: !!task.adaptive_config?.enabled,
currencySymbol: this._stats?.budget?.currency_symbol || "",
}),
L,
);
}}
>
<ha-icon icon="mdi:check"></ha-icon>
@@ -15,9 +15,11 @@ import { buildObjectReportHtml, type ReportLabels } from "./helpers/report";
import { warrantyStatus } from "./helpers/warranty";
import { OBJECT_COLUMNS, DEFAULT_OBJECTS_TABLE_COLUMNS, sanitizeColumns } from "./helpers/object-columns";
import { downloadTextFile } from "./helpers/download";
import { renderNotesMarkdown } from "./helpers/notes-markdown";
import { buildTaskWorksheetHtml, type WorksheetExcerpt, type WorksheetLabels } from "./helpers/worksheet";
import { describePartLink, partsForCompletion } from "./helpers/shared-parts";
import { effectivePhase, phaseLabel } from "./helpers/phases";
import { describePartLink } from "./helpers/shared-parts";
import { effectivePhase } from "./helpers/phases";
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
import { describeWsError } from "./ws-errors";
import { panelStyles } from "./panel-styles";
import type {
@@ -694,6 +696,20 @@ export class MaintenanceSupporterPanel extends LitElement {
if ((msg as SubscriptionEvent<MaintenanceObjectResponse>).objects) {
writeObjectsCache(next, this._stats ?? null);
}
// The KPI row (_stats) and the budget card come from their own
// endpoints — a pushed change (completion from the card, a timer
// wave flipping a task overdue) left them frozen at the last
// _loadData until the next user action.
void this._refreshKpis();
// The open task's full history is a separate fetch too; the
// truncated list payload never wins the "longer list" comparison
// in _historyCtx, so a pushed completion stayed invisible there.
if (
this._view === "task" && this._selectedEntryId && this._selectedTaskId
&& (!!ev.objects || (ev.delta || []).some((d) => d.entry_id === this._selectedEntryId))
) {
void this._fetchFullHistory(this._selectedEntryId, this._selectedTaskId);
}
}
},
// deltas: only entries whose rebuilt response actually changed —
@@ -713,6 +729,34 @@ export class MaintenanceSupporterPanel extends LitElement {
}
}
/** Refetch the KPI statistics + budget status after a pushed object change.
* Coalesced: a burst of deltas (import, timer wave) runs at most one
* in-flight fetch plus one trailing catch-up instead of one per event. */
private _kpiRefreshInFlight = false;
private _kpiRefreshPending = false;
private async _refreshKpis(): Promise<void> {
if (this._kpiRefreshInFlight) {
this._kpiRefreshPending = true;
return;
}
this._kpiRefreshInFlight = true;
try {
do {
this._kpiRefreshPending = false;
const [statsResult, budgetResult] = await Promise.all([
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/budget_status" }).catch(() => null),
]);
if (!this.isConnected) return;
if (statsResult) this._stats = statsResult as StatisticsResponse;
if (budgetResult) this._budget = budgetResult as BudgetStatus;
} while (this._kpiRefreshPending);
} finally {
this._kpiRefreshInFlight = false;
}
}
// --- Data accessors ---
private get _taskRows(): TaskRow[] {
@@ -1734,17 +1778,27 @@ export class MaintenanceSupporterPanel extends LitElement {
this._showToast(t("quick_complete_success", this._lang));
} catch (e: unknown) {
const code = (e as { code?: string })?.code || "";
if (code === "no_defaults") {
// No defaults set — open the dialog so the user can fill them in.
// Both refusals mean "the dialog has to collect something": no
// quick-complete defaults configured, or the task demands completion
// details a silent complete cannot supply. The dialog opens as the
// scan's fallback — via_tag_scan keeps the proof of presence.
if (code === "no_defaults" || code === "completion_details_required") {
this._openCompleteDialog(
entryId, taskId, task.name,
this._features.checklists ? task.checklist : undefined,
this._features.adaptive && !!task.adaptive_config?.enabled,
{ viaTagScan: true },
);
} else {
this._showToast(t("action_error", this._lang));
// Relay the server's reason (too_early carries the earliest date)
// instead of a generic "action failed".
this._showToast(describeWsError(e, this._lang, t("action_error", this._lang)));
}
return;
}
// Silent success: the list must reflect the completion right away — the
// subscription delta may lag or be coalesced.
try { await this._loadData(); } catch { /* subscription will sync */ }
}
// v2.21: printable one-pager for a task — details, checklist tick boxes,
@@ -1874,59 +1928,41 @@ export class MaintenanceSupporterPanel extends LitElement {
});
}
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
private _openCompleteDialog(
entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean,
opts?: { viaTagScan?: boolean },
): void {
void this._ui<MaintenanceCompleteDialog>("maintenance-complete-dialog")
.then((dlg) => dlg && this._fillAndOpenCompleteDialog(dlg, entryId, taskId, taskName, checklist, adaptiveEnabled));
.then((dlg) => dlg && this._fillAndOpenCompleteDialog(dlg, entryId, taskId, taskName, checklist, adaptiveEnabled, opts));
}
private _fillAndOpenCompleteDialog(dlg: MaintenanceCompleteDialog, entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
dlg.entryId = entryId;
dlg.taskId = taskId;
dlg.taskName = taskName;
dlg.lang = this._lang;
dlg.checklist = checklist || [];
dlg.adaptiveEnabled = !!adaptiveEnabled;
// v2.20 (#83): reading tasks get a value field — resolve type + unit here
// so none of the many call sites need to thread them through.
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 = 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 || [];
const refPart = tk?.part_ref ? objParts.find((pt) => pt.id === tk.part_ref!.part_id) : undefined;
dlg.restockDefault = tk?.part_ref ? (refPart?.restock_quantity ?? 1) : null;
// #104 follow-up: parts carry unit costs — the dialog offers their sum
// as a one-click cost suggestion (buy task: restock qty × unit cost).
dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null;
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).
const links = phase ? phase.consumesParts : (tk?.consumes_parts || []);
dlg.consumesInfo = links.map((link) =>
describePartLink(link, entryId, this._objects, this._lang),
/** Every dialog field is derived in helpers/complete-dialog-args — the
* ONE derivation the panel, the Lovelace card and the quick-actions
* dialog share (phase override #139, reading type+unit #83, restock
* default + unit cost #104, shared pools #111, checklist ticks #73,
* tag-scan gate). Resolved HERE so none of the call sites need to
* thread anything but the name through. */
private _fillAndOpenCompleteDialog(
dlg: MaintenanceCompleteDialog, entryId: string, taskId: string, taskName: string,
checklist?: string[], adaptiveEnabled?: boolean, opts?: { viaTagScan?: boolean },
): void {
fillAndOpenCompleteDialog(
dlg,
buildCompleteDialogArgs({
entryId,
taskId,
taskName,
task: this._getTask(entryId, taskId),
objects: this._objects,
lang: this._lang,
checklist,
checklistsEnabled: this._features.checklists,
adaptiveEnabled,
currencySymbol: this._currencySymbol,
viaTagScan: opts?.viaTagScan,
}),
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({ consumes_parts: links }, entryId, this._objects, this._lang);
dlg.consumesParts = tk?.part_ref ? [] : links;
dlg.open();
}
private _openQrForObject(entryId: string, objectName: string): void {
@@ -3287,7 +3323,7 @@ export class MaintenanceSupporterPanel extends LitElement {
${o.notes
? html`<div class="object-notes">
<div class="object-notes-label">${t("object_notes_label", L)}</div>
<div class="object-notes-body">${o.notes}</div>
<div class="object-notes-body">${renderNotesMarkdown(o.notes)}</div>
</div>`
: nothing}
@@ -445,6 +445,12 @@ export const panelStyles = css`
word-break: break-word;
line-height: 1.45;
}
/* Markdown notes: <ha-markdown> parses the source itself — the container's
pre-wrap must not leak into its rendered paragraphs. */
.object-notes-body ha-markdown,
.task-meta-notes ha-markdown {
white-space: normal;
}
.empty { color: var(--secondary-text-color); font-style: italic; }
.analysis-empty-state { text-align: center; padding: 24px 16px; }
.analysis-empty-state .empty { font-size: 15px; margin-bottom: 8px; }
@@ -1053,6 +1059,19 @@ export const panelStyles = css`
border-color: var(--primary-color);
}
/* #142: "add a past completion" entry point in the history tab — the
backdate field lives in the complete dialog, but people LOOK for it
here. */
.history-add-past {
display: flex;
justify-content: flex-end;
margin-bottom: 4px;
}
.history-add-past-btn ha-icon {
--mdc-icon-size: 18px;
margin-right: 4px;
}
/* #139: cycle-phase strip in the task overview */
.phases-card {
background: var(--card-background-color, #fff);
@@ -44,6 +44,7 @@ export function renderPredictionSection(task: MaintenanceTask, lang: string, fea
<span class="prediction-value prediction-days${task.days_until_threshold === 0 ? " exceeded" : task.sensor_prediction_urgency ? " urgent" : ""}">${task.days_until_threshold === 0 ? t("threshold_exceeded", lang) : "~" + Math.round(task.days_until_threshold!) + " " + t("days", lang)}</span>
${task.threshold_prediction_date ? html`<span class="prediction-date">${formatDate(task.threshold_prediction_date, lang)}</span>` : nothing}
${task.threshold_prediction_confidence ? html`<span class="confidence-dot ${task.threshold_prediction_confidence}"></span>` : nothing}
${(task.prediction_cycles ?? 0) > 0 ? html`<span class="prediction-cycles">${t("prediction_cycles", lang)}: ${task.prediction_cycles}</span>` : nothing}
</div>
` : nothing}
${hasEnv && features.environmental ? html`
@@ -39,6 +39,100 @@ export interface SparklineContext {
* [Q1 − 1.5·IQR, Q3 + 1.5·IQR]. Robust to a few wild glitch readings (a
* pressure sensor spiking to 100 while it normally sits at 1.5–3). No-ops on
* short series (< 4 points) where quartiles aren't meaningful. */
/** A binary HA state as the 0/1 level the history fallback plots, or null
* for states the fallback cannot express (then no trigger view is built). */
export function binaryLevel(state: string | null | undefined): 0 | 1 | null {
const s = (state ?? "").trim().toLowerCase();
if (s === "on" || s === "open" || s === "true") return 1;
if (s === "off" || s === "closed" || s === "false") return 0;
return null;
}
export interface StateChangeView {
points: ChartPoint[];
/** "alarm": single-shot latch (target 1 + a to-state) — 1 while the alert
* state is held, 0 otherwise. "count": change counter since the last
* service, stepped, honouring the hold filter. */
mode: "alarm" | "count";
}
/** #141 (round 2): the recorder fallback plots the RAW entity state, but a
* state_change trigger is about transitions. A water-valve switch that is
* "on" all week with a few second-long "off" blips drew a flat 1 with the
* "Target changes: 1" line on the same axis — while the header said 0 / 1,
* because those blips never held for the #136 hold time. Re-express the
* step series in the trigger's own terms:
*
* - latch (target_changes == 1 with a to-state): 1 = alert state HELD for
* the hold time, 0 = fine. Reads as "is the trigger active" — for a
* problem sensor (to-state on) this equals the raw line, for a switch
* watched for "off" it is the inverse the user expects.
* - counter (everything else): the cumulative number of matching, held
* transitions since the last service, anchored so the line ends at the
* trigger's live count (transitions before the recorder window are
* unknown but already in that count).
*
* Returns null when the from/to states cannot be expressed as 0/1. */
export function stateChangeView(
points: ChartPoint[],
tc: { trigger_from_state?: string | null; trigger_to_state?: string | null; trigger_target_changes?: number; trigger_for_minutes?: number },
opts: { since: number | null; current: number | null; now?: number },
): StateChangeView | null {
if (points.length < 2) return null;
const now = opts.now ?? Date.now();
const holdMs = Math.max(0, tc.trigger_for_minutes ?? 0) * 60000;
const fromLevel = tc.trigger_from_state ? binaryLevel(tc.trigger_from_state) : null;
const toLevel = tc.trigger_to_state ? binaryLevel(tc.trigger_to_state) : null;
if ((tc.trigger_from_state && fromLevel === null) || (tc.trigger_to_state && toLevel === null)) return null;
// Collapse the doubled step points into held segments (start, level).
const sorted = [...points].sort((a, b) => a.ts - b.ts);
const segments: { start: number; level: number }[] = [];
for (const p of sorted) {
const last = segments[segments.length - 1];
if (!last || last.level !== p.val) segments.push({ start: p.ts, level: p.val });
}
const held = (i: number) => {
const end = i + 1 < segments.length ? segments[i + 1].start : now;
return end - segments[i].start >= holdMs;
};
const step = (out: ChartPoint[], ts: number, val: number) => {
const last = out[out.length - 1];
if (last && last.val !== val) out.push({ ts, val: last.val });
out.push({ ts, val });
};
const latch = (tc.trigger_target_changes ?? 1) === 1 && toLevel !== null;
if (latch) {
const out: ChartPoint[] = [];
segments.forEach((seg, i) => step(out, seg.start, seg.level === toLevel && held(i) ? 1 : 0));
const lastVal = out[out.length - 1]?.val ?? 0;
out.push({ ts: now, val: lastVal });
return { points: out, mode: "alarm" };
}
const since = opts.since ?? segments[0].start;
let counted = 0;
const stepsAt: number[] = [];
for (let i = 1; i < segments.length; i++) {
const prev = segments[i - 1], seg = segments[i];
if (fromLevel !== null && prev.level !== fromLevel) continue;
if (toLevel !== null && seg.level !== toLevel) continue;
if (!held(i) || seg.start < since) continue;
counted += 1;
stepsAt.push(seg.start);
}
const base = Math.max(0, (opts.current ?? counted) - counted);
const out: ChartPoint[] = [{ ts: Math.max(since, segments[0].start), val: base }];
let val = base;
for (const ts of stepsAt) {
val += 1;
step(out, ts, val);
}
out.push({ ts: now, val });
return { points: out, mode: "count" };
}
export function filterOutliers(points: ChartPoint[]): ChartPoint[] {
if (points.length < 4) return points;
const vals = points.map((p) => p.val).sort((a, b) => a - b);
@@ -276,6 +370,13 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
const entityId = tc.entity_id || "";
let points = rawStatsPoints(task, ctx);
// #141 round 2: a state_change trigger on recorder history draws the
// trigger's view (alert held / change count), not the raw entity state.
let stateView: StateChangeView | null = null;
if (triggerType === "state_change" && entityId && ctx.historyFallbackIds?.has(entityId)) {
stateView = stateChangeView(points, tc, { since: lastServiceTs(task), current: task.trigger_current_value ?? null });
if (stateView) points = stateView.points;
}
// Runtime accumulates hours with no stored intermediate snapshots: synthesize
// the current cycle (0 at the last service → live value now) so the chart
// resets each completion and always has a drawable 2-point line.
@@ -326,8 +427,9 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
}
targetValue = tc.trigger_target_value;
forceZero = true;
} else if (triggerType === "state_change" && tc.trigger_target_changes) {
// Change counts / runtime hours already accumulate from zero.
} else if (triggerType === "state_change" && tc.trigger_target_changes && stateView?.mode !== "alarm") {
// Change counts / runtime hours already accumulate from zero. A latch
// ("alarm" view) has no meaningful target line: its axis is alert/ok.
targetValue = tc.trigger_target_changes;
forceZero = true;
} else if (triggerType === "runtime" && tc.trigger_runtime_hours) {
@@ -341,15 +443,25 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
// exists — a slow 0.25 %/day decline classifies stable yet still crosses
// the threshold in a foreseeable number of days.
let projection: ChartPoint[] | null = null;
// A projection pointing AWAY from the configured threshold is noise (the
// ET-4800 finding: a refill-skewed rising rate on a below-threshold ink
// sensor projected the ink level going UP) — draw it only when it heads
// toward the bound, or when no bound is configured at all.
const rate = task.degradation_rate;
const pointsAway =
rate != null &&
((tc.trigger_below != null && tc.trigger_above == null && rate > 0) ||
(tc.trigger_above != null && tc.trigger_below == null && rate < 0));
if (
targetValue == null &&
task.degradation_rate != null &&
rate != null &&
!pointsAway &&
(task.degradation_trend !== "stable" || task.days_until_threshold != null) &&
task.degradation_trend !== "insufficient_data" &&
points.length >= 2
) {
const lp = points[points.length - 1];
projection = [lp, { ts: lp.ts + 30 * 86400000, val: lp.val + task.degradation_rate * 30 }];
projection = [lp, { ts: lp.ts + 30 * 86400000, val: lp.val + rate * 30 }];
}
const events: ChartEvent[] = task.history
@@ -380,7 +492,7 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
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)}
${t(stateView?.mode === "alarm" ? "chart_history_alarm" : stateView?.mode === "count" ? "chart_history_count" : "chart_history_fallback", ctx.lang)}
</div>`;
}
if (statsFetchedEmpty) {
@@ -11,6 +11,7 @@
import { html, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask, ManualDocRef } from "../types";
import { renderTriggerSection, type SparklineContext } from "./sparkline";
@@ -222,8 +223,8 @@ function renderPhasesCard(task: MaintenanceTask, ctx: TaskDetailContext) {
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); }}>
title=${i === cursor ? t("phase_current", L) : t("phase_set", L)}
@click=${() => { if (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>
@@ -284,7 +285,7 @@ function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
${task.notes ? html`
<div class="task-meta-row">
<ha-icon icon="mdi:note-text-outline"></ha-icon>
<span class="task-meta-notes">${task.notes}</span>
<span class="task-meta-notes">${renderNotesMarkdown(task.notes)}</span>
</div>
` : nothing}
${safeTaskUrl ? html`
@@ -494,6 +495,12 @@ export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext)
function renderHistoryTab(task: MaintenanceTask, ctx: TaskDetailContext) {
return html`
<div class="tab-content history-tab">
<div class="history-add-past">
<ha-button appearance="plain" class="history-add-past-btn" @click=${() => ctx.openComplete(task)}>
<ha-icon icon="mdi:calendar-plus"></ha-icon>
${t("history_add_past", ctx.lang)}
</ha-button>
</div>
${renderHistoryFilters(task, ctx.history)}
${renderHistoryList(task, ctx.history)}
</div>
@@ -99,10 +99,15 @@ export function t(key: string, lang?: string): string {
* in English until then rather than as raw keys). Was copy-pasted across the
* panel and both cards (drift audit 2026-08). */
export function syncLocaleFromHass(
host: { hass?: { language?: string; locale?: unknown }; requestUpdate: () => void },
host: {
hass?: { language?: string; locale?: unknown; config?: { country?: string | null } };
requestUpdate: () => void;
},
changedProps: Map<string, unknown>,
): void {
if (changedProps.has("hass")) setDateTimePrefs(host.hass?.locale as Parameters<typeof setDateTimePrefs>[0]);
if (changedProps.has("hass")) {
setDateTimePrefs(host.hass?.locale as Parameters<typeof setDateTimePrefs>[0], host.hass?.config?.country);
}
const lang = host.hass?.language;
if (lang && !isLocaleLoaded(lang)) {
ensureLocale(lang).then(() => host.requestUpdate());
@@ -196,15 +201,41 @@ function langToLocale(lang?: string): string {
interface DateTimePrefs {
date?: string;
time?: string;
country?: string;
}
const _w = window as unknown as { __msDateTimePrefs?: DateTimePrefs };
const DT_PREFS: DateTimePrefs = (_w.__msDateTimePrefs ??= {});
/** Feed HA's per-user date/time format into formatDate/formatDateTime. */
export function setDateTimePrefs(locale?: { date_format?: string; time_format?: string }): void {
/** Feed HA's per-user date/time format into formatDate/formatDateTime.
* `country` is the SERVER's configured country (#140): with the profile
* format left on "language" it regionalizes the language default —
* "en" + AU renders DD/MM/YYYY instead of the hard en-US mapping. */
export function setDateTimePrefs(
locale?: { date_format?: string; time_format?: string },
country?: string | null,
): void {
if (!locale) return;
DT_PREFS.date = locale.date_format;
DT_PREFS.time = locale.time_format;
if (country !== undefined) DT_PREFS.country = country || undefined;
}
/** BCP-47 locale for the "language" default: the UI language regionalized
* by the server country when Intl knows the combination (#140). Explicit
* profile formats (DMY/MDY/YMD/system) never reach this path. */
function resolveLocale(lang?: string): string {
const base = langToLocale(lang);
const country = DT_PREFS.country;
if (country && /^[A-Za-z]{2}$/.test(country)) {
const candidate = `${normLang(lang).split("-")[0]}-${country.toUpperCase()}`;
try {
new Intl.DateTimeFormat(candidate);
return candidate;
} catch {
// syntactically invalid tag — keep the language mapping
}
}
return base;
}
function _formatDateObj(d: Date, lang?: string): string {
@@ -217,17 +248,17 @@ function _formatDateObj(d: Date, lang?: string): string {
case "YMD": return `${yyyy}-${mm}-${dd}`;
case "system":
return d.toLocaleDateString(undefined, { day: "2-digit", month: "2-digit", year: "numeric" });
default: // "language" or unset — derive from the UI language (pre-#97 behaviour)
return d.toLocaleDateString(langToLocale(lang), { day: "2-digit", month: "2-digit", year: "numeric" });
default: // "language" or unset — UI language, regionalized by the server country (#140)
return d.toLocaleDateString(resolveLocale(lang), { day: "2-digit", month: "2-digit", year: "numeric" });
}
}
function _formatTimeObj(d: Date, lang?: string): string {
switch (DT_PREFS.time) {
case "12": return d.toLocaleTimeString(langToLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: true });
case "24": return d.toLocaleTimeString(langToLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: false });
case "12": return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: true });
case "24": return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: false });
case "system": return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
default: return d.toLocaleTimeString(langToLocale(lang), { hour: "2-digit", minute: "2-digit" });
default: return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit" });
}
}
@@ -1291,6 +1322,11 @@ export const sharedStyles = css`
opacity: 0.7;
}
.prediction-cycles {
font-size: 11px;
opacity: 0.7;
}
.prediction-entity {
font-size: 10px;
opacity: 0.6;
@@ -248,6 +248,9 @@ export interface MaintenanceTask {
degradation_rate?: number | null;
degradation_trend?: string | null; // "rising" | "falling" | "stable" | "insufficient_data"
degradation_r_squared?: number | null;
/** How many prior service cycles the degradation rate learned from
* (sawtooth sensors); 0 = current window only. */
prediction_cycles?: number;
days_until_threshold?: number | null;
threshold_prediction_date?: string | null;
threshold_prediction_confidence?: string | null; // "low" | "medium" | "high"
@@ -259,6 +262,8 @@ export interface MaintenanceTask {
responsible_user_id?: string | null;
custom_icon?: string | null;
nfc_tag_id?: string | null;
/** Proof of presence: completion only via NFC/QR scan on the thing. */
require_tag_scan?: boolean;
/** Number of documents linked to this task (paperclip badge on the row). */
document_count?: number;
/** v2.20 (#83): display unit for `reading`-type tasks ("kWh", "m³", ...). */
@@ -560,6 +565,9 @@ export interface HomeAssistant {
// "system" | "DMY" | "MDY" | "YMD" resp. "language" | "system" | "12" | "24")
// — issue #97: dates must follow them, not just the UI language.
locale?: { language: string; number_format?: string; date_format?: string; time_format?: string };
/** Server config — country (#140) feeds the "language"-default date
* format ("en" + AU → en-AU → DD/MM/YYYY). */
config?: { country?: string | null };
localize(key: string, ...args: unknown[]): string;
user?: { id: string; name: string; is_admin: boolean; is_owner: boolean };
/** Current access token — used for authenticated `fetch()` to our HTTP