Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts
T

482 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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;
}
interface RosterRow extends BatteryRow {
status: "low" | "soon" | "ok";
}
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<string, number>;
needs_soon: Record<string, number>;
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 = "";
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
}
connectedCallback(): void {
super.connectedCallback();
if (this.hass) this._load();
}
updated(changed: Map<string, unknown>): 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<void> {
this._loading = true;
this._error = "";
try {
this._ov = await this.hass.connection.sendMessagePromise<Overview>({
type: "maintenance_supporter/battery_fleet/overview",
});
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._loading = false;
}
}
private _markAll = async (): Promise<void> => {
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<void> => {
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<void> {
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<void> {
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;
}
}
/** 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);
}
private _shoppingLine(needs: Record<string, number>): string {
return Object.entries(needs)
.map(([type, qty]) => `${qty}× ${type}`)
.join(" · ");
}
render() {
const L = this._lang;
if (this._loading && this._ov === null) return html`<div class="bf-card"><div class="bf-loading">…</div></div>`;
const ov = this._ov;
if (!ov) {
return this._error ? html`<div class="bf-card"><div class="bf-error">${this._error}</div></div>` : nothing;
}
const lowCount = ov.low.length;
return html`
<div class="bf-card">
<div class="bf-head">
<ha-icon icon="mdi:battery-alert"></ha-icon>
<span class="bf-title">${t("battery_fleet_title", L)}</span>
<span class="bf-count ${lowCount ? "bad" : "ok"}">${lowCount}</span>
</div>
${this._error ? html`<div class="bf-error">${this._error}</div>` : nothing}
${ov.configured && ov.task_ok === false
? html`
<div class="bf-repair">
<span>${t("battery_fleet_trigger_lost", L)}</span>
<ha-button .disabled=${this._marking} @click=${this._repair}>
${t("battery_fleet_repair", L)}
</ha-button>
</div>
`
: nothing}
${lowCount === 0
? html`<div class="bf-empty">${t("battery_fleet_none_low", L)}</div>`
: html`
<div class="bf-shopping">
<span class="bf-label">${t("battery_fleet_buy_now", L)}</span>
<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>`
: nothing}
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
<button
class="bf-mark"
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>
<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>
`,
)}
</div>
<div class="bf-actions">
<ha-button .disabled=${this._marking} @click=${this._markAll}>
<ha-icon icon="mdi:battery-sync"></ha-icon> ${t("battery_fleet_mark_all", L)}
</ha-button>
</div>
`}
${ov.soon.length
? html`
<div class="bf-soon">
<span class="bf-label">${t("battery_fleet_soon", L)}</span>
<span class="bf-list">${this._shoppingLine(ov.needs_soon)}</span>
<div class="bf-soon-hint">${t("battery_fleet_soon_hint", L)}</div>
</div>
`
: nothing}
${ov.all?.length
? html`
<details class="bf-roster">
<summary>${t("battery_fleet_all", L)} (${ov.all.length})</summary>
<div class="bf-rows">
${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.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
${b.days_until != null
? html`<span
class="bf-predicted"
title=${t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
>~${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>
`,
)}
</div>
<div class="bf-roster-hint">${t("battery_fleet_all_hint", L)}</div>
</details>
`
: nothing}
${ov.excluded?.length
? html`
<div class="bf-excluded">
<span class="bf-label">${t("battery_fleet_excluded", L)}</span>
${ov.excluded.map(
(x) => html`
<span class="bf-excluded-chip">
${x.device_name}
<button
class="bf-mark"
title=${t("battery_fleet_include", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(x.entity_id, false)}
>
<ha-icon icon="mdi:eye-outline"></ha-icon>
</button>
</span>
`,
)}
</div>
`
: nothing}
<div class="bf-total">${t("battery_fleet_total", L).replace("{n}", String(ov.total))}</div>
</div>
`;
}
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-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;
}
.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);
}