187 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-03 17:54:35 +00:00
1 parent 16c9b4d7f4
commit d6413e19b5
187 files changed
+15542 -2765

No files matched your search

@@ -5,7 +5,7 @@ import { isSafeHttpUrl } from "./helpers/url";
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { isStaleBundle } from "./helpers/bundle-version";
import { customElement, property, state } from "lit/decorators.js";
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs, langOf } from "./styles";
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatNumber, formatCost } from "./styles";
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
import { openHtmlInNewTab, openSignedDocument, signApiPath } from "./helpers/document-url";
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
@@ -83,6 +83,7 @@ import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail
// would never register.
import "./components/task-detail-view";
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type StickyState } from "./helpers/sticky-pane";
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
@@ -113,6 +114,16 @@ interface PartsOverviewRow {
type SortMode = "due_date" | "object" | "type" | "task_name" | "area" | "assigned_user" | "group";
type ObjectSortMode = "alphabetical" | "due_soonest" | "task_count";
type GroupByMode = "none" | "area" | "group" | "user" | "object";
type OverviewTab = "today" | "dashboard" | "calendar" | "settings";
// Value lists for everything that arrives as a free string — localStorage,
// a saved view, a deep-link query — and must be validated before it lands in
// state (an unknown mode would be persisted and silently break sorting).
const SORT_MODES: readonly SortMode[] = ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"];
const GROUP_BY_MODES: readonly GroupByMode[] = ["none", "area", "group", "user", "object"];
const OVERVIEW_TABS: readonly OverviewTab[] = ["today", "dashboard", "calendar", "settings"];
/** The dashboard list's status filter options (the <select> in the toolbar). */
const STATUS_FILTERS: readonly string[] = ["overdue", "due_soon", "triggered", "ok"];
// Chart dimension constants for mini sparklines (overview)
@@ -209,7 +220,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _dismissedSuggestions = new Set<string>();
// Dashboard redesign state
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
@state() private _overviewTab: OverviewTab = (() => {
try {
const v = lsGet(LS_KEYS.overviewTab);
return v === "today" || v === "calendar" ? v : "dashboard";
@@ -240,6 +251,18 @@ export class MaintenanceSupporterPanel extends LitElement {
private _virtTotalRows = 0;
private _virtScrollAttached = false;
private _virtRaf = 0;
// Split view (2026-09-02): the docked detail pane is scroll-aware sticky
// (helpers/sticky-pane) — it never scrolls on its own; a pane taller than
// the viewport is pinned by its bottom edge while scrolling down and by
// its top edge while scrolling up.
private _stickyState: StickyState = INITIAL_STICKY;
private _stickySelectPending = false;
private _stickyRaf = 0;
private _stickyAttached = false;
private _stickyObserver: ResizeObserver | null = null;
// Group-by sections collapsed by the user (keyed by section label). Cleared
// when the group-by mode changes — labels mean something else then.
@state() private _collapsedGroups = new Set<string>();
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
// remembered per section across visits.
@state() private _collapsedSections: Set<string> = (() => {
@@ -292,6 +315,7 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
private _locationChangedHandler = () => this._onLocationChanged();
/** Lazy UI chunks (perf wave 2, item 5): the six dialogs + the settings
* view are code-split out of the entry bundle and fetched in parallel
@@ -333,6 +357,7 @@ export class MaintenanceSupporterPanel extends LitElement {
if (idle) idle(kick, { timeout: 3000 });
else window.setTimeout(kick, 1500);
window.addEventListener("popstate", this._popstateHandler);
window.addEventListener("location-changed", this._locationChangedHandler);
window.addEventListener("keydown", this._paletteKeydown);
if (typeof ResizeObserver !== "undefined") {
this._tightObserver = new ResizeObserver((entries) => {
@@ -355,7 +380,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// other storage access in this file is wrapped; wrap these too.
try {
const saved = lsGet(LS_KEYS.taskSort);
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
if (saved && (SORT_MODES as readonly string[]).includes(saved)) {
this._sortMode = saved as SortMode;
}
const savedObj = lsGet(LS_KEYS.objectSort);
@@ -363,7 +388,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._objectSortMode = savedObj as ObjectSortMode;
}
const savedGroup = lsGet(LS_KEYS.groupBy);
if (savedGroup && ["none", "area", "group", "user", "object"].includes(savedGroup)) {
if (savedGroup && (GROUP_BY_MODES as readonly string[]).includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode;
}
const savedView = lsGet(LS_KEYS.objectView);
@@ -387,6 +412,7 @@ export class MaintenanceSupporterPanel extends LitElement {
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("popstate", this._popstateHandler);
window.removeEventListener("location-changed", this._locationChangedHandler);
window.removeEventListener("keydown", this._paletteKeydown);
this._tightObserver?.disconnect();
this._tightObserver = null;
@@ -394,17 +420,28 @@ export class MaintenanceSupporterPanel extends LitElement {
this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll", this._onVirtualScroll);
this._virtScrollAttached = false;
if (this._virtRaf) cancelAnimationFrame(this._virtRaf);
this._detachStickyPane();
if (this._unsub) {
this._unsub();
this._unsub = null;
}
this._dataLoaded = false;
this._initialLoadDone = false;
this._lastConnection = null;
this._deepLinkHandled = false;
this._statsService?.clearCache();
this._statsService = null;
}
willUpdate(changedProps: Map<string, unknown>): void {
super.willUpdate(changedProps);
// Section labels are mode-specific ("Kitchen" vs "Family Car"): a
// collapsed set from the previous mode would fold random new sections.
if (changedProps.has("_groupByMode") && this._collapsedGroups.size > 0) {
this._collapsedGroups = new Set();
}
}
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
syncLocaleFromHass(this, changedProps);
@@ -451,6 +488,90 @@ export class MaintenanceSupporterPanel extends LitElement {
this._virtScrollAttached = true;
}
this._updateVirtualWindow();
this._syncStickyPane(content);
}
/** Split view: (de)attach the scroll-aware docking of the detail pane and
* apply the current state to the just-rendered DOM. */
private _syncStickyPane(content: Element | null | undefined): void {
const pane = this.shadowRoot?.querySelector<HTMLElement>(".split-pane");
if (!pane || !content) {
if (this._stickyAttached) this._detachStickyPane();
return;
}
if (!this._stickyAttached) {
content.addEventListener("scroll", this._onStickyScroll, { passive: true });
window.addEventListener("resize", this._onStickyScroll);
if (typeof ResizeObserver !== "undefined") this._stickyObserver = new ResizeObserver(this._onStickyScroll);
this._stickyAttached = true;
}
// Observing an already-observed node is a no-op; a re-created pane
// (split left and came back) gets picked up here.
this._stickyObserver?.observe(pane);
this._updateStickyPane();
}
private _detachStickyPane(): void {
this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll", this._onStickyScroll);
window.removeEventListener("resize", this._onStickyScroll);
this._stickyObserver?.disconnect();
this._stickyObserver = null;
if (this._stickyRaf) cancelAnimationFrame(this._stickyRaf);
this._stickyRaf = 0;
this._stickyAttached = false;
this._stickyState = INITIAL_STICKY;
this._stickySelectPending = false;
}
/** rAF-throttled scroll/resize/pane-resize handler for the docked pane. */
private _onStickyScroll = (): void => {
if (this._stickyRaf) return;
this._stickyRaf = requestAnimationFrame(() => {
this._stickyRaf = 0;
this._updateStickyPane();
});
};
/** Measure scroller/pane/list and apply the next docking state as inline
* `top` + `margin-top` on the (sticky) pane. */
private _updateStickyPane(): void {
const sr = this.shadowRoot;
const content = sr?.querySelector<HTMLElement>(".content");
const pane = sr?.querySelector<HTMLElement>(".split-pane");
const layout = sr?.querySelector<HTMLElement>(".split-layout");
const list = sr?.querySelector<HTMLElement>(".split-list");
if (!content || !pane || !layout || !list) return;
// Viewport y of the scroller's content origin — turns client rects
// into scrollTop-space offsets.
const origin = content.getBoundingClientRect().top - content.scrollTop;
const m = {
scrollTop: content.scrollTop,
viewH: content.clientHeight,
paneH: pane.offsetHeight,
listH: list.offsetHeight,
layoutTop: layout.getBoundingClientRect().top - origin,
renderedTop: pane.getBoundingClientRect().top - origin,
};
// Fresh selection: the detail view fills in asynchronously (its own
// update + WS loads), so keep re-parking on every pane resize until the
// user scrolls — from then on the direction machine owns the state.
if (this._stickySelectPending && m.scrollTop === this._stickyState.lastScrollTop) {
this._stickyState = stickyStateOnSelect(m);
} else {
this._stickySelectPending = false;
this._stickyState = nextStickyState(this._stickyState, m);
}
const next = this._stickyState;
pane.style.top = `${stickyTop(next.mode, m)}px`;
pane.style.marginTop = next.marginTop > 0 ? `${next.marginTop}px` : "";
}
/** A new selection parks the pane where the user is looking, whatever the
* pane was doing before (see stickyStateOnSelect). */
private _resetStickyPane(): void {
const content = this.shadowRoot?.querySelector<HTMLElement>(".content");
this._stickyState = { ...INITIAL_STICKY, lastScrollTop: content?.scrollTop ?? 0 };
this._stickySelectPending = true;
}
/** rAF-throttled scroll/resize handler for the virtualized task table. */
@@ -567,10 +688,30 @@ export class MaintenanceSupporterPanel extends LitElement {
this._fetchMiniStatsForOverview();
// Handle deep-link URL parameters (from QR code scan)
this._initialLoadDone = true;
this._handleDeepLink();
}
private _deepLinkHandled = false;
/** Objects + saved views have arrived at least once — deep links that
* reference them are routable. */
private _initialLoadDone = false;
/** In-app navigation to the panel while it is already mounted (#160): HA's
* navigate() — a dashboard button's navigation_path, a notification tap
* with the app open — fires `location-changed` and, since the panel path
* is unchanged, neither remounts us nor hands over a new route object
* (verified on HA 2026.7: the route stays identical for a query-only
* navigation). The URL is the only signal, so re-read the deep-link
* params from it. Navigations away from the panel are not ours. */
private _onLocationChanged(): void {
if (!this._initialLoadDone || !window.location.search) return;
const base = `/${typeof this.panel?.url_path === "string" ? this.panel.url_path : "maintenance-supporter"}`;
const path = window.location.pathname;
if (path !== base && !path.startsWith(`${base}/`)) return;
this._deepLinkHandled = false;
this._handleDeepLink();
}
private _handleDeepLink(): void {
if (this._deepLinkHandled) return;
@@ -616,6 +757,48 @@ export class MaintenanceSupporterPanel extends LitElement {
return;
}
// v2.74.0 (Discussion #160, @maisun): overview deep links — land on a tab
// with the task list pre-filtered / pre-sorted, or on a saved view, so a
// dashboard button or a notification tap can open e.g. the Today list:
// ?tab=today|dashboard|calendar|settings
// ?view=<saved view id or name> (implies the Dashboard tab)
// ?sort=due_date|object|type|task_name|area|assigned_user|group
// ?status=overdue|due_soon|triggered|ok (implies the Dashboard tab)
// Explicit sort/status win over the saved view's; unknown values are
// ignored. Like a tap, the tab (also the implied one) and the sort are
// remembered for the next plain visit. Consumed once and stripped like
// every other param.
const tab = params.get("tab");
const view = params.get("view");
const sort = params.get("sort");
const status = params.get("status");
if (tab !== null || view !== null || sort !== null || status !== null) {
cleanMsActionUrl();
if (this._view !== "overview") this._showOverview();
if ((OVERVIEW_TABS as readonly string[]).includes(tab ?? "")) {
this._setOverviewTab(tab as OverviewTab);
}
if (view !== null) {
const wanted = view.trim().toLowerCase();
const match = this._savedViews.find((v) => v.id === view)
?? this._savedViews.find((v) => v.name.trim().toLowerCase() === wanted);
if (match) {
// Tab first: _applyView flips to the dashboard without persisting.
if (this._overviewTab !== "dashboard") this._setOverviewTab("dashboard");
this._applyView(match.id);
}
}
if ((SORT_MODES as readonly string[]).includes(sort ?? "")) {
this._sortMode = sort as SortMode;
this._activeViewId = "";
lsSet(LS_KEYS.taskSort, this._sortMode);
}
if (STATUS_FILTERS.includes(status ?? "")) {
if (this._overviewTab !== "dashboard") this._setOverviewTab("dashboard");
this._filterByStatus(status as string);
}
}
const entryId = params.get("entry_id");
if (!entryId) return;
this._deepLinkHandled = true;
@@ -1026,10 +1209,10 @@ export class MaintenanceSupporterPanel extends LitElement {
// Validate against the current enums before assigning — a view saved by an
// older/renamed build could carry a mode no longer valid, which would then
// be persisted to localStorage and silently break sorting/grouping.
if (["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(f.sort_mode)) {
if ((SORT_MODES as readonly string[]).includes(f.sort_mode)) {
this._sortMode = f.sort_mode as SortMode;
}
if (["none", "area", "group", "user", "object"].includes(f.group_by)) {
if ((GROUP_BY_MODES as readonly string[]).includes(f.group_by)) {
this._groupByMode = f.group_by as GroupByMode;
}
// Persist sort/group like the manual controls do, so they stick after reload.
@@ -1098,6 +1281,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._selectedTaskId = taskId;
this._activeTab = "overview";
this._historyFilter = null;
this._resetStickyPane();
this._fetchFullHistory(entryId, taskId);
const task = this._getTask(entryId, taskId);
if (task?.trigger_config?.entity_id) {
@@ -1567,7 +1751,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const html = buildObjectReportHtml(
resp.object, resp.tasks, labels,
(iso) => (iso ? formatDate(iso, L) : ""),
this._currencySymbol,
(amount) => formatCost(amount, this._currencySymbol, L),
new Date().toISOString(),
);
openHtmlInNewTab(html);
@@ -2295,16 +2479,18 @@ export class MaintenanceSupporterPanel extends LitElement {
// duplicating what the card now does.
/** Status badge with a shape icon so status isn't conveyed by colour alone
* (colour-blind accessibility) — icon + text + colour together. */
* (colour-blind accessibility) — icon + text + colour together. The label
* is its own span so narrow/tight rows can drop it (#150: the pill ate a
* third of a phone row) while `title`/`aria-label` keep the text reachable. */
private _statusBadge(archived: boolean, isDone: boolean, status: string) {
const L = this._lang;
const cls = archived ? "archived" : (isDone ? "done" : status);
const iconKey = archived ? "archived" : (isDone ? "completed" : status);
const label = archived ? t("archived", L) : (isDone ? t("completed", L) : t(status, L));
return html`<span class="status-badge ${cls}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon>${label}</span>`;
return html`<span class="status-badge ${cls}" role="img" title="${label}" aria-label="${label}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon><span class="status-label">${label}</span></span>`;
}
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
private _setOverviewTab(tab: OverviewTab): void {
this._overviewTab = tab;
try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
this._scrollContentToTop();
@@ -2655,7 +2841,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<div class="task-row virt-sizer" aria-hidden="true">
${this._bulkMode ? html`<span></span>` : nothing}
<span class="cell-badges">
<span class="status-badge"><ha-icon icon="mdi:circle-medium"></ha-icon>${widest}</span>
<span class="status-badge"><ha-icon icon="mdi:circle-medium"></ha-icon><span class="status-label">${widest}</span></span>
${anyDisabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${anyNfc ? html`<span class="nfc-badge"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
${anyPriority ? html`<span class="priority-badge"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
@@ -2721,22 +2907,53 @@ export class MaintenanceSupporterPanel extends LitElement {
: this._groupByMode === "group" ? "mdi:folder-outline"
: this._groupByMode === "object" ? "mdi:cube-outline"
: "mdi:account-outline";
// ONE grid owns the column tracks for every section (each section card
// and its row block are column subgrids), so the badge/object/name
// columns line up across sections no matter which badges a section
// carries — per-section tables started the task name up to 60px apart
// ("OK"-only vs overdue+ok sections). A <details> can't sit in that
// chain (Chrome's ::details-content box breaks subgrid), hence the
// div-based disclosure with its own collapsed state.
return html`
${sorted.map(([key, taskRows]) => html`
<details class="group-section" open>
<summary class="group-section-header">
<ha-icon icon="${icon}"></ha-icon>
<span>${key}</span>
<span class="group-section-count">(${taskRows.length})</span>
</summary>
<div class="task-table${this._bulkMode ? " bulk" : ""}">
${taskRows.map((row) => this._renderOverviewRow(row))}
</div>
</details>
`)}
<div class="task-table grouped${this._bulkMode ? " bulk" : ""}">
${sorted.map(([key, taskRows]) => {
const open = !this._collapsedGroups.has(key);
return html`
<div class="group-section" ?open=${open}>
<div
class="group-section-header"
role="button"
tabindex="0"
aria-expanded=${open ? "true" : "false"}
@click=${() => this._toggleGroup(key)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this._toggleGroup(key);
}
}}
>
<ha-icon icon="${icon}"></ha-icon>
<span>${key}</span>
<span class="group-section-count">(${taskRows.length})</span>
</div>
${open
? html`<div class="group-rows">${taskRows.map((row) => this._renderOverviewRow(row))}</div>`
: nothing}
</div>
`;
})}
</div>
`;
}
private _toggleGroup(key: string): void {
const next = new Set(this._collapsedGroups);
if (next.has(key)) next.delete(key);
else next.add(key);
this._collapsedGroups = next;
}
// (#67) Localised warranty label, shared by the detail meta + table cell.
private _warrantyLabel(ws: ReturnType<typeof warrantyStatus>, iso: string, L: string): string {
if (ws.kind === "expired") return t("warranty_expired", L);
@@ -3223,17 +3440,17 @@ export class MaintenanceSupporterPanel extends LitElement {
const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
const color = pct >= 100 ? "var(--error-color, #f44336)" : pct >= b.alert_threshold_pct ? "var(--warning-color, #ff9800)" : "var(--success-color, #4caf50)";
return html`
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} / ${budget.toFixed(2)} ${cs}">
<span class="stat-value budget-tile-value">${spent.toFixed(2)} ${cs}</span>
<span class="budget-tile-max">/ ${budget.toFixed(0)} ${cs}</span>
<div class="stat-item budget-tile" title="${label}: ${formatNumber(spent, L, 2)} / ${formatCost(budget, cs, L)}">
<span class="stat-value budget-tile-value">${formatCost(spent, cs, L)}</span>
<span class="budget-tile-max">/ ${formatCost(budget, cs, L, 0)}</span>
<div class="budget-tile-bar"><div style="width:${pct}%; background:${color}"></div></div>
<span class="stat-label">${label}</span>
</div>
`;
}
return html`
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} ${cs}">
<span class="stat-value budget-tile-value">${spent.toFixed(2)} ${cs}</span>
<div class="stat-item budget-tile" title="${label}: ${formatCost(spent, cs, L)}">
<span class="stat-value budget-tile-value">${formatCost(spent, cs, L)}</span>
<span class="stat-label">${label}</span>
</div>
`;
@@ -3278,8 +3495,10 @@ export class MaintenanceSupporterPanel extends LitElement {
${row.priority === "high" ? html`<span class="priority-badge priority-high" title="${t("priority_high", L)}"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
${row.priority === "low" ? html`<span class="priority-badge priority-low" title="${t("priority_low", L)}"><ha-icon icon="mdi:chevron-double-down"></ha-icon></span>` : nothing}
</span>
<span class="cell object-name" @click=${(e: Event) => { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name}</span>
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${row.task_name}</span>
<span class="row-head">
<span class="cell object-name" @click=${(e: Event) => { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name}</span>
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${row.task_name}</span>
</span>
<span class="task-sub${hasSub ? '' : ' task-sub-empty'}">
${row.group_names.length > 0 ? html`
<span class="sub-chip" title="${t("groups", L)}">
@@ -3338,25 +3557,27 @@ export class MaintenanceSupporterPanel extends LitElement {
}
}
/** #145: row actions as HA buttons — labelled (accent/success + plain, the
* task-detail pairing) or, for "buttons_compact" on narrow screens,
/** #145: row actions as HA buttons — labelled (accent/success + outlined
* warning, the task-detail pairing) or, for "buttons_compact" on narrow screens,
* icon-only — or the classic icon pair when the household chose "icons". */
private _renderRowActions(L: string, onComplete: () => void, onSkip: () => void, allowSkip = true): TemplateResult {
const style = this._actionStyle();
if (style === "buttons" || style === "buttons_compact") {
const iconOnly = style === "buttons_compact" && (this.narrow || this.tight);
if (iconOnly) {
// Compact narrow form: the primary action stays a solid ha-button
// (icon only), the secondary one becomes HA's own icon button.
// Compact narrow form: two icon-only ha-buttons — solid green
// Complete, outlined orange Skip (#150). Skip wears HA's warning
// variant on every surface, so the colour reads the same whether
// or not the word is there.
return html`
<span class="row-actions as-buttons compact">
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" aria-label="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon icon="mdi:check"></ha-icon>
</ha-button>
${allowSkip ? html`
<ha-icon-button .label=${t("skip", L)} title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-button size="small" appearance="outlined" variant="warning" title="${t("skip", L)}" aria-label="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</ha-icon-button>` : nothing}
</ha-button>` : nothing}
</span>`;
}
return html`
@@ -3365,7 +3586,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}
</ha-button>
${allowSkip ? html`
<ha-button size="small" appearance="outlined" variant="neutral" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-button size="small" appearance="outlined" variant="warning" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>${t("skip", L)}
</ha-button>` : nothing}
</span>`;
@@ -3499,7 +3720,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.then((d) => d?.openCreate(obj.entry_id));
}}>${t("add_first_task", L)}</ha-button>
</div>`
: html`<div class="task-table">${[...visibleTasks].sort((a, b) => {
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) => {
const so: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
return (so[a.status] ?? 9) - (so[b.status] ?? 9) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
}).map((task) => html`