Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -0,0 +1,322 @@
/** 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 Overview {
available: boolean;
configured: boolean;
task_ok?: boolean;
total: number;
low: BatteryRow[];
soon: BatteryRow[];
needs_now: Record<string, number>;
needs_soon: Record<string, number>;
types: 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",
});
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;
}
}
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>
</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}
<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);
}
.bf-total {
font-size: 12px;
color: var(--secondary-text-color);
}
`;
}
if (!customElements.get("maintenance-battery-fleet-section")) {
customElements.define("maintenance-battery-fleet-section", MaintenanceBatteryFleetSection);
}