/** Battery-fleet detail section, rendered inside the single "Replace low * batteries" task's overview tab (task.battery_fleet_task). Self-contained: * fetches the live aggregate over Battery Notes and offers the mark-replaced * action — the fleet's one surface, instead of 30-70 per-battery tasks. */ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale } from "../styles"; import { describeWsError } from "../ws-errors"; import type { HomeAssistant } from "../types"; interface BatteryRow { entity_id: string; device_name: string; battery_type: string; quantity: number; 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; task_ok?: boolean; total: number; low: BatteryRow[]; soon: BatteryRow[]; // Every tracked battery, whatever its state. Optional so an older backend // simply renders no roster instead of throwing. all?: RosterRow[]; needs_now: Record; needs_soon: Record; types: string[]; excluded?: { entity_id: string; device_name: string }[]; } export class MaintenanceBatteryFleetSection extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _ov: Overview | null = null; @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 { return this.hass?.language || "en"; } connectedCallback(): void { super.connectedCallback(); if (this.hass) this._load(); } updated(changed: Map): void { if (changed.has("hass") && this.hass && !this._localeReady) { this._localeReady = true; ensureLocale(this._lang).then(() => this.requestUpdate()); if (this._ov === null && !this._loading) this._load(); } } private async _load(): Promise { this._loading = true; this._error = ""; try { this._ov = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/battery_fleet/overview", }); } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._loading = false; } } private _markAll = async (): Promise => { await this._mark(undefined); }; // Re-runs the idempotent setup, which restores the fleet task's trigger // when a user edit wiped it (issue #106) or recreates a deleted task. private _repair = async (): Promise => { if (this._marking) return; this._marking = true; this._error = ""; try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/battery_fleet/setup", language: this._lang, }); await this._load(); } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._marking = false; } }; private async _mark(entityIds: string[] | undefined): Promise { if (this._marking) return; this._marking = true; this._error = ""; try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/battery_fleet/mark_replaced", ...(entityIds ? { entity_ids: entityIds } : {}), }); await this._load(); } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._marking = false; } } // Manual exclude/include (#107): a rechargeable device the heuristics // missed (or any battery the user never wants tracked) leaves the fleet; // the restore list below the section brings it back. private async _setExcluded(entityId: string, excluded: boolean): Promise { if (this._marking) return; this._marking = true; this._error = ""; try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/battery_fleet/set_excluded", entity_id: entityId, excluded, }); await this._load(); } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._marking = false; } } /** 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 => { 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` ${t("battery_fleet_sparkline_hint", this._lang)} ${projEnd !== null ? html`` : nothing} `; } 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 { return this._fmtDate(Date.now() + daysUntil * 864e5); } 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) { return Object.entries(needs).map( ([type, qty]) => html``, ); } private _toggleTypeFilter(type: string): void { this._typeFilter = this._typeFilter === type ? null : type; if (this._typeFilter !== null) { const details = this.shadowRoot?.querySelector("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 { 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``; } render() { const L = this._lang; if (this._loading && this._ov === null) return html`
`; const ov = this._ov; if (!ov) { return this._error ? html`
${this._error}
` : nothing; } const lowCount = ov.low.length; return html`
${t("battery_fleet_title", L)} ${lowCount}
${this._error ? html`
${this._error}
` : nothing} ${ov.configured && ov.task_ok === false ? html`
${t("battery_fleet_trigger_lost", L)} ${t("battery_fleet_repair", L)}
` : nothing} ${lowCount === 0 ? html`
${t("battery_fleet_none_low", L)}
` : html`
${t("battery_fleet_buy_now", L)} ${this._shoppingLine(ov.needs_now)}
${ov.low.map( (b) => html`
${b.device_name} ${b.available === false ? html`${t("battery_fleet_offline", L)}` : nothing} ${b.quantity}× ${b.battery_type} ${b.rechargeable ? html`` : nothing} ${this._levelBar(b)} ${b.level != null ? html`${b.level}%` : nothing}
`, )}
${t("battery_fleet_mark_all", L)}
`} ${ov.soon.length ? html`
${t("battery_fleet_soon", L)} ${this._shoppingLine(ov.needs_soon)}
${t("battery_fleet_soon_hint", L)}
` : nothing} ${ov.all?.length ? html`
${t("battery_fleet_all", L)} (${ov.all.length})
${this._sortedRoster(ov.all).map( (b) => html`
${b.device_name} ${t("battery_fleet_status_" + b.status, L)} ${b.quantity}× ${b.battery_type} ${b.rechargeable ? html`` : nothing} ${this._sparkline(b)} ${this._levelBar(b)} ${b.level != null ? html`${b.level}%` : nothing} ${(() => { const jump = this._history?.[b.entity_id]?.jump; if (!jump || this._recorded.includes(b.entity_id)) return nothing; return html``; })()} ${b.days_until != null ? html`~${this._predictedDate(b.days_until)}` : nothing}
`, )}
${t("battery_fleet_all_hint", L)}
` : nothing} ${ov.excluded?.length ? html`
${t("battery_fleet_excluded", L)} ${ov.excluded.map( (x) => html` ${x.device_name} `, )}
` : nothing}
${t("battery_fleet_total", L).replace("{n}", String(ov.total))}
`; } static styles = css` .bf-card { background: var(--card-background-color, #fff); border: 1px solid var(--divider-color); border-radius: 10px; padding: 14px 16px; margin: 12px 0; display: flex; flex-direction: column; gap: 10px; } .bf-head { display: flex; align-items: center; gap: 8px; font-weight: 500; } .bf-title { flex: 1; } .bf-count { font-size: 13px; padding: 1px 9px; border-radius: 10px; } .bf-count.bad { background: var(--error-color, #f44336); color: #fff; } .bf-count.ok { background: var(--success-color, #4caf50); color: #fff; } .bf-error { color: var(--error-color, #f44336); font-size: 13px; } .bf-repair { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--warning-color, #ff9800) 12%, transparent); font-size: 13px; } .bf-empty { color: var(--secondary-text-color); font-size: 14px; } .bf-shopping, .bf-soon { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } .bf-label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--secondary-text-color); } .bf-list { font-weight: 500; } .bf-rows { display: flex; flex-direction: column; gap: 4px; } .bf-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--divider-color); } .bf-dev { flex: 1; min-width: 0; } .bf-type { 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); } .bf-mark { background: transparent; border: none; color: var(--primary-color); cursor: pointer; padding: 4px; border-radius: 4px; display: inline-flex; } .bf-mark:hover { background: var(--secondary-background-color); } .bf-actions { display: flex; justify-content: flex-end; } .bf-soon { border-top: 1px solid var(--divider-color); padding-top: 8px; } .bf-soon-hint { width: 100%; font-size: 12px; color: var(--secondary-text-color); } /* The roster is a lookup list, not the headline — collapsed by default so the section still opens on what actually needs doing. */ .bf-roster > summary { cursor: pointer; font-size: 13px; color: var(--secondary-text-color); padding: 2px 0; } .bf-roster-hint { font-size: 12px; color: var(--secondary-text-color); padding-top: 6px; } .bf-status { font-size: 11px; padding: 1px 7px; border-radius: 9px; white-space: nowrap; background: var(--secondary-background-color, rgba(127, 127, 127, 0.15)); color: var(--secondary-text-color); } .bf-status.bf-low { background: var(--error-color, #f44336); color: #fff; } .bf-status.bf-soon { background: var(--warning-color, #ff9800); color: #fff; } .bf-predicted { font-size: 12px; 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); } .bf-exclude { color: var(--secondary-text-color); } .bf-excluded { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; border-top: 1px solid var(--divider-color); padding-top: 8px; } .bf-excluded-chip { display: inline-flex; align-items: center; gap: 2px; font-size: 12px; color: var(--secondary-text-color); background: var(--secondary-background-color); border-radius: 10px; padding: 1px 4px 1px 10px; } `; } if (!customElements.get("maintenance-battery-fleet-section")) { customElements.define("maintenance-battery-fleet-section", MaintenanceBatteryFleetSection); }