251 files

This commit is contained in:
Home Assistant Version Control
2026-10-16 12:38:25 +00:00
parent 3acf0cdf8d
commit 64eb0c647c
251 changed files with 6380 additions and 14560 deletions
@@ -38,6 +38,10 @@ interface BatteryRow {
* healthy. Never escalates to low/task — the row just shows the
* discrepancy (usual cause: an unrecorded swap). */
forecast_overdue?: boolean;
/** The forecast in days, NOT clamped: negative once the date has passed.
* `days_until` holds at 0 for overdue rows (B1, sorting), so the ~date
* is drawn from this one. */
forecast_days?: number | null;
/** D#162: a Battery Notes note whose device has NO battery level at
* all — read from its type sensor. No level bar, never offline; the
* forecast is the whole signal and (option) a passed one is `low`. */
@@ -235,7 +239,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* replacements)" (D#162 follow-up). */
private _predictedTitle(b: BatteryRow, L: string): string {
if (b.forecast_overdue) return t("battery_fleet_forecast_overdue", L);
const date = this._predictedDate(b.days_until ?? 0);
const date = this._predictedDate(this._forecastDays(b) ?? 0);
if (b.predicted_source === "trend") {
return t("battery_fleet_predicted_trend", L)
.replace("{date}", date)
@@ -344,6 +348,15 @@ export class MaintenanceBatteryFleetSection extends LitElement {
return formatDate(addDaysIso(haToday(), daysUntil), this._lang);
}
/** The days the ~date is drawn from: the unclamped forecast, so a passed
* one keeps its own date instead of reading "today" every day (prod
* report 2026-10-13). `days_until` only covers a server from before
* `forecast_days` (an update not yet restarted); it is the same number
* wherever the forecast has not passed. */
private _forecastDays(b: BatteryRow): number | null {
return b.forecast_days !== undefined ? b.forecast_days : b.days_until;
}
/** The calendar date of an epoch (a detected swap), in the HA profile
* date format and time zone (#163 — a direct Intl.DateTimeFormat call
* here ignored the profile and showed 9/2/2026 to a DD/MM/YYYY user). */
@@ -390,16 +403,20 @@ export class MaintenanceBatteryFleetSection extends LitElement {
);
}
/** Purely visual level bar next to the number — scannable at a glance.
* Colored against the battery's OWN low threshold: red at/below it,
* amber inside a 20-point approach band, green above. */
/** How a level reads against the battery's OWN low threshold: "bad"
* at/below it, "warn" inside a 20-point approach band, "good" above.
* ONE judgement for the bar and the number beside it. */
private _levelTone(level: number, b: BatteryRow): "bad" | "warn" | "good" {
const t = b.low_threshold ?? 20;
return level <= t ? "bad" : level <= t + 20 ? "warn" : "good";
}
/** Purely visual level bar next to the number — scannable at a glance. */
private _levelBar(b: BatteryRow) {
const level = b.level;
if (level == null) return nothing;
const t = b.low_threshold ?? 20;
const cls = level <= t ? "bad" : level <= t + 20 ? "warn" : "good";
return html`<span class="bf-bar" aria-hidden="true"
><span class="bf-bar-fill bf-bar-${cls}" style="width: ${Math.min(100, Math.max(0, level))}%"></span
><span class="bf-bar-fill bf-bar-${this._levelTone(level, b)}" style="width: ${Math.min(100, Math.max(0, level))}%"></span
></span>`;
}
@@ -425,8 +442,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
*
* DOM order is load-bearing for the grid CSS: `.bf-status + .bf-type +
* .bf-offline` (the roster's chip sits AFTER the type, the low/soon
* lists' BEFORE it), `.bf-level + .bf-mark.bf-replaced`, and the phone
* layout's column assignments — keep it. */
* lists' BEFORE it) and the phone layout's column assignments — keep it. */
private _renderRow(
b: FleetRow,
L: string,
@@ -456,6 +472,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
const type = html`<span class="bf-type" title=${typeText}>${typeText}</span>`;
const writer = this._canWrite;
const showMark = writer && (o.mark === "always" || b.no_sensor || b.can_mark_replaced);
const days = this._forecastDays(b);
return html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
@@ -473,7 +490,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
: nothing}
${o.sparkline ? this._sparkline(b) : nothing}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
${b.level != null ? html`<span class="bf-level bf-level-${this._levelTone(b.level, b)}">${b.level}%</span>` : nothing}
${showMark
? html`<button
class="bf-mark${o.mark === "replaced" ? " bf-replaced" : ""}"
@@ -485,11 +502,11 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</button>`
: nothing}
${o.jump && writer ? this._jumpButton(b, L) : nothing}
${o.predicted && b.days_until != null
${o.predicted && days != null
? html`<span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
title=${this._predictedTitle(b, L)}
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(days)}</span
>`
: nothing}
${o.exclude && writer
@@ -821,7 +838,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* has no status chip, no sparkline, no date). */
.bf-rows {
display: grid;
grid-template-columns: minmax(0, 1fr) repeat(9, max-content);
/* The right-hand columns (percentage, action, date, eye) have a
* minimum width, so they line up across the three lists ("Buy now",
* "Needed soon", the roster) although each list is a grid of its own
* and fills different columns - only the roster has the eye, and its
* dates used to end 32 px left of the soon list's. */
grid-template-columns:
minmax(0, 1fr) repeat(5, max-content)
minmax(40px, max-content) minmax(32px, max-content) minmax(92px, max-content) minmax(32px, max-content);
column-gap: 8px;
}
.bf-row {
@@ -867,24 +891,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
grid-column: 7;
justify-self: end;
}
/* Every row action, the Replaced one included, sits in column 8. The
* Replaced action used to take the empty percentage slot on sensorless
* rows (D#162, while nothing else filled column 8) and column 8 on rows
* with a percentage (since 2.79), so it jumped 40 px between
* neighbouring rows (prod report 2026-10-13). Rows with a percentage
* fill column 8 anyway, so one column for all costs no width. The
* narrow layout below gives Replaced a slot of its own. */
.bf-row .bf-mark {
grid-column: 8;
}
/* D#162: the sensorless row's Replaced action sits in the percentage
* slot (always empty for such a row) instead of the row-action column
* - a new max-content track there widens EVERY row via the subgrid
* and pushed the phone roster 34 px past its edge. */
.bf-row .bf-mark.bf-replaced {
grid-column: 7;
justify-self: end;
}
/* D#162 (maisun's iPhone): since 2.79 rows WITH a level offer Replaced
* too — level and button shared column 7 and overlapped. With a
* percentage present the button takes the row-action column instead. */
.bf-row .bf-level + .bf-mark.bf-replaced {
grid-column: 8;
justify-self: start;
}
.bf-predicted {
grid-column: 9;
justify-self: end;
@@ -1003,7 +1019,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-soon-rows .bf-row:has(.bf-level) .bf-dev {
grid-column: 1 / 9;
}
.bf-row .bf-level + .bf-mark.bf-replaced {
.bf-row .bf-mark.bf-replaced {
grid-row: 2;
grid-column: 7;
justify-self: end;
@@ -1090,10 +1106,19 @@ export class MaintenanceBatteryFleetSection extends LitElement {
border-color: var(--primary-color);
color: var(--primary-color);
}
/* The number takes the bar's colour. It was red on every row, from the
* days it only showed in the low list; the soon list and the roster
* show it too, and a full battery read as an alarm there (prod report
* 2026-10-13). A healthy level is plain text. */
.bf-level {
font-size: 12px;
}
.bf-level-bad {
color: var(--error-color, #f44336);
}
.bf-level-warn {
color: var(--warning-color, #ff9800);
}
.bf-mark {
background: transparent;
border: none;
@@ -8,7 +8,6 @@ import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import { MAX_READING_SLOTS, cleanReadingSlots, duplicateReadingSlotIds, newReadingSlotId } from "../helpers/reading-slots";
import {
ENVIRONMENTAL_PICKER_DEVICE_CLASSES,
ENVIRONMENTAL_PICKER_DOMAINS,
TRIGGER_PICKER_DOMAINS,
} from "../helpers/trigger-domains";
@@ -3216,10 +3215,7 @@ export class MaintenanceTaskDialog extends LitElement {
.hass=${this.hass}
.schema=${[{
name: "environmental_entity",
selector: { entity: {
domain: ENVIRONMENTAL_PICKER_DOMAINS,
device_class: ENVIRONMENTAL_PICKER_DEVICE_CLASSES,
} },
selector: { entity: { domain: ENVIRONMENTAL_PICKER_DOMAINS } },
}]}
.data=${{ environmental_entity: this._environmentalEntity }}
.computeLabel=${() => t("environmental_entity_optional", L)}
@@ -67,6 +67,7 @@
"last_performed": "Naposledy provedeno",
"last_done_days_ago": "hotovo před {n} d",
"last_done_today": "hotovo dnes",
"no_due_date": "Bez termínu",
"next_due": "Další termín",
"days_until_due": "Dnů do termínu",
"avg_duration": "Prům. trvání",
@@ -69,6 +69,7 @@
"last_performed": "Senest udført",
"last_done_days_ago": "udført for {n} d siden",
"last_done_today": "udført i dag",
"no_due_date": "Ingen forfaldsdato",
"next_due": "Næste forfald",
"days_until_due": "Dage til forfald",
"avg_duration": "Gns. varighed",
@@ -69,6 +69,7 @@
"last_performed": "Zuletzt durchgeführt",
"last_done_days_ago": "vor {n} T erledigt",
"last_done_today": "heute erledigt",
"no_due_date": "Ohne Fälligkeit",
"next_due": "Nächste Fälligkeit",
"days_until_due": "Tage bis fällig",
"avg_duration": "Ø Dauer",
@@ -69,6 +69,7 @@
"last_performed": "Last performed",
"last_done_days_ago": "done {n} d ago",
"last_done_today": "done today",
"no_due_date": "No due date",
"next_due": "Next due",
"days_until_due": "Days until due",
"avg_duration": "Avg duration",
@@ -67,6 +67,7 @@
"last_performed": "Última ejecución",
"last_done_days_ago": "hecho hace {n} d",
"last_done_today": "hecho hoy",
"no_due_date": "Sin vencimiento",
"next_due": "Próximo vencimiento",
"days_until_due": "Días hasta vencimiento",
"avg_duration": "Ø Duración",
@@ -69,6 +69,7 @@
"last_performed": "Viimeksi suoritettu",
"last_done_days_ago": "tehty {n} pv sitten",
"last_done_today": "tehty tänään",
"no_due_date": "Ei eräpäivää",
"next_due": "Seuraava eräpäivä",
"days_until_due": "Päiviä eräpäivään",
"avg_duration": "Keskim. kesto",
@@ -67,6 +67,7 @@
"last_performed": "Dernière exécution",
"last_done_days_ago": "fait il y a {n} j",
"last_done_today": "fait aujourd’hui",
"no_due_date": "Sans échéance",
"next_due": "Prochaine échéance",
"days_until_due": "Jours restants",
"avg_duration": "Ø Durée",
@@ -69,6 +69,7 @@
"last_performed": "अंतिम बार किया गया",
"last_done_days_ago": "{n} दिन पहले किया",
"last_done_today": "आज किया",
"no_due_date": "कोई देय तिथि नहीं",
"next_due": "अगली देय",
"days_until_due": "देय तक दिन",
"avg_duration": "औसत अवधि",
@@ -69,6 +69,7 @@
"last_performed": "Utoljára elvégezve",
"last_done_days_ago": "{n} napja elvégezve",
"last_done_today": "ma elvégezve",
"no_due_date": "Nincs határidő",
"next_due": "Következő esedékesség",
"days_until_due": "Napok az esedékességig",
"avg_duration": "Átl. időtartam",
@@ -67,6 +67,7 @@
"last_performed": "Ultima esecuzione",
"last_done_days_ago": "fatto {n} g fa",
"last_done_today": "fatto oggi",
"no_due_date": "Senza scadenza",
"next_due": "Prossima scadenza",
"days_until_due": "Giorni alla scadenza",
"avg_duration": "Ø Durata",
@@ -69,6 +69,7 @@
"last_performed": "最終実施",
"last_done_days_ago": "{n}日前に実施",
"last_done_today": "今日実施",
"no_due_date": "期限なし",
"next_due": "次回期限",
"days_until_due": "期限までの日数",
"avg_duration": "平均所要時間",
@@ -69,6 +69,7 @@
"last_performed": "마지막 수행일",
"last_done_days_ago": "{n}일 전 완료",
"last_done_today": "오늘 완료",
"no_due_date": "예정일 없음",
"next_due": "다음 예정일",
"days_until_due": "남은 일수",
"avg_duration": "평균 소요 시간",
@@ -69,6 +69,7 @@
"last_performed": "Sist utført",
"last_done_days_ago": "utført for {n} d siden",
"last_done_today": "utført i dag",
"no_due_date": "Ingen forfallsdato",
"next_due": "Neste forfall",
"days_until_due": "Dager til forfall",
"avg_duration": "Gj.snittlig varighet",
@@ -67,6 +67,7 @@
"last_performed": "Laatst uitgevoerd",
"last_done_days_ago": "{n} d geleden gedaan",
"last_done_today": "vandaag gedaan",
"no_due_date": "Geen vervaldatum",
"next_due": "Volgende keer",
"days_until_due": "Dagen tot vervaldatum",
"avg_duration": "Ø Duur",
@@ -67,6 +67,7 @@
"last_performed": "Ostatnio wykonane",
"last_done_days_ago": "wykonano {n} d temu",
"last_done_today": "wykonano dziś",
"no_due_date": "Bez terminu",
"next_due": "Następny termin",
"days_until_due": "Dni do terminu",
"avg_duration": "Śr. czas trwania",
@@ -69,6 +69,7 @@
"last_performed": "Última execução",
"last_done_days_ago": "feito há {n} d",
"last_done_today": "feito hoje",
"no_due_date": "Sem vencimento",
"next_due": "Próximo vencimento",
"days_until_due": "Dias até o vencimento",
"avg_duration": "Duração média",
@@ -67,6 +67,7 @@
"last_performed": "Última execução",
"last_done_days_ago": "feito há {n} d",
"last_done_today": "feito hoje",
"no_due_date": "Sem vencimento",
"next_due": "Próximo vencimento",
"days_until_due": "Dias até vencimento",
"avg_duration": "Ø Duração",
@@ -67,6 +67,7 @@
"last_performed": "Последнее выполнение",
"last_done_days_ago": "выполнено {n} дн. назад",
"last_done_today": "выполнено сегодня",
"no_due_date": "Без срока",
"next_due": "Следующий срок",
"days_until_due": "Дней до срока",
"avg_duration": "Ср. длительность",
@@ -67,6 +67,7 @@
"last_performed": "Senast utförd",
"last_done_days_ago": "klar för {n} d sedan",
"last_done_today": "klar i dag",
"no_due_date": "Inget förfallodatum",
"next_due": "Nästa förfallodatum",
"days_until_due": "Dagar till förfallodatum",
"avg_duration": "Snittlig varaktighet",
@@ -69,6 +69,7 @@
"last_performed": "Son yapılma",
"last_done_days_ago": "{n} g önce yapıldı",
"last_done_today": "bugün yapıldı",
"no_due_date": "Vade tarihi yok",
"next_due": "Sonraki vade",
"days_until_due": "Vadeye kalan gün",
"avg_duration": "Ort. süre",
@@ -67,6 +67,7 @@
"last_performed": "Останнє виконання",
"last_done_days_ago": "виконано {n} д тому",
"last_done_today": "виконано сьогодні",
"no_due_date": "Без терміну",
"next_due": "Наступний термін",
"days_until_due": "Днів до терміну",
"avg_duration": "Сер. тривалість",
@@ -69,6 +69,7 @@
"last_performed": "上次执行",
"last_done_days_ago": "{n} 天前完成",
"last_done_today": "今天完成",
"no_due_date": "无到期日期",
"next_due": "下次到期",
"days_until_due": "距离到期天数",
"avg_duration": "平均耗时",
@@ -5,7 +5,7 @@ import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscr
import { hydrateObjects } from "./helpers/hydrate-objects";
import { taskIsFor } from "./helpers/assignees";
import { property, state } from "lit/decorators.js";
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatLastDone, formatDate, langOf, currencySymbolOf, syncCurrencyDecimals} from "./styles";
import { syncLocaleFromHass, sharedStyles, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatLastDone, formatDate, langOf, currencySymbolOf, syncCurrencyDecimals} from "./styles";
import { openSignedDocument } from "./helpers/document-url";
import { isPhotoDocument } from "./helpers/document-categories";
import { isSafeLink } from "./helpers/url";
@@ -28,6 +28,7 @@ import { renderEventTitles } from "./helpers/event-titles";
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
import { fetchSettingsOnce, FALLBACK_SETTINGS, type SettingsCache } from "./helpers/settings-cache";
import { canWrite } from "./helpers/permissions";
import { statusColor } from "./renderers/status";
import { ACTIONABLE_STATUSES, isActionableStatus, statusRank } from "./status-constants";
import "./maintenance-card-editor";
import "./components/complete-dialog";
@@ -579,7 +580,7 @@ export class MaintenanceSupporterCard extends LitElement {
<div class="task-item clickable"
@click=${() => this._openTaskDetail(entry_id, task.id)}
title="${t("open_task", L)}">
<div class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></div>
<div class="status-dot" style="background: ${statusColor(task)}"></div>
<div class="task-info">
<div class="task-name">
${task.name}${renderEventTitles(task.next_event_titles)}
@@ -130,7 +130,7 @@ import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type S
import { invalidateSettingsCache, parseSettings, type SettingsWire } from "./helpers/settings-cache";
import type { PartsCostMode } from "./helpers/parts-cost";
import { canWrite } from "./helpers/permissions";
import { renderStatusBadge } from "./renderers/status";
import { renderStatusBadge, statusKey, statusLabel, type StatusLike } from "./renderers/status";
import { TOAST_MS, ACTION_TOAST_MS } from "./helpers/toast";
import { primaryTriggerEntity } from "./helpers/trigger-entities";
import { buildHistoryEntryDraft } from "./helpers/history-draft";
@@ -355,6 +355,9 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _virtStart = 0;
@state() private _virtEnd = 0; // 0 = uninitialized → initial slice
private _virtRowHeight = 53;
/** Set by the first render and by every scroll/resize: the next window
* pass may re-measure the row height (see _updateVirtualWindow). */
private _virtMeasure = true;
private _virtTotalRows = 0;
private _virtScrollAttached = false;
private _virtRaf = 0;
@@ -619,8 +622,9 @@ export class MaintenanceSupporterPanel extends LitElement {
// Virtualized task table: attach the scroll listener once `.content`
// exists (lit keeps the node stable across renders), then reconcile the
// window with the just-rendered DOM (row-height probe + clamps). Only
// sets state when the window actually moved, so this can't loop.
// window with the just-rendered DOM (clamps). Only sets state when the
// window actually moved, and the row-height estimate holds still until
// the next scroll or resize, so this can't loop.
const content = this.shadowRoot?.querySelector(".content");
if (content && !this._virtScrollAttached) {
content.addEventListener("scroll", this._onVirtualScroll, { passive: true });
@@ -715,6 +719,7 @@ export class MaintenanceSupporterPanel extends LitElement {
/** rAF-throttled scroll/resize handler for the virtualized task table. */
private _onVirtualScroll = (): void => {
this._virtMeasure = true;
if (this._virtRaf) return;
this._virtRaf = requestAnimationFrame(() => {
this._virtRaf = 0;
@@ -728,10 +733,22 @@ export class MaintenanceSupporterPanel extends LitElement {
const content = this.shadowRoot?.querySelector<HTMLElement>(".content");
const table = this.shadowRoot?.querySelector<HTMLElement>(".task-table.virtual");
if (!content || !table) return;
// Probe the real row height from a rendered row (uniform rows; the
// hidden sizer row is height 0 and excluded).
const probe = table.querySelector<HTMLElement>(".task-row:not(.virt-sizer)");
if (probe && probe.offsetHeight > 20) this._virtRowHeight = probe.offsetHeight;
// The row-height estimate is the mean of the rendered rows (the hidden
// sizer row is height 0 and excluded), taken on the first render and
// after each scroll or resize, never from the render the new window
// caused. Rows are not uniform: a sensor row carries a value and a
// sparkline under its date. Probing the first row after every render
// fed a loop: window A measured a short row, the new estimate moved the
// window to B, B's first row was a tall one, the estimate moved it back
// to A, and the page froze (2026-10-13, a 125-task install).
if (this._virtMeasure) {
const rows = [...table.querySelectorAll<HTMLElement>(".task-row:not(.virt-sizer)")];
const mean = rows.length ? rows.reduce((n, r) => n + r.offsetHeight, 0) / rows.length : 0;
if (mean > 20) {
if (Math.abs(mean - this._virtRowHeight) >= 2) this._virtRowHeight = Math.round(mean);
this._virtMeasure = false;
}
}
const listTop =
table.getBoundingClientRect().top - content.getBoundingClientRect().top + content.scrollTop;
const w = computeWindow({
@@ -3495,8 +3512,8 @@ export class MaintenanceSupporterPanel extends LitElement {
* (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) {
return renderStatusBadge({ archived, is_done: isDone, status }, this._lang);
private _statusBadge(task: StatusLike) {
return renderStatusBadge(task, this._lang);
}
private _setOverviewTab(tab: OverviewTab): void {
@@ -3869,7 +3886,7 @@ export class MaintenanceSupporterPanel extends LitElement {
let anyNfc = false;
let anyPriority = false;
for (const r of rows) {
const label = r.archived ? t("archived", L) : (r.is_done ? t("completed", L) : t(r.status, L));
const label = statusLabel(statusKey(r), L);
if (label.length > widest.length) widest = label;
if (!r.enabled) anyDisabled = true;
if (r.nfc_tag_id) anyNfc = true;
@@ -4625,7 +4642,7 @@ export class MaintenanceSupporterPanel extends LitElement {
</label>
` : nothing}
<span class="cell-badges">
${this._statusBadge(!!row.archived, row.is_done, row.status)}
${this._statusBadge(row)}
${!row.enabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${row.nfc_tag_id ? html`<span class="nfc-badge" title="${t("nfc_linked", L)}"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
${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}
@@ -4871,7 +4888,7 @@ export class MaintenanceSupporterPanel extends LitElement {
).map((task) => html`
<div class="task-row${!task.enabled ? ' task-disabled' : ''}">
<span class="cell-badges">
${this._statusBadge(!!task.archived, !!task.is_done, task.status)}
${this._statusBadge(task)}
${!task.enabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${task.nfc_tag_id ? html`<span class="nfc-badge" title="${t("nfc_linked", L)}"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
${task.document_count
@@ -221,6 +221,29 @@ export const panelStyles = css`
transition: background 0.15s;
}
/* One-line rows line up on their FIRST TEXT LINE, not on their middles.
The due cell is a stack (date, then bar, sensor value, sparkline) and
"center" centred the whole stack, so in sensor rows the date sat 11-24 px
above the type next to it. Baseline alignment puts status, object, task,
type, date and the button labels on one line; whatever a cell stacks
below hangs underneath. Only the one-line layout: narrow, tight and the
768 px fallback lay their rows out in two lines of their own. */
@media (min-width: 769px) {
:host(:not([narrow]):not([tight])) .task-row { align-items: first baseline; }
/* The status pill starts with its icon, which has no text, so the pill
took its baseline from the icon's bottom edge and sat 3.5 px off the
line. The label is the taller item, so this does not move it inside
the pill. */
:host(:not([narrow]):not([tight])) .task-row .status-label { align-self: baseline; }
/* Icon-only actions have no text, so they would hang off their bottom
edge. A zero-width character in their vertical middle gives the pair
a baseline, and the buttons centre on the text line. */
:host(:not([narrow]):not([tight])) .row-actions:not(.as-buttons)::before {
content: "\\200b";
margin-inline-end: -2px;
}
}
/* Virtualized task table (large installs): only the scroll window of rows
is in the DOM. The spacers span all columns and carry the off-window
height so the scrollbar stays honest. The sizer row is invisible and
@@ -826,6 +849,13 @@ export const panelStyles = css`
color: white;
}
/* D#203: a manual task without a due date (outline, muted grey). */
.status-chip.no_due {
background: var(--maint-no-due-background, transparent);
color: var(--secondary-text-color, #727272);
box-shadow: inset 0 0 0 1px currentColor;
}
/* (#67) Warranty status chip — object detail meta + objects table */
.warranty-chip {
display: inline-block;
@@ -541,6 +541,9 @@ export const sharedStyles = css`
.status-badge.triggered { background-color: var(--maint-triggered-color); }
/* Completed one-time task ("done") — muted blue-grey. */
.status-badge.done { background-color: var(--maint-done-color, #78909c); }
/* D#203: a manual task without a due date — an outline in the muted grey of
its "done 23 d ago", so it does not read as a green, on-schedule OK. */
.status-badge.no_due { background-color: var(--maint-no-due-background, transparent); color: var(--secondary-text-color, #727272); box-shadow: inset 0 0 0 1px currentColor; }
/* v2.10.0: archived (retire-but-retain) — neutral grey, clearly inert. */
.status-badge.archived { background-color: var(--disabled-color, #9e9e9e); color: #000; }
/* v2.20 (N3): paused — frozen but present, info blue. */
@@ -1057,6 +1060,10 @@ export const sharedStyles = css`
}
.due-text { font-size: 13px; }
/* The cell is right-aligned; a date that wraps in a narrow column
("63 d / overdue") ends flush right like the bar and the value under it,
instead of starting each line at a different spot from row to row. */
.due-cell .due-text { text-align: end; }
/* D#203: a task without a due date shows when it was last done — muted,
so it never reads as a status. */
.due-text.last-done { color: var(--secondary-text-color); }