329 files

This commit is contained in:
Home Assistant Version Control
2026-08-06 13:56:25 +00:00
parent 0df89406fa
commit 7afe7add1d
330 changed files with 13098 additions and 5942 deletions
@@ -18,10 +18,35 @@ interface BatteryRow {
level: number | null;
days_until: number | null;
available?: boolean;
/** Where the ~date comes from: "trend" (discharge regression) or "typical"
* (type-lifetime table). */
predicted_source?: "trend" | "typical";
prediction_confidence?: "medium" | "high" | null;
/** Charged, never bought — the row never feeds the shopping groupings and
* a ~date only appears when the discharge trend earned one. */
rechargeable?: boolean;
/** This battery's own low threshold (Battery Notes' configured value or
* the fleet floor, whichever is higher) — the level bar colors against
* it, not against a fixed 20 %. */
low_threshold?: number;
}
interface RosterRow extends BatteryRow {
status: "low" | "soon" | "ok";
}
/** 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.
* jump = an upward step that looks like a swap nobody recorded in Battery
* Notes (the forecast still anchors on the dead battery's date) — with the
* device to call `battery_notes.set_battery_replaced` on. */
type HistorySeries = Record<
string,
{
points: [number, number][];
threshold: number;
jump?: { at: number; from: number; to: number; device_id: string };
}
>;
interface Overview {
available: boolean;
configured: boolean;
@@ -45,6 +70,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
@state() private _loading = false;
@state() private _marking = false;
@state() private _error = "";
@state() private _history: HistorySeries | null = null;
// Urgency by default (issue #123: "soon sat in the middle of the list");
// the choice is remembered per browser.
@state() private _rosterSort: "name" | "urgency" = MaintenanceBatteryFleetSection._storedSort();
@state() private _typeFilter: string | null = null;
@state() private _recorded: string[] = [];
private _historyRequested = false;
private _localeReady = false;
private get _lang(): string {
@@ -139,20 +171,165 @@ 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. */
private _loadHistory = async (e: Event): Promise<void> => {
if (!(e.target as HTMLDetailsElement).open || this._historyRequested) return;
this._historyRequested = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ series: HistorySeries }>({
type: "maintenance_supporter/battery_fleet/overview_history",
});
this._history = res.series;
} catch {
this._history = null; // sparklines are an enhancement — rows render without them
}
};
/** Inline-SVG sparkline: 30 d level line, a faint threshold line, and —
* 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) {
const h = this._history?.[b.entity_id];
if (!h || h.points.length < 2) return nothing;
const W = 110, H = 24, P = 2;
const t0 = h.points[0][0];
const tLast = h.points[h.points.length - 1][0];
const nowSec = Date.now() / 1000;
const projEnd =
b.status !== "low" && b.predicted_source === "trend" && b.days_until != null
? nowSec + b.days_until * 86400
: null;
const tMax = Math.max(tLast, projEnd ?? tLast);
const x = (t: number) => (tMax === t0 ? P : P + ((t - t0) / (tMax - t0)) * (W - 2 * P));
const y = (v: number) => P + (1 - Math.min(100, Math.max(0, v)) / 100) * (H - 2 * P);
const line = h.points.map(([t, v]) => `${x(t).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const vLast = h.points[h.points.length - 1][1];
const yTh = y(h.threshold).toFixed(1);
return html`<svg
class="bf-spark"
viewBox="0 0 ${W} ${H}"
role="img"
aria-label=${t("battery_fleet_sparkline_hint", this._lang)}
>
<title>${t("battery_fleet_sparkline_hint", this._lang)}</title>
<line class="bf-spark-th" x1="0" y1=${yTh} x2=${W} y2=${yTh}></line>
<polyline class="bf-spark-line" points=${line}></polyline>
${projEnd !== null
? html`<line
class="bf-spark-proj"
x1=${x(tLast).toFixed(1)}
y1=${y(vLast).toFixed(1)}
x2=${x(projEnd).toFixed(1)}
y2=${yTh}
></line>`
: nothing}
</svg>`;
}
private static readonly _SORT_KEY = "ms_bf_roster_sort";
private static _storedSort(): "name" | "urgency" {
try {
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
return v === "name" ? "name" : "urgency";
} catch {
return "urgency";
}
}
private _setSort(mode: "name" | "urgency"): void {
this._rosterSort = mode;
try {
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode);
} catch {
// storage unavailable — the toggle still works for this visit
}
}
/** Urgency (the default, issue #123): low rows first — emptiest first —
* then the soonest forecast, dateless rows last. Name mode keeps the
* alphabetical lookup list. */
private _sortedRoster(rows: RosterRow[]): RosterRow[] {
const filtered = this._typeFilter === null ? rows : rows.filter((r) => r.battery_type === this._typeFilter);
if (this._rosterSort === "name") return filtered;
// Low rows rank far below everything and among themselves by LEVEL
// ascending (a 6 % battery before an 18 % one); the rest by days-until.
const rank = (r: RosterRow) => (r.status === "low" ? -1000 + (r.level ?? 101) / 101 : (r.days_until ?? Infinity));
return [...filtered].sort(
(a, b) => rank(a) - rank(b) || a.device_name.localeCompare(b.device_name),
);
}
/** The forecast as a date a person can plan with, not a day count.
* `days_until` comes from last-replaced + typical lifetime, so it is an
* estimate — the tilde in the template says so. Negative values (past the
* typical lifetime but not reported low yet) render as past dates, which
* is honest: the battery is living on borrowed time. */
private _predictedDate(daysUntil: number): string {
const when = new Date(Date.now() + daysUntil * 864e5);
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(when);
return this._fmtDate(Date.now() + daysUntil * 864e5);
}
private _shoppingLine(needs: Record<string, number>): string {
return Object.entries(needs)
.map(([type, qty]) => `${qty}× ${type}`)
.join(" · ");
private _fmtDate(epochMs: number): string {
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(new Date(epochMs));
}
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
* roster to the devices that need it — "which devices need those 4× AAA?"
* without scanning. Clicking the active chip clears the filter. */
private _shoppingLine(needs: Record<string, number>) {
return Object.entries(needs).map(
([type, qty]) => html`<button
class="bf-type-chip ${this._typeFilter === type ? "bf-type-chip-active" : ""}"
title=${t("battery_fleet_filter_type", this._lang)}
@click=${() => this._toggleTypeFilter(type)}
>
${qty}× ${type}
</button>`,
);
}
private _toggleTypeFilter(type: string): void {
this._typeFilter = this._typeFilter === type ? null : type;
if (this._typeFilter !== null) {
const details = this.shadowRoot?.querySelector<HTMLDetailsElement>("details.bf-roster");
if (details && !details.open) details.open = true; // fires toggle → history loads
}
}
/** One-click fix for a detected-but-unrecorded swap: record the DETECTED
* jump time in Battery Notes, so the forecast re-anchors on the real
* replacement instead of the dead battery's date. */
private async _recordJump(entityId: string, jump: { at: number; device_id: string }): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.callService("battery_notes", "set_battery_replaced", {
device_id: jump.device_id,
datetime_replaced: new Date(jump.at * 1000).toISOString(),
});
this._recorded = [...this._recorded, entityId];
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
}
/** 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. */
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>`;
}
render() {
@@ -199,10 +376,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
? html`<span class="bf-offline">${t("battery_fleet_offline", 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=${t("battery_fleet_mark_one", L)}
title=${b.rechargeable ? t("battery_fleet_mark_recharged", L) : t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
@@ -238,20 +421,57 @@ export class MaintenanceBatteryFleetSection extends LitElement {
: nothing}
${ov.all?.length
? html`
<details class="bf-roster">
<details class="bf-roster" @toggle=${this._loadHistory}>
<summary>${t("battery_fleet_all", L)} (${ov.all.length})</summary>
<div class="bf-roster-tools">
<button
class="bf-sort ${this._rosterSort === "urgency" ? "bf-sort-active" : ""}"
@click=${() => this._setSort("urgency")}
>
${t("battery_fleet_sort_urgency", L)}
</button>
<button
class="bf-sort ${this._rosterSort === "name" ? "bf-sort-active" : ""}"
@click=${() => this._setSort("name")}
>
${t("battery_fleet_sort_name", L)}
</button>
</div>
<div class="bf-rows">
${ov.all.map(
${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}">${t("battery_fleet_status_" + b.status, L)}</span>
<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._sparkline(b)}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : 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"
title=${t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""}"
title=${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))}
>~${this._predictedDate(b.days_until)}</span
>`
: nothing}
@@ -385,6 +605,102 @@ export class MaintenanceBatteryFleetSection extends LitElement {
color: var(--secondary-text-color);
font-size: 13px;
}
.bf-recharge {
color: var(--secondary-text-color);
display: inline-flex;
cursor: help;
}
.bf-recharge ha-icon {
--mdc-icon-size: 16px;
}
.bf-spark {
width: 110px;
height: 24px;
flex: 0 0 auto;
cursor: help;
}
/* On phones the row cannot fit name + chips + curve + bar + date: the
* decorations yield (the percentage still carries the number). */
@media (max-width: 640px) {
.bf-spark,
.bf-bar {
display: none;
}
}
.bf-spark-line {
fill: none;
stroke: var(--primary-color);
stroke-width: 1.5;
stroke-linejoin: round;
}
.bf-spark-proj {
stroke: var(--primary-color);
stroke-width: 1.2;
stroke-dasharray: 2 3;
opacity: 0.7;
}
.bf-spark-th {
stroke: var(--error-color, #f44336);
stroke-width: 1;
opacity: 0.35;
}
.bf-type-chip {
background: none;
border: 1px solid var(--divider-color);
border-radius: 10px;
padding: 1px 8px;
margin: 0 4px 2px 0;
font-size: 13px;
color: inherit;
cursor: pointer;
}
.bf-type-chip-active {
border-color: var(--primary-color);
color: var(--primary-color);
}
.bf-bar {
width: 30px;
height: 6px;
border-radius: 3px;
background: var(--divider-color);
overflow: hidden;
flex: 0 0 auto;
}
.bf-bar-fill {
display: block;
height: 100%;
border-radius: 3px;
}
.bf-bar-good {
background: var(--success-color, #4caf50);
}
.bf-bar-warn {
background: var(--warning-color, #ff9800);
}
.bf-bar-bad {
background: var(--error-color, #f44336);
}
.bf-jump ha-icon {
color: var(--warning-color, #ff9800);
}
.bf-roster-tools {
display: flex;
gap: 6px;
margin: 8px 0 2px;
}
.bf-sort {
background: none;
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 2px 10px;
font-size: 12px;
color: var(--secondary-text-color);
cursor: pointer;
}
.bf-sort-active {
border-color: var(--primary-color);
color: var(--primary-color);
}
.bf-level {
font-size: 12px;
color: var(--error-color, #f44336);
@@ -448,6 +764,12 @@ export class MaintenanceBatteryFleetSection extends LitElement {
color: var(--secondary-text-color);
white-space: nowrap;
}
/* Trend-based dates (discharge regression) get a dotted underline — the
tooltip carries source + confidence. */
.bf-predicted.bf-trend {
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.bf-total {
font-size: 12px;
color: var(--secondary-text-color);