224 files

This commit is contained in:
Home Assistant Version Control
2026-07-27 13:30:34 +00:00
parent b5723aa856
commit e36b8a1a22
224 changed files with 9967 additions and 2663 deletions
@@ -29,6 +29,7 @@ interface Overview {
needs_now: Record<string, number>;
needs_soon: Record<string, number>;
types: string[];
excluded?: { entity_id: string; device_name: string }[];
}
export class MaintenanceBatteryFleetSection extends LitElement {
@@ -111,6 +112,27 @@ export class MaintenanceBatteryFleetSection extends LitElement {
}
}
// 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;
}
}
private _shoppingLine(needs: Record<string, number>): string {
return Object.entries(needs)
.map(([type, qty]) => `${qty}× ${type}`)
@@ -170,6 +192,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
>
<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>
`,
)}
@@ -190,6 +220,28 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</div>
`
: 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>
`;
@@ -315,6 +367,27 @@ export class MaintenanceBatteryFleetSection extends LitElement {
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;
}
`;
}
@@ -7,6 +7,14 @@ import { cpSync, mkdirSync, rmSync } from "fs";
// this — every frontend change left orphans behind).
rmSync("../frontend/strategy/chunks", { recursive: true, force: true });
// Stamp the integration version into every bundle (helpers/bundle-version.ts
// reads it) — the panel compares it against the backend at runtime and shows
// a "reload" banner when a stale cached bundle is running (roadmap guard 2;
// the #106 follow-up class: HA's service worker serves stale-while-revalidate,
// so "I updated and restarted" routinely still runs old frontend code).
import { readFileSync } from "node:fs";
const manifestVersion = JSON.parse(readFileSync("../manifest.json", "utf-8")).version;
const common = {
bundle: true,
format: "esm",
@@ -14,6 +22,7 @@ const common = {
minify: true,
sourcemap: false,
external: [],
define: { __MS_BUNDLE_VERSION__: JSON.stringify(manifestVersion) },
};
// Panel
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Senzorový spouštěč této úlohy byl ztracen — nespustí se ani se automaticky nedokončí.",
"battery_fleet_repair": "Opravit",
"battery_fleet_exclude": "Vyloučit z flotily",
"battery_fleet_excluded": "Vyloučené",
"battery_fleet_include": "Znovu sledovat",
"battery_fleet_total": "Sledováno baterií: {n}",
"battery_fleet_setup_button": "Flotila baterií",
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny."
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
"update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.",
"update_reload": "Načíst znovu"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Opgavens sensorudløser er gået tabt — den udløses ikke og fuldføres ikke automatisk.",
"battery_fleet_repair": "Reparér",
"battery_fleet_exclude": "Udeluk fra flåden",
"battery_fleet_excluded": "Udelukket",
"battery_fleet_include": "Følg igen",
"battery_fleet_total": "{n} batterier overvåges",
"battery_fleet_setup_button": "Batteriflåde",
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier."
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
"update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.",
"update_reload": "Genindlæs"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Der Sensor-Trigger dieser Aufgabe ist verloren gegangen — sie wird nicht ausgelöst oder automatisch abgeschlossen.",
"battery_fleet_repair": "Reparieren",
"battery_fleet_exclude": "Aus der Flotte ausschließen",
"battery_fleet_excluded": "Ausgeschlossen",
"battery_fleet_include": "Wieder aufnehmen",
"battery_fleet_total": "{n} Batterien überwacht",
"battery_fleet_setup_button": "Batterie-Flotte",
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien."
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
"update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.",
"update_reload": "Neu laden"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "This task's sensor trigger was lost — it will not fire or auto-complete.",
"battery_fleet_repair": "Repair",
"battery_fleet_exclude": "Exclude from the fleet",
"battery_fleet_excluded": "Excluded",
"battery_fleet_include": "Track again",
"battery_fleet_total": "{n} batteries tracked",
"battery_fleet_setup_button": "Battery fleet",
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries."
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
"update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.",
"update_reload": "Reload"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "sin conexión",
"battery_fleet_trigger_lost": "El disparador de sensor de esta tarea se perdió — no se activará ni se completará automáticamente.",
"battery_fleet_repair": "Reparar",
"battery_fleet_exclude": "Excluir de la flota",
"battery_fleet_excluded": "Excluidas",
"battery_fleet_include": "Seguir de nuevo",
"battery_fleet_total": "{n} baterías monitorizadas",
"battery_fleet_setup_button": "Flota de baterías",
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas."
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
"update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.",
"update_reload": "Recargar"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Tehtävän anturiliipaisin on kadonnut — se ei laukea eikä valmistu automaattisesti.",
"battery_fleet_repair": "Korjaa",
"battery_fleet_exclude": "Sulje pois kannasta",
"battery_fleet_excluded": "Pois suljetut",
"battery_fleet_include": "Seuraa uudelleen",
"battery_fleet_total": "{n} paristoa seurannassa",
"battery_fleet_setup_button": "Paristokanta",
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia."
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
"update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.",
"update_reload": "Lataa uudelleen"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "hors ligne",
"battery_fleet_trigger_lost": "Le déclencheur de capteur de cette tâche a été perdu — elle ne se déclenchera pas et ne se terminera pas automatiquement.",
"battery_fleet_repair": "Réparer",
"battery_fleet_exclude": "Exclure du parc",
"battery_fleet_excluded": "Exclues",
"battery_fleet_include": "Suivre à nouveau",
"battery_fleet_total": "{n} piles suivies",
"battery_fleet_setup_button": "Parc de piles",
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles."
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
"update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.",
"update_reload": "Recharger"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "ऑफ़लाइन",
"battery_fleet_trigger_lost": "इस कार्य का सेंसर ट्रिगर खो गया है — यह सक्रिय नहीं होगा और स्वतः पूर्ण नहीं होगा।",
"battery_fleet_repair": "मरम्मत करें",
"battery_fleet_exclude": "फ्लीट से बाहर करें",
"battery_fleet_excluded": "बाहर किए गए",
"battery_fleet_include": "फिर से ट्रैक करें",
"battery_fleet_total": "{n} बैटरियाँ ट्रैक की गईं",
"battery_fleet_setup_button": "बैटरी फ्लीट",
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।"
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
"update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।",
"update_reload": "पुनः लोड करें"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Il trigger del sensore di questa attività è andato perso — non si attiverà né si completerà automaticamente.",
"battery_fleet_repair": "Ripara",
"battery_fleet_exclude": "Escludi dal parco",
"battery_fleet_excluded": "Escluse",
"battery_fleet_include": "Traccia di nuovo",
"battery_fleet_total": "{n} batterie monitorate",
"battery_fleet_setup_button": "Parco batterie",
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte."
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
"update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.",
"update_reload": "Ricarica"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "オフライン",
"battery_fleet_trigger_lost": "このタスクのセンサートリガーが失われました — 発動も自動完了もしません。",
"battery_fleet_repair": "修復",
"battery_fleet_exclude": "フリートから除外",
"battery_fleet_excluded": "除外済み",
"battery_fleet_include": "再び追跡",
"battery_fleet_total": "{n} 個の電池を監視",
"battery_fleet_setup_button": "電池フリート",
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。"
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
"update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。",
"update_reload": "再読み込み"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "frakoblet",
"battery_fleet_trigger_lost": "Oppgavens sensorutløser er tapt — den utløses ikke og fullføres ikke automatisk.",
"battery_fleet_repair": "Reparer",
"battery_fleet_exclude": "Utelukk fra flåten",
"battery_fleet_excluded": "Utelukket",
"battery_fleet_include": "Følg igjen",
"battery_fleet_total": "{n} batterier spores",
"battery_fleet_setup_button": "Batteriflåte",
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene."
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
"update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.",
"update_reload": "Last inn på nytt"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "De sensortrigger van deze taak is verloren gegaan — deze wordt niet geactiveerd of automatisch voltooid.",
"battery_fleet_repair": "Repareren",
"battery_fleet_exclude": "Uitsluiten van de vloot",
"battery_fleet_excluded": "Uitgesloten",
"battery_fleet_include": "Opnieuw volgen",
"battery_fleet_total": "{n} batterijen gevolgd",
"battery_fleet_setup_button": "Batterijvloot",
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen."
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
"update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.",
"update_reload": "Herladen"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Wyzwalacz czujnika tego zadania został utracony — nie uruchomi się ani nie zakończy automatycznie.",
"battery_fleet_repair": "Napraw",
"battery_fleet_exclude": "Wyklucz z floty",
"battery_fleet_excluded": "Wykluczone",
"battery_fleet_include": "Śledź ponownie",
"battery_fleet_total": "Śledzone baterie: {n}",
"battery_fleet_setup_button": "Flota baterii",
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie."
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
"update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.",
"update_reload": "Załaduj ponownie"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "O gatilho de sensor desta tarefa foi perdido — ela não será acionada nem concluída automaticamente.",
"battery_fleet_repair": "Reparar",
"battery_fleet_exclude": "Excluir da frota",
"battery_fleet_excluded": "Excluídas",
"battery_fleet_include": "Acompanhar novamente",
"battery_fleet_total": "{n} baterias monitorizadas",
"battery_fleet_setup_button": "Frota de baterias",
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas."
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
"update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
"update_reload": "Recarregar"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "не в сети",
"battery_fleet_trigger_lost": "Сенсорный триггер этой задачи утерян — она не сработает и не завершится автоматически.",
"battery_fleet_repair": "Восстановить",
"battery_fleet_exclude": "Исключить из парка",
"battery_fleet_excluded": "Исключены",
"battery_fleet_include": "Снова отслеживать",
"battery_fleet_total": "Отслеживается батарей: {n}",
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми."
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
"update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.",
"update_reload": "Перезагрузить"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Uppgiftens sensorutlösare har gått förlorad — den utlöses inte och slutförs inte automatiskt.",
"battery_fleet_repair": "Reparera",
"battery_fleet_exclude": "Uteslut från flottan",
"battery_fleet_excluded": "Uteslutna",
"battery_fleet_include": "Spåra igen",
"battery_fleet_total": "{n} batterier spåras",
"battery_fleet_setup_button": "Batteriflotta",
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier."
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
"update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.",
"update_reload": "Ladda om"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "не в мережі",
"battery_fleet_trigger_lost": "Сенсорний тригер цього завдання втрачено — воно не спрацює і не завершиться автоматично.",
"battery_fleet_repair": "Відновити",
"battery_fleet_exclude": "Виключити з парку",
"battery_fleet_excluded": "Виключені",
"battery_fleet_include": "Знову відстежувати",
"battery_fleet_total": "Відстежується батарей: {n}",
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма."
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
"update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.",
"update_reload": "Перезавантажити"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "离线",
"battery_fleet_trigger_lost": "此任务的传感器触发器已丢失——它不会触发,也不会自动完成。",
"battery_fleet_repair": "修复",
"battery_fleet_exclude": "从电池群中排除",
"battery_fleet_excluded": "已排除",
"battery_fleet_include": "重新跟踪",
"battery_fleet_total": "已跟踪 {n} 个电池",
"battery_fleet_setup_button": "电池群",
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。"
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
"update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。",
"update_reload": "重新加载"
}
@@ -19,6 +19,8 @@
* show_window_chips: true # default true; hide for embedded use
* show_user_filter: true # default true
* user_filter: "" # "" | "current_user" | "<uuid>"
* show_object_filter: true # default true; dropdown appears with 2+ objects
* object_filter: "" # "" | "<entry_id>" | "<object name>" (Discussion #83)
*/
import { LitElement, html, css, nothing } from "lit";
@@ -47,6 +49,9 @@ interface CalendarCardConfig {
show_window_chips?: boolean;
show_user_filter?: boolean;
user_filter?: string;
/** Discussion #83 — in-card object dropdown + optional preselect. */
show_object_filter?: boolean;
object_filter?: string;
/** v2.2.0 — when set, the card renders past N days from history instead
* of forward N days from next_due. Mutually exclusive with window_days. */
past_days?: PastDays;
@@ -62,6 +67,7 @@ export class MaintenanceCalendarCard extends LitElement {
@state() private _windowDays: WindowDays = 30;
@state() private _pastDays: PastDays | 0 = 0; // 0 = forward mode
@state() private _userFilter = "";
@state() private _objectFilter = "";
@state() private _unsub: (() => void) | null = null;
private _dataLoaded = false;
@@ -92,6 +98,9 @@ export class MaintenanceCalendarCard extends LitElement {
if (typeof config.user_filter === "string") {
this._userFilter = config.user_filter;
}
if (typeof config.object_filter === "string") {
this._objectFilter = config.object_filter;
}
}
getCardSize(): number {
@@ -224,12 +233,26 @@ export class MaintenanceCalendarCard extends LitElement {
: this._userFilter;
}
// Object filter (#83): the config value may be an entry_id or an object
// NAME (friendlier in hand-written YAML) — resolve against the loaded
// objects; an unknown value falls back to "all" instead of a blank card.
const showObjectFilter = this._config.show_object_filter !== false && this._objects.length > 1;
let filterEntryId: string | null = null;
if (this._objectFilter) {
const needle = this._objectFilter.toLowerCase();
const match = this._objects.find(
(o) => o.entry_id === this._objectFilter || o.object.name.toLowerCase() === needle,
);
filterEntryId = match?.entry_id ?? null;
}
const objects = filterEntryId ? this._objects.filter((o) => o.entry_id === filterEntryId) : this._objects;
const today = new Date();
today.setHours(0, 0, 0, 0);
const isPast = this._pastDays > 0;
const buckets = isPast
? buildPastBuckets(this._objects, today, this._pastDays, userFilter)
: buildCalendarBuckets(this._objects, today, this._windowDays, userFilter);
? buildPastBuckets(objects, today, this._pastDays, userFilter)
: buildCalendarBuckets(objects, today, this._windowDays, userFilter);
const todayIso = isoDateLocal(today);
// Year view AND past views collapse empty days — past windows can be
@@ -363,6 +386,22 @@ export class MaintenanceCalendarCard extends LitElement {
</select>
`
: nothing}
${showObjectFilter
? html`
<select class="cal-user-filter"
.value=${filterEntryId ?? ""}
@change=${(e: Event) => {
this._objectFilter = (e.target as HTMLSelectElement).value;
}}>
<option value="">${t("all_objects", L)}</option>
${[...this._objects]
.sort((a, b) => a.object.name.localeCompare(b.object.name))
.map(
(o) => html`<option value=${o.entry_id} ?selected=${o.entry_id === filterEntryId}>${o.object.name}</option>`,
)}
</select>
`
: nothing}
</div>
`
: nothing}
@@ -417,6 +456,9 @@ class MaintenanceCalendarCardEditor extends LitElement {
if (key === "show_user_filter" && value === true) {
delete (newConfig as unknown as Record<string, unknown>).show_user_filter;
}
if (key === "show_object_filter" && value === true) {
delete (newConfig as unknown as Record<string, unknown>).show_object_filter;
}
if (key === "title" && (!value || (typeof value === "string" && value.trim() === ""))) {
delete (newConfig as unknown as Record<string, unknown>).title;
}
@@ -513,6 +555,22 @@ class MaintenanceCalendarCardEditor extends LitElement {
</option>
</select>
</div>
<div class="row toggle">
<label for="objf">Show object filter dropdown</label>
<input
id="objf"
type="checkbox"
.checked=${this._config.show_object_filter !== false}
@change=${(e: Event) =>
this._valueChanged(
"show_object_filter",
(e.target as HTMLInputElement).checked,
)}
/>
</div>
<div class="hint">
Pre-select one object via YAML: object_filter: "&lt;object name&gt;".
</div>
</div>
`;
}
@@ -2,6 +2,7 @@
import { LitElement, html, nothing } from "lit";
import { isSafeHttpUrl } from "./helpers/url";
import { isStaleBundle } from "./helpers/bundle-version";
import { customElement, property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
import { LS_KEYS } from "./helpers/storage-keys";
@@ -142,6 +143,8 @@ export class MaintenanceSupporterPanel extends LitElement {
// Battery Fleet: offer one-click setup only when Battery Notes is present
// and the fleet isn't set up yet.
@state() private _batteryFleetSetupAvailable = false;
@state() private _staleBundle = false;
private _staleChecked = false;
private _toastTimer: ReturnType<typeof setTimeout> | null = null;
private _dismissedSuggestions = new Set<string>();
@@ -392,6 +395,21 @@ export class MaintenanceSupporterPanel extends LitElement {
.catch(() => {
this._batteryFleetSetupAvailable = false;
});
// Stale-bundle handshake (roadmap guard 2): compare the version esbuild
// stamped into this bundle with the backend's — a mismatch means the
// browser/service worker is still serving an OLD cached frontend, and no
// amount of HA restarts fixes that. Checked once per panel lifetime.
if (!this._staleChecked) {
this._staleChecked = true;
this.hass.connection
.sendMessagePromise<{ version: string }>({ type: "maintenance_supporter/version" })
.then((v) => {
this._staleBundle = isStaleBundle(v?.version);
})
.catch(() => {
/* backend too old to answer — no banner */
});
}
if (statsResult) this._stats = statsResult as StatisticsResponse;
if (budgetResult) this._budget = budgetResult as BudgetStatus;
if (groupsResult) this._groups = (groupsResult as { groups: Record<string, MaintenanceGroup> }).groups || {};
@@ -1827,6 +1845,17 @@ export class MaintenanceSupporterPanel extends LitElement {
render() {
return html`
<div class="panel">
${this._staleBundle
? html`
<div class="update-banner" role="status">
<ha-icon icon="mdi:update"></ha-icon>
<span>${t("update_banner", this._lang)}</span>
<ha-button appearance="plain" @click=${() => location.reload()}>
${t("update_reload", this._lang)}
</ha-button>
</div>
`
: nothing}
${this.narrow || this._view !== "overview" ? this._renderHeader() : nothing}
<div class="content">
${this._view === "overview"
@@ -735,6 +735,20 @@ export const panelStyles = css`
position: relative;
}
/* Stale-bundle handshake banner (roadmap guard 2) */
.update-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
background: color-mix(in srgb, var(--primary-color) 14%, var(--card-background-color, #fff));
border-bottom: 1px solid var(--divider-color);
font-size: 14px;
}
.update-banner span {
flex: 1;
}
.popup-menu {
position: absolute;
top: 100%;
@@ -1223,8 +1237,10 @@ export const panelStyles = css`
}
:host([narrow]) .tab {
padding: 12px 16px;
font-size: 14px;
/* Tight enough that the four tabs fit 412px in the longest languages —
Ukrainian ("Налаштування") overflowed at 12px 16px (overflow sweep). */
padding: 12px 8px;
font-size: 13px;
}
:host([narrow]) .task-header {
@@ -1435,7 +1451,7 @@ export const panelStyles = css`
.kpi-value { font-size: 14px; }
.kpi-value-large { font-size: 18px; }
.two-column-layout { grid-template-columns: 1fr; }
.tab { padding: 12px 16px; font-size: 14px; }
.tab { padding: 12px 8px; font-size: 13px; }
.task-header { flex-direction: column; align-items: flex-start; }
.task-header-actions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
.filter-bar { flex-wrap: wrap; }
@@ -52,8 +52,8 @@ if (!STORE.en) STORE.en = EN as Translations;
/** Languages available as runtime-loaded JSON. Keep in sync with locales/. */
const SUPPORTED_LANGS = new Set<string>([
"de", "nl", "fr", "it", "es", "pt", "ru", "uk", "pl", "cs", "sv", "zh",
"da", "fi", "nb", "ja", "hi",
"de", "nl", "fr", "it", "es", "pt", "pt-br", "ru", "uk", "pl", "cs", "sv", "zh",
"da", "fi", "nb", "ja", "hi", "hu", "ko", "tr",
]);
/** Served base for the runtime locale files (mirrors LOCALES_URL in const.py). */
@@ -61,9 +61,16 @@ const LOCALES_BASE = "/maintenance_supporter_locales";
const _localeInflight = _localeGlobals.inflight;
/** Normalize an HA language code to our 2-letter table key. */
/** Normalize an HA language code to our table key.
*
* Brazilian Portuguese is the one regional variant with its OWN table
* ("pt-br") — it must not collapse into European "pt". Mirrors
* normalize_language_code in helpers/i18n.py.
*/
function normLang(lang?: string): string {
return (lang || DEFAULT_LANG).substring(0, 2).toLowerCase();
const l = (lang || DEFAULT_LANG).toLowerCase();
if (l.startsWith("pt") && l.endsWith("br")) return "pt-br";
return l.substring(0, 2);
}
/** Get a localized string. Falls back to English, then to the key itself. */
@@ -117,15 +124,16 @@ export function setLocale(lang: string, table: Translations): void {
STORE[normLang(lang)] = table;
}
/** Map language prefix to BCP-47 locale for date formatting. */
/** Map language table key to BCP-47 locale for date formatting. */
function langToLocale(lang?: string): string {
const l = (lang || "en").substring(0, 2).toLowerCase();
const l = normLang(lang);
const map: Record<string, string> = {
de: "de-DE", en: "en-US", nl: "nl-NL", fr: "fr-FR", it: "it-IT", es: "es-ES", pt: "pt-PT", ru: "ru-RU", uk: "uk-UA", zh: "zh-CN",
da: "da-DK", fi: "fi-FI", nb: "nb-NO", ja: "ja-JP", hi: "hi-IN",
// pl/cs/sv were missing and silently fell back to en-US — Polish users
// saw MM/DD dates (caught by the live multi-language check, 2026-07-19).
pl: "pl-PL", cs: "cs-CZ", sv: "sv-SE",
"pt-br": "pt-BR", hu: "hu-HU", ko: "ko-KR", tr: "tr-TR",
};
return map[l] ?? "en-US";
}
@@ -224,8 +232,7 @@ export function formatInterval(
/** Localized weekday name (0=Mon … 6=Sun) via Intl — 2024-01-01 is a Monday.
* Single source for the dialog selectors AND formatRecurrence (DRY). */
export function weekdayName(i: number, lang?: string, style: "long" | "short" = "long"): string {
const locale = (lang || "en").substring(0, 2);
return new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(locale, { weekday: style, timeZone: "UTC" });
return new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(langToLocale(lang), { weekday: style, timeZone: "UTC" });
}
/** Recurrence shape carried on the WS payload (see types.TaskSchedule). */