278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
@@ -14,14 +14,15 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, formatNumber, langOf } from "../styles";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
import { describeWsError } from "../ws-errors";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
import { phaseLabel } from "../helpers/phases";
import { historyPhotoIds } from "../helpers/history-photos";
import { entryReadingValues, readingSlotDelta } from "../helpers/reading-slots";
import "./history-photo";
import { buildHistoryEntryDraft } from "../helpers/history-draft";
import { readingSlotDelta } from "../helpers/reading-slots";
import { taskRef } from "../helpers/reference";
import { renderHistoryEntry } from "../renderers/history";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
@@ -40,7 +41,7 @@ import type {
interface MaintenanceObjectFull {
entry_id: string;
object: { id: string; name: string };
object: { id: string; name: string; ref_no?: number | null };
tasks: MaintenanceTask[];
}
@@ -52,6 +53,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
@state() private _taskId: string | null = null;
@state() private _task: MaintenanceTask | null = null;
@state() private _objectName = "";
/** #170: "8.3" once the backend numbered the task — the history rows' "#8.3-n" chips. */
@state() private _taskRef: string | null = null;
@state() private _busy = false;
@state() private _error = "";
@state() private _showSkip = false;
@@ -100,12 +103,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
try {
const r = await this.hass.connection.sendMessagePromise<{
features?: Partial<AdvancedFeatures>;
budget?: { currency_symbol?: string };
budget?: { currency_symbol?: string; currency_decimals?: number };
}>({ type: "maintenance_supporter/settings" });
if (r?.features) {
this._features = { ...this._features, ...r.features };
}
this._currencySymbol = r?.budget?.currency_symbol || "";
this._currencySymbol = currencySymbolOf(r?.budget);
syncCurrencyDecimals(r?.budget);
this._featuresLoaded = true;
} catch {
// Settings endpoint unavailable — leave defaults (all false), the
@@ -129,6 +133,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
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);
}
@@ -311,7 +316,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
interval: this._task.suggested_interval,
});
if (ok) {
this._toast = t("suggestion_applied", this._lang) || "Applied";
this._toast = t("suggestion_applied", this._lang);
this._notifyChanged("apply_suggestion");
// Refresh local task so the recommendation card hides
await this._loadTask();
@@ -336,8 +341,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
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) || "Recomputed"}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
: (t("reanalyze_insufficient_data", this._lang) || "Not enough data");
? `${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) {
@@ -349,26 +354,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
private _onEditHistoryEntry(entry: HistoryEntry): void {
if (!this._entryId || !this._taskId) return;
import("../dialog-mount").then(({ openHistoryEditDialog }) => {
openHistoryEditDialog({
entry_id: this._entryId!,
task_id: this._taskId!,
original_timestamp: entry.timestamp,
type: entry.type,
timestamp: entry.timestamp,
notes: entry.notes ?? null,
cost: entry.cost ?? null,
duration: entry.duration ?? null,
completed_by: entry.completed_by ?? null,
used_parts: entry.used_parts ?? null,
photo_doc_ids: historyPhotoIds(entry),
reading_value: entry.reading_value ?? null,
reading_values: entryReadingValues(entry),
readings: this._task?.readings ?? [],
task_type: this._task?.type ?? null,
reading_unit: this._task?.reading_unit ?? null,
});
});
const draft = buildHistoryEntryDraft(this._entryId, this._taskId, entry, this._task);
import("../dialog-mount").then(({ openHistoryEditDialog }) => openHistoryEditDialog(draft));
}
/** Inline recommendation card (Current vs Suggested with apply/reanalyze).
@@ -426,7 +413,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
if (!hasRecommendation && !hasPrediction && !hasWeibull && !hasSeasonal) {
return html`<div class="adaptive-empty">
${t("adaptive_no_data", L) || "Not enough completion history yet for adaptive analysis."}
${t("adaptive_no_data", L)}
</div>`;
}
return html`
@@ -448,30 +435,6 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
`;
}
/** #161: the entry's readings — per-slot lines with the delta against the
* previous entry carrying the slot, or the single value of a scalar task.
* Parity with the panel timeline for the Lovelace / strategy path. */
private _renderHistoryReadings(entry: HistoryEntry, history: HistoryEntry[], L: string) {
const values = entryReadingValues(entry);
const fmt = (n: number) => formatNumber(n, L, { maximumFractionDigits: 3 });
if (values.length > 0) {
return html`<div class="history-readings">
${values.map((v) => {
const d = readingSlotDelta(history, entry, v.id);
return html`<span class="history-reading"><span class="history-reading-name">${v.name}</span>
<span class="history-reading-value">${fmt(v.value)}${v.unit ? ` ${v.unit}` : ""}${d == null ? "" : ` (${d >= 0 ? "+" : ""}${fmt(d)})`}</span></span>`;
})}
</div>`;
}
if (entry.reading_value != null) {
const unit = this._task?.reading_unit ? ` ${this._task.reading_unit}` : "";
return html`<div class="history-readings"><span class="history-reading">
<span class="history-reading-name">${t("reading_label", L)}</span>
<span class="history-reading-value">${fmt(entry.reading_value)}${unit}</span></span></div>`;
}
return nothing;
}
/** Read-only details panel: stats + history. Shown when the user clicks
* "Show details" in the dialog. Edit-buttons on history entries open the
* existing history-edit dialog (which lives in the same dialog-mount). */
@@ -495,64 +458,37 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<div class="details">
<div class="stats-grid">
<div class="stat">
<span class="stat-label">${t("times_performed", L) || "Performed"}</span>
<span class="stat-label">${t("times_performed", L)}</span>
<span class="stat-value">${completed.length}</span>
</div>
<div class="stat">
<span class="stat-label">${t("total_cost", L) || "Total cost"}</span>
<span class="stat-value">${formatNumber(totalCost, L, 2)}</span>
<span class="stat-label">${t("total_cost", L)}</span>
<span class="stat-value">${formatCost(totalCost, this._currencySymbol, L)}</span>
</div>
<div class="stat">
<span class="stat-label">${t("avg_duration", L) || "Avg duration"}</span>
<span class="stat-value">${avgDuration != null ? `${avgDuration}m` : "—"}</span>
<span class="stat-label">${t("avg_duration", L)}</span>
<span class="stat-value">${formatDuration(avgDuration, L)}</span>
</div>
</div>
<div class="history-header">
<strong>${t("history", L) || "History"}</strong>
<strong>${t("history", L)}</strong>
<span class="history-count">${history.length}</span>
</div>
${history.length === 0
? html`<div class="history-empty">${t("history_empty", L) || "No history yet."}</div>`
? html`<div class="history-empty">${t("history_empty", L)}</div>`
: html`
<div class="history-list">
${[...history].reverse().slice(0, 20).map((entry) => {
const editable = ["completed", "reset", "skipped"].includes(entry.type);
return html`
<div class="history-entry">
<div class="history-line">
<span class="history-type type-${entry.type}">${t(entry.type, L)}</span>
<span class="history-date">${formatDateTime(entry.timestamp, L)}</span>
${editable
? html`<button class="history-edit"
title="${t("history_edit_button", L) || "Edit"}"
@click=${() => this._onEditHistoryEntry(entry)}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>`
: nothing}
</div>
${entry.notes
? html`<div class="history-notes">${entry.notes}</div>`
: nothing}
${this._renderHistoryReadings(entry, history, L)}
${(() => {
const photos = historyPhotoIds(entry);
return photos.length
? html`<div class="history-photos">
${photos.map((docId) => html`<maintenance-history-photo .hass=${this.hass} .docId=${docId}></maintenance-history-photo>`)}
</div>`
: nothing;
})()}
${entry.cost != null || entry.duration != null
? html`<div class="history-meta">
${entry.cost != null ? html`<span>💰 ${formatNumber(entry.cost, L, 2)}</span>` : nothing}
${entry.duration != null ? html`<span>⏱️ ${entry.duration}m</span>` : nothing}
</div>`
: nothing}
</div>
`;
})}
${[...history].reverse().slice(0, 20).map((entry) => renderHistoryEntry(entry, {
lang: L,
hass: this.hass,
currencySymbol: this._currencySymbol,
openEdit: (e) => this._onEditHistoryEntry(e),
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) || "older"}</div>`
? html`<div class="history-more">… +${history.length - 20} ${t("older_entries", L)}</div>`
: nothing}
</div>
`}
@@ -587,13 +523,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
</div>
<div class="quick-info">
${task.next_due
? html`<span><strong>${t("next_due", L) || "Next due"}:</strong> ${formatDate(task.next_due, L)}</span>`
? html`<span><strong>${t("next_due", L)}:</strong> ${formatDate(task.next_due, L)}</span>`
: nothing}
${task.last_performed
? html`<span><strong>${t("last_performed", L) || "Last"}:</strong> ${formatDate(task.last_performed, L)}</span>`
? html`<span><strong>${t("last_performed", L)}:</strong> ${formatDate(task.last_performed, L)}</span>`
: nothing}
${(task.schedule?.kind && !["manual", "one_time"].includes(task.schedule.kind)) || task.interval_days != null
? html`<span><strong>${t("interval", L) || "Interval"}:</strong> ${formatRecurrence(task, L)}</span>`
? html`<span><strong>${t("interval", L)}:</strong> ${formatRecurrence(task, L)}</span>`
: nothing}
${phaseLabel(task)
? html`<span><strong>${t("phase_current", L)}:</strong> ${phaseLabel(task)}</span>`
@@ -608,15 +544,15 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
${this._showSkip
? html`
<div class="inline-form">
<label>${t("skip_reason", L) || "Skip reason (optional)"}</label>
<label>${t("skip_reason", L)}</label>
<input type="text" .value=${this._skipReason}
@input=${(e: Event) => { this._skipReason = (e.target as HTMLInputElement).value; }} />
<div class="inline-actions">
<button class="btn cancel" @click=${() => { this._showSkip = false; }} ?disabled=${this._busy}>
${t("cancel", L) || "Cancel"}
${t("cancel", L)}
</button>
<button class="btn primary" @click=${this._onSkipConfirm} ?disabled=${this._busy}>
${t("skip", L) || "Skip"}
${t("skip", L)}
</button>
</div>
</div>
@@ -624,7 +560,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
: this._showReset
? html`
<div class="inline-form">
<label>${t("reset_to_date", L) || "Reset last_performed to"}</label>
<label>${t("reset_to_date", L)}</label>
<ms-date-field
kind="date"
.hass=${this.hass}
@@ -634,10 +570,10 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
></ms-date-field>
<div class="inline-actions">
<button class="btn cancel" @click=${() => { this._showReset = false; }} ?disabled=${this._busy}>
${t("cancel", L) || "Cancel"}
${t("cancel", L)}
</button>
<button class="btn primary" @click=${this._onResetConfirm} ?disabled=${this._busy}>
${t("reset", L) || "Reset"}
${t("reset", L)}
</button>
</div>
</div>
@@ -646,19 +582,19 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<div class="actions primary-row">
<ha-button appearance="accent" variant="success" @click=${this._onComplete} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:check"></ha-icon>
${t("complete", L) || "Complete"}
${t("complete", L)}
</ha-button>
${task.allow_skip !== false
? html`
<ha-button appearance="outlined" variant="warning" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>
${t("skip", L) || "Skip"}
${t("skip", L)}
</ha-button>
`
: nothing}
<ha-button appearance="outlined" variant="neutral" @click=${() => { this._showReset = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:restart"></ha-icon>
${t("reset", L) || "Reset"}
${t("reset", L)}
</ha-button>
</div>
${isAdmin
@@ -666,21 +602,21 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<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) || "Edit"}
${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) || "QR"}
${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) || "Unarchive") : (t("archive", L) || "Archive")}
${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) || "Delete"}
${t("delete", L)}
</ha-button>
</div>
`
@@ -689,8 +625,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<button class="link" @click=${() => { this._showDetails = !this._showDetails; }}>
<ha-icon icon="${this._showDetails ? 'mdi:chevron-up' : 'mdi:chevron-down'}"></ha-icon>
${this._showDetails
? (t("hide_details", L) || "Hide details")
: (t("show_details", L) || "Show history + stats")}
? t("hide_details", L)
: t("show_details", L)}
</button>
${this._features.adaptive
|| this._features.seasonal
@@ -698,8 +634,8 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
? html`<button class="link" @click=${() => { this._showAdaptive = !this._showAdaptive; }}>
<ha-icon icon="${this._showAdaptive ? 'mdi:chart-line' : 'mdi:chart-line-variant'}"></ha-icon>
${this._showAdaptive
? (t("hide_stats", L) || "Hide stats")
: (t("show_stats", L) || "Show stats + graphs")}
? t("hide_stats", L)
: t("show_stats", L)}
</button>`
: nothing}
</div>
@@ -708,12 +644,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<div class="footer">
<button class="link" @click=${this._onOpenInPanel}>
<ha-icon icon="mdi:open-in-new"></ha-icon>
${t("open_in_panel", L) || "Open in Maintenance panel"}
${t("open_in_panel", L)}
</button>
</div>
`}
`
: html`<div class="loading">${t("loading", L) || "Loading…"}</div>`}
: html`<div class="loading">${t("loading", L)}</div>`}
</div>
`;
}
@@ -843,41 +779,16 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
background: var(--secondary-background-color); padding: 2px 8px; border-radius: 999px;
}
.history-empty { color: var(--secondary-text-color); font-style: italic; font-size: 13px; }
.history-list { display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow: auto; }
.history-entry {
padding: 6px 8px; border-radius: 6px;
/* The rows themselves are the shared renderer's (.history-entry.compact
+ the history-* classes from sharedStyles); only the list chrome is
local. */
.history-list { display: flex; flex-direction: column; max-height: 280px; overflow: auto; }
.history-list .history-entry {
padding: 6px 8px; border-radius: 6px; border-bottom: none; margin-bottom: 6px;
background: var(--secondary-background-color, rgba(255,255,255,0.03));
font-size: 13px;
}
.history-line {
display: flex; align-items: center; gap: 8px;
justify-content: space-between;
}
.history-type {
font-weight: 600; font-size: 11px;
padding: 2px 6px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.type-completed { background: rgba(46,125,50,0.2); color: #66bb6a; }
.type-skipped { background: rgba(158,158,158,0.2); color: var(--secondary-text-color); }
.type-reset { background: rgba(33,150,243,0.2); color: #64b5f6; }
.type-triggered { background: rgba(255,87,34,0.2); color: #ff8a65; }
.history-date { font-size: 11px; color: var(--secondary-text-color); flex: 1; text-align: right; }
.history-edit {
background: transparent; border: none; cursor: pointer;
padding: 4px; border-radius: 4px;
color: var(--secondary-text-color);
}
.history-edit:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); color: var(--primary-color); }
.history-edit ha-icon { --mdc-icon-size: 14px; }
.history-notes { margin-top: 4px; color: var(--primary-text-color); }
.history-meta { display: flex; gap: 12px; margin-top: 4px; color: var(--secondary-text-color); font-size: 11px; }
/* #161: readings + photos on the entry (panel-timeline parity) */
.history-readings { display: flex; flex-wrap: wrap; gap: 2px 16px; margin-top: 4px; font-size: 12px; }
.history-reading { display: inline-flex; gap: 6px; }
.history-reading-name { color: var(--secondary-text-color); }
.history-reading-value { font-variant-numeric: tabular-nums; }
.history-photos { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.history-list .history-date { font-size: 11px; }
.history-list .history-details { font-size: 11px; }
.history-more { padding: 8px; text-align: center; font-size: 12px; color: var(--secondary-text-color); font-style: italic; }
/* Adaptive section — wraps the panel renderers (which assume sharedStyles