393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -14,15 +14,21 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
import { describeWsError } from "../ws-errors";
import { sharedStyles, t, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
import { runWs } from "../helpers/ws-run";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
import { phaseLabel } from "../helpers/phases";
import { buildHistoryEntryDraft } from "../helpers/history-draft";
import { readingSlotDelta } from "../helpers/reading-slots";
import { taskRef } from "../helpers/reference";
import { renderHistoryEntry } from "../renderers/history";
import { newestFirst, renderHistoryEntry } from "../renderers/history";
import { renderStatusBadge, statusColor } from "../renderers/status";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { confirmAction } from "../helpers/confirm";
import { ToastTimer } from "../helpers/toast";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
@@ -69,7 +75,10 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
completion_actions: false,
};
@state() private _toast = "";
private _featuresLoaded = false;
private readonly _toastTimer = new ToastTimer();
/** Operator-write delegation (helpers/permissions): Edit / Archive /
* Delete follow canWrite() like the panel, not `is_admin` alone. */
@state() private _access: WriteAccess = NO_DELEGATION;
/** Currency symbol for the complete dialog's cost suggestion — read off
* the same settings response as the feature flags. */
private _currencySymbol = "";
@@ -81,6 +90,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
* sync even if the underlying card has stale data. */
public async openFor(entryId: string, taskId: string): Promise<void> {
// Reopened for ANOTHER task while open: never show the previous one
// under the new ids until the fresh load lands.
if (entryId !== this._entryId || taskId !== this._taskId) this._task = null;
this._entryId = entryId;
this._taskId = taskId;
this._error = "";
@@ -97,60 +109,70 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
/** Pull the active feature flags so adaptive sections only render when
* Adaptive / Seasonal / Environmental are actually enabled (matches the
* panel's behaviour). Cached after first load. */
* panel's behaviour), plus the write delegation and currency. Through
* the page-wide settings cache (one fetch per page, invalidated by
* global/update) — the private fetch here bypassed it and never
* re-read after a settings change (DRY audit 2026-09-26). A failed
* fetch serves the all-off fallback and asks again next open. */
private async _loadFeatures(): Promise<void> {
if (this._featuresLoaded) return;
try {
const r = await this.hass.connection.sendMessagePromise<{
features?: Partial<AdvancedFeatures>;
budget?: { currency_symbol?: string; currency_decimals?: number };
}>({ type: "maintenance_supporter/settings" });
if (r?.features) {
this._features = { ...this._features, ...r.features };
}
this._currencySymbol = currencySymbolOf(r?.budget);
syncCurrencyDecimals(r?.budget);
this._featuresLoaded = true;
} catch {
// Settings endpoint unavailable — leave defaults (all false), the
// adaptive panel will then stay hidden.
}
const settings = await fetchSettingsOnce(this.hass);
this._features = { ...this._features, ...settings.features };
this._access = settings.access;
this._currencySymbol = currencySymbolOf(settings.budget ?? undefined);
syncCurrencyDecimals(settings.budget ?? undefined);
}
public close(): void {
this._loadSeq++; // a load still in flight belongs to the closed dialog
this._open = false;
this._task = null;
this._error = "";
this._toastTimer.clear();
this._toast = "";
}
private _showToast(msg: string, ms?: number): void {
this._toast = msg;
this._toastTimer.schedule(() => { this._toast = ""; }, ms);
}
/** Only the newest `_loadTask` may land: the answer for a task the dialog
* was opened for BEFORE (or after it closed) looked up the CURRENT task
* id in the wrong object, found nothing, and left the dialog stuck on
* "Loading" (bug audit 2026-09-26 #2). */
private _loadSeq = 0;
private async _loadTask(): Promise<void> {
if (!this._entryId || !this._taskId) return;
try {
const r = await this.hass.connection.sendMessagePromise<MaintenanceObjectFull>({
type: "maintenance_supporter/object",
entry_id: this._entryId,
});
this._objectName = r.object?.name || "";
const found = (r.tasks || []).find((t) => t.id === this._taskId);
this._task = found ?? null;
this._taskRef = taskRef(r.object, found);
} catch (e) {
this._error = describeWsError(e, this._lang);
const entryId = this._entryId;
const taskId = this._taskId;
if (!entryId || !taskId) return;
const seq = ++this._loadSeq;
let failure = "";
const r = await runWs<MaintenanceObjectFull>(
this,
{ type: "maintenance_supporter/object", entry_id: entryId },
{ onError: (m) => { failure = m; } },
);
if (seq !== this._loadSeq) return;
if (r === undefined) {
this._error = failure;
return;
}
this._objectName = r?.object?.name || "";
const found = (r?.tasks || []).find((t) => t.id === taskId);
this._task = found ?? null;
this._taskRef = taskRef(r?.object, found);
// A task deleted meanwhile: say so instead of "Loading" forever.
if (!found) this._error = t("ws_err_not_found", this._lang);
}
private async _runWs(payload: Record<string, unknown>): Promise<boolean> {
this._busy = true;
/** helpers/ws-run runWs bound to this dialog's busy flag and error line. */
private _runWs<T = unknown>(payload: Record<string, unknown>): Promise<T | undefined> {
this._error = "";
try {
await this.hass.connection.sendMessagePromise(payload);
this._busy = false;
return true;
} catch (e) {
this._error = describeWsError(e, this._lang);
this._busy = false;
return false;
}
return runWs<T>(this, payload, {
busy: (b) => { this._busy = b; },
onError: (m) => { this._error = m; },
});
}
private _notifyChanged(action: string): void {
@@ -195,8 +217,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task,
objects,
lang: this._lang,
checklist: task.checklist || [],
adaptiveEnabled: !!task.adaptive_config?.enabled,
// The feature switches gate the checklist and the adaptive
// feedback exactly like the panel (DRY audit 2026-09-26).
features: this._features,
currencySymbol: this._currencySymbol,
}),
);
@@ -215,7 +238,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task_id: this._taskId,
reason: this._skipReason.trim() || null,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("skip");
this.close();
}
@@ -229,7 +252,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task_id: this._taskId,
date: this._resetDate || undefined,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("reset");
this.close();
}
@@ -258,15 +281,19 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
private async _onDelete(): Promise<void> {
if (!this._entryId || !this._taskId) return;
const confirmText = t("delete_task_confirm", this._lang)
|| `Delete "${this._task?.name}"?`;
if (!window.confirm(confirmText)) return;
const confirmed = await confirmAction(this.hass, {
title: t("delete", this._lang),
message: t("delete_task_confirm", this._lang),
confirmText: t("delete", this._lang),
danger: true,
});
if (!confirmed) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/delete",
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("delete");
this.close();
}
@@ -279,7 +306,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("archive");
this.close();
}
@@ -292,7 +319,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
if (ok !== undefined) {
this._notifyChanged("unarchive");
this.close();
}
@@ -315,41 +342,32 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task_id: this._taskId,
interval: this._task.suggested_interval,
});
if (ok) {
this._toast = t("suggestion_applied", this._lang);
if (ok !== undefined) {
this._showToast(t("suggestion_applied", this._lang));
this._notifyChanged("apply_suggestion");
// Refresh local task so the recommendation card hides
await this._loadTask();
setTimeout(() => { this._toast = ""; }, 2500);
}
}
private async _reanalyzeInterval(): Promise<void> {
if (!this._entryId || !this._taskId) return;
this._busy = true;
this._error = "";
try {
const r = await this.hass.connection.sendMessagePromise<{
recommended_interval: number | null;
confidence: string;
data_points: number;
}>({
type: "maintenance_supporter/task/analyze_interval",
entry_id: this._entryId,
task_id: this._taskId,
});
this._toast = r.recommended_interval
// The analyzer always works in DAYS (helpers/interval_analyzer.py), so
// the unit is pinned here rather than taken from the task's own unit.
? `${t("reanalyze_result", this._lang)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
: t("reanalyze_insufficient_data", this._lang);
await this._loadTask();
setTimeout(() => { this._toast = ""; }, 3500);
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
const r = await this._runWs<{
recommended_interval: number | null;
confidence: string;
data_points: number;
}>({
type: "maintenance_supporter/task/analyze_interval",
entry_id: this._entryId,
task_id: this._taskId,
});
if (r === undefined) return;
this._showToast(r?.recommended_interval
// The analyzer always works in DAYS (helpers/interval_analyzer.py), so
// the unit is pinned here rather than taken from the task's own unit.
? `${t("reanalyze_result", this._lang)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
: t("reanalyze_insufficient_data", this._lang));
await this._loadTask();
}
private _onEditHistoryEntry(entry: HistoryEntry): void {
@@ -378,11 +396,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task.interval_confidence || "medium", L,
)}
<div class="recommendation-actions">
<button class="btn primary"
@click=${this._applySuggestion} ?disabled=${this._busy}>
<ha-icon icon="mdi:check"></ha-icon>
${t("apply_suggestion", L)}
</button>
${canWrite(this.hass?.user, this._access)
? html`<button class="btn primary qa-apply-suggestion"
@click=${this._applySuggestion} ?disabled=${this._busy}>
<ha-icon icon="mdi:check"></ha-icon>
${t("apply_suggestion", L)}
</button>`
: nothing /* task/apply_suggestion is write tier */}
<button class="btn"
@click=${this._reanalyzeInterval} ?disabled=${this._busy}>
<ha-icon icon="mdi:refresh"></ha-icon>
@@ -440,55 +460,51 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
* existing history-edit dialog (which lives in the same dialog-mount). */
private _renderDetails(task: MaintenanceTask) {
const L = this._lang;
// The list payload carries only the most recent entries (payload diet,
// 20) — the stats come from the server's totals over the WHOLE history,
// not from that window: a task done 30 times read "20" here (DRY audit
// 2026-09-26). The list itself stays the recent window, newest first by
// timestamp (a backdated #133 completion is appended last).
const history = (task.history || []) as HistoryEntry[];
const completed = history.filter((h) => h.type === "completed");
const totalCost = completed.reduce(
(s, h) => s + (typeof h.cost === "number" ? h.cost : 0),
0,
);
const avgDuration = (() => {
const durs = completed
.map((h) => (typeof h.duration === "number" ? h.duration : null))
.filter((d): d is number => d != null);
if (!durs.length) return null;
return Math.round(durs.reduce((s, d) => s + d, 0) / durs.length);
})();
const totalEntries = task.history_count ?? history.length;
const recent = newestFirst(history).slice(0, 20);
return html`
<div class="details">
<div class="stats-grid">
<div class="stat">
<span class="stat-label">${t("times_performed", L)}</span>
<span class="stat-value">${completed.length}</span>
<span class="stat-value">${task.times_performed ?? 0}</span>
</div>
<div class="stat">
<span class="stat-label">${t("total_cost", L)}</span>
<span class="stat-value">${formatCost(totalCost, this._currencySymbol, L)}</span>
<span class="stat-value">${formatCost(task.total_cost ?? 0, this._currencySymbol, L)}</span>
</div>
<div class="stat">
<span class="stat-label">${t("avg_duration", L)}</span>
<span class="stat-value">${formatDuration(avgDuration, L)}</span>
<span class="stat-value">${formatDuration(task.average_duration != null ? Math.round(task.average_duration) : null, L)}</span>
</div>
</div>
<div class="history-header">
<strong>${t("history", L)}</strong>
<span class="history-count">${history.length}</span>
<span class="history-count">${totalEntries}</span>
</div>
${history.length === 0
? html`<div class="history-empty">${t("history_empty", L)}</div>`
: html`
<div class="history-list">
${[...history].reverse().slice(0, 20).map((entry) => renderHistoryEntry(entry, {
${recent.map((entry) => renderHistoryEntry(entry, {
lang: L,
hass: this.hass,
currencySymbol: this._currencySymbol,
openEdit: (e) => this._onEditHistoryEntry(e),
// task/history/update is write tier: no pencil otherwise.
openEdit: canWrite(this.hass?.user, this._access) ? (e) => this._onEditHistoryEntry(e) : undefined,
readingUnit: task.reading_unit,
readingSlotDelta: (e, slotId) => readingSlotDelta(history, e, slotId),
taskRef: this._taskRef,
}, { compact: true }))}
${history.length > 20
? html`<div class="history-more">… +${history.length - 20} ${t("older_entries", L)}</div>`
${totalEntries > recent.length
? html`<div class="history-more">… +${totalEntries - recent.length} ${t("older_entries", L)}</div>`
: nothing}
</div>
`}
@@ -500,17 +516,16 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
if (!this._open) return nothing;
const L = this._lang;
const task = this._task;
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
const writer = canWrite(this.hass?.user, this._access);
return html`
<div class="backdrop" @click=${this.close}></div>
<div class="dialog" role="dialog" aria-modal="true">
return renderModalShell(() => this.close(), html`
${task
? html`
<div class="header">
<div class="title">
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
<span class="status-dot" style="background: ${statusColor(task)}"></span>
<span class="task-name">${task.name}</span>
${renderStatusBadge(task, L)}
</div>
<div class="object">
<button class="link-inline" @click=${() => {
@@ -597,30 +612,33 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
${t("reset", L)}
</ha-button>
</div>
${isAdmin
? html`
<div class="actions secondary-row">
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onEdit} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
${t("edit", L)}
</ha-button>
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onQr} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
${t("qr_code", L)}
</ha-button>
<ha-button size="small" appearance="outlined" variant="neutral"
@click=${task.archived ? this._onUnarchive : this._onArchive}
.disabled=${this._busy}>
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${task.archived ? t("unarchive", L) : t("archive", L)}
</ha-button>
<ha-button size="small" appearance="outlined" variant="danger" class="danger" @click=${this._onDelete} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
${t("delete", L)}
</ha-button>
</div>
`
: nothing}
<!-- QR is read tier (the panel offers it to operators too);
Edit / Archive / Delete follow canWrite() — the panel's
operator delegation, not is_admin alone. -->
<div class="actions secondary-row">
${writer
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-edit" @click=${this._onEdit} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
${t("edit", L)}
</ha-button>`
: nothing}
<ha-button size="small" appearance="outlined" variant="neutral" class="qa-qr" @click=${this._onQr} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
${t("qr_code", L)}
</ha-button>
${writer
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-archive"
@click=${task.archived ? this._onUnarchive : this._onArchive}
.disabled=${this._busy}>
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${task.archived ? t("unarchive", L) : t("archive", L)}
</ha-button>
<ha-button size="small" appearance="outlined" variant="danger" class="danger qa-delete" @click=${this._onDelete} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
${t("delete", L)}
</ha-button>`
: nothing}
</div>
<div class="details-toggle">
<button class="link" @click=${() => { this._showDetails = !this._showDetails; }}>
<ha-icon icon="${this._showDetails ? 'mdi:chevron-up' : 'mdi:chevron-down'}"></ha-icon>
@@ -649,30 +667,18 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
</div>
`}
`
: html`<div class="loading">${t("loading", L)}</div>`}
</div>
`;
: this._error
? html`<div class="error">${this._error}</div>`
: html`<div class="loading">${t("loading", L)}</div>`}
`);
}
static styles = [sharedStyles, css`
:host { display: contents; }
.backdrop {
position: fixed; inset: 0; z-index: 100;
background: rgba(0,0,0,0.5);
}
.dialog {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 95vw; max-width: 460px;
max-height: 92vh; overflow: auto;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
color: var(--primary-text-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
padding: 20px;
display: flex; flex-direction: column; gap: 14px;
z-index: 101;
}
protected updated(changed: Map<string, unknown>): void {
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
}
static styles = [sharedStyles, modalShellStyles, css`
:host { display: contents; --ms-modal-max-width: 460px; }
.header { display: flex; flex-direction: column; gap: 6px; }
.title { display: flex; align-items: center; gap: 10px; }
.status-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }