251 files
This commit is contained in:
+54
-29
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user