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
@@ -41,12 +41,19 @@ interface BatteryRow {
no_sensor?: boolean;
/** D#162 follow-up: the note has a replaced button → per-row Replaced action. */
can_mark_replaced?: boolean;
/** D#162 follow-up: which type lifetime the typical forecast used. */
lifetime_months?: number | null;
lifetime_source?: "override" | "learned_device" | "learned_model" | "table" | "default" | null;
lifetime_samples?: number;
/** ISO date the forecast anchors on (Battery Notes' last replaced). */
last_replaced?: string | null;
}
interface RosterRow extends BatteryRow {
status: "low" | "soon" | "ok";
}
/** What the shared row template takes: a low/soon row, or a roster row
* (which adds the status). */
type FleetRow = BatteryRow & { status?: RosterRow["status"] };
/** 30 d downsampled level history per battery, for the roster sparklines.
* threshold = the same low threshold the trend forecast regresses toward,
* so the dotted projection ends exactly where the ~date comes from.
@@ -252,8 +259,37 @@ export class MaintenanceBatteryFleetSection extends LitElement {
/** Lazy: the recorder-backed history is fetched once, when the roster is
* first expanded — most panel visits never open it. */
/** D#162: the roster is open unless this browser collapsed it before. */
private _rosterOpen(): boolean {
return lsGet(LS_KEYS.batteryRosterOpen) !== "0";
}
/** The ~date tooltip: trend (with confidence) or the type lifetime with
* where it came from — "18 months typical for CR2032 (learned from 5
* 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);
if (b.predicted_source === "trend") {
return t("battery_fleet_predicted_trend", L)
.replace("{date}", date)
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L));
}
if (b.lifetime_months != null && b.lifetime_source) {
const source = t("lifetime_source_" + b.lifetime_source, L).replace("{n}", String(b.lifetime_samples ?? 0));
return t("battery_fleet_predicted_typical", L)
.replace("{date}", date)
.replace("{months}", String(b.lifetime_months))
.replace("{type}", b.battery_type)
.replace("{source}", source);
}
return t("battery_fleet_predicted_on", L).replace("{date}", date);
}
private _loadHistory = async (e: Event): Promise<void> => {
if (!(e.target as HTMLDetailsElement).open || this._historyRequested) return;
const open = (e.target as HTMLDetailsElement).open;
lsSet(LS_KEYS.batteryRosterOpen, open ? "1" : "0");
if (!open || this._historyRequested) return;
this._historyRequested = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ series: HistorySeries }>({
@@ -269,7 +305,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* where the ~date comes from the discharge trend — a dotted projection
* from the last reading down to the threshold, so the date is visible
* instead of merely stated. */
private _sparkline(b: RosterRow) {
private _sparkline(b: FleetRow) {
const h = this._history?.[b.entity_id];
if (!h || h.points.length < 2) return nothing;
const W = 110, H = 24, P = 2;
@@ -406,6 +442,106 @@ export class MaintenanceBatteryFleetSection extends LitElement {
></span>`;
}
/** The "record this replacement" chip: the level history shows an upward
* jump nobody recorded in Battery Notes (roster only). */
private _jumpButton(b: BatteryRow, L: string) {
const jump = this._history?.[b.entity_id]?.jump;
if (!jump || this._recorded.includes(b.entity_id)) return nothing;
return html`<button
class="bf-mark bf-jump"
title=${t("battery_fleet_record_replacement", L).replace("{date}", this._fmtDate(jump.at * 1000))}
.disabled=${this._marking}
@click=${() => this._recordJump(b.entity_id, jump)}
>
<ha-icon icon="mdi:calendar-sync"></ha-icon>
</button>`;
}
/** ONE row template for the three lists (low / needed soon / roster).
* They were hand-copied and had drifted: only the low list said
* "recharged" on a rechargeable's mark action and only it showed the
* offline hint (DRY audit 2026-09-12). `o` names the per-list extras.
*
* 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. */
private _renderRow(
b: FleetRow,
L: string,
o: {
/** Roster: status chip in column 2, the sensor chip after the type. */
status?: boolean;
/** Rechargeable badge (low list + roster). */
recharge?: boolean;
/** 30 d level sparkline (roster). */
sparkline?: boolean;
/** "always": every row gets the mark action (low list); "replaced":
* only a sensorless row or a note with a replaced button. */
mark: "always" | "replaced";
jump?: boolean;
predicted?: boolean;
exclude?: boolean;
},
) {
const chip =
b.available === false
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
: b.no_sensor
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
: nothing;
const type = html`<span class="bf-type">${b.quantity}× ${b.battery_type}</span>`;
const showMark = o.mark === "always" || b.no_sensor || b.can_mark_replaced;
return html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
${o.status
? html`<span class="bf-status bf-${b.status}"
>${b.no_sensor && b.status === "low"
? t("battery_fleet_status_due", L)
: t("battery_fleet_status_" + b.status, L)}</span
>${type}${chip}`
: html`${chip}${type}`}
${o.recharge && b.rechargeable
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
></span>`
: nothing}
${o.sparkline ? this._sparkline(b) : nothing}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
${showMark
? html`<button
class="bf-mark${o.mark === "replaced" ? " bf-replaced" : ""}"
title=${b.rechargeable ? t("battery_fleet_mark_recharged", L) : t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>`
: nothing}
${o.jump ? this._jumpButton(b, L) : nothing}
${o.predicted && b.days_until != 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
>`
: nothing}
${o.exclude
? html`<button
class="bf-mark bf-exclude"
title=${t("battery_fleet_exclude", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(b.entity_id, true)}
>
<ha-icon icon="mdi:eye-off-outline"></ha-icon>
</button>`
: nothing}
</div>
`;
}
render() {
const L = this._lang;
if (this._loading && this._ov === null) return html`<div class="bf-card"><div class="bf-loading">…</div></div>`;
@@ -442,42 +578,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
<span class="bf-list">${this._shoppingLine(ov.needs_now)}</span>
</div>
<div class="bf-rows">
${ov.low.map(
(b) => html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
${b.available === false
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
: b.no_sensor
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
: nothing}
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
${b.rechargeable
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
></span>`
: nothing}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
<button
class="bf-mark"
title=${b.rechargeable ? t("battery_fleet_mark_recharged", L) : t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>
<button
class="bf-mark bf-exclude"
title=${t("battery_fleet_exclude", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(b.entity_id, true)}
>
<ha-icon icon="mdi:eye-off-outline"></ha-icon>
</button>
</div>
`,
)}
${ov.low.map((b) => this._renderRow(b, L, { recharge: true, mark: "always", exclude: true }))}
</div>
<div class="bf-actions">
<ha-button .disabled=${this._marking} @click=${this._markAll}>
@@ -493,11 +594,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
<span class="bf-list">${this._shoppingLine(ov.needs_soon)}</span>
<div class="bf-soon-hint">${t("battery_fleet_soon_hint", L)}</div>
</div>
<div class="bf-rows bf-soon-rows">
${ov.soon.map((b) => this._renderRow(b, L, { mark: "replaced", predicted: true }))}
</div>
`
: nothing}
${ov.all?.length
? html`
<details class="bf-roster" @toggle=${this._loadHistory}>
<details class="bf-roster" ?open=${this._rosterOpen()} @toggle=${this._loadHistory}>
<summary>${t("battery_fleet_all", L)} (${ov.all.length})</summary>
<div class="bf-roster-tools">
<button
@@ -514,72 +618,8 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</button>
</div>
<div class="bf-rows">
${this._sortedRoster(ov.all).map(
(b) => html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
<span class="bf-status bf-${b.status}"
>${b.no_sensor && b.status === "low"
? t("battery_fleet_status_due", L)
: t("battery_fleet_status_" + b.status, L)}</span
>
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
${b.no_sensor
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
: nothing}
${b.rechargeable
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
></span>`
: nothing}
${this._sparkline(b)}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
${b.no_sensor || b.can_mark_replaced
? html`<button
class="bf-mark bf-replaced"
title=${t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>`
: nothing}
${(() => {
const jump = this._history?.[b.entity_id]?.jump;
if (!jump || this._recorded.includes(b.entity_id)) return nothing;
return html`<button
class="bf-mark bf-jump"
title=${t("battery_fleet_record_replacement", L).replace("{date}", this._fmtDate(jump.at * 1000))}
.disabled=${this._marking}
@click=${() => this._recordJump(b.entity_id, jump)}
>
<ha-icon icon="mdi:calendar-sync"></ha-icon>
</button>`;
})()}
${b.days_until != null
? html`<span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
title=${b.forecast_overdue
? t("battery_fleet_forecast_overdue", L)
: b.predicted_source === "trend"
? t("battery_fleet_predicted_trend", L)
.replace("{date}", this._predictedDate(b.days_until))
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
>`
: nothing}
<button
class="bf-mark bf-exclude"
title=${t("battery_fleet_exclude", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(b.entity_id, true)}
>
<ha-icon icon="mdi:eye-off-outline"></ha-icon>
</button>
</div>
`,
${this._sortedRoster(ov.all).map((b) =>
this._renderRow(b, L, { status: true, recharge: true, sparkline: true, mark: "replaced", jump: true, predicted: true, exclude: true }),
)}
</div>
<div class="bf-roster-hint">${t("battery_fleet_all_hint", L)}</div>
@@ -761,9 +801,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-bar {
grid-column: 6;
}
/* D#162: the roster's "No sensor" chip takes the level bar's slot
* (such a row has no bar); column 2 stays the status chip's. */
.bf-status + .bf-type + .bf-nosensor {
/* D#162: the roster's "No sensor" / offline chip takes the level bar's
* slot (such a row has no bar); column 2 stays the status chip's. */
.bf-status + .bf-type + .bf-offline {
grid-column: 6;
justify-self: start;
white-space: nowrap;
@@ -783,6 +823,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
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;
@@ -855,11 +902,38 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-row .bf-mark {
grid-row: 2;
}
/* Phone (D#162, maisun): the percentage belongs to the name line - it
* is what you scan for - and the Replaced action to line 2 with type
* and date. A row WITH a percentage therefore lifts it to line 1 (the
* name yields column 8; in the "Needed soon" list, which has no status
* chip, it takes the chip's place at the right edge) and the action
* takes the percentage's slot on line 2 - the same slot a sensorless
* row uses, so every row's action lines up. Before, the action shared
* the slot with the percentage and painted over it. */
.bf-row .bf-level {
grid-row: 1;
grid-column: 8;
justify-self: end;
}
.bf-soon-rows .bf-row .bf-level {
grid-column: 9 / 11;
}
.bf-row:has(.bf-level) .bf-dev {
grid-column: 1 / 8;
}
.bf-soon-rows .bf-row:has(.bf-level) .bf-dev {
grid-column: 1 / 9;
}
.bf-row .bf-level + .bf-mark.bf-replaced {
grid-row: 2;
grid-column: 7;
justify-self: end;
}
/* The roster's "No sensor" chip would widen the shared bar track
* for EVERY row (subgrid) - on phones the missing percentage and
* the "Due" status already tell the story, so it yields like the
* bar and the sparkline do. */
.bf-status + .bf-type + .bf-nosensor {
.bf-status + .bf-type + .bf-offline {
display: none;
}
}