117 files

This commit is contained in:
Home Assistant Version Control
2026-08-08 10:38:07 +00:00
parent c31830deeb
commit ca54a47de7
119 changed files with 9737 additions and 623 deletions
@@ -1,10 +1,12 @@
/**
* Dashboard budget display: spent totals without a maximum (#104).
* Budget KPI tiles: spent totals without a maximum (#104, layout #125).
*
* With budget tracking enabled but no monthly/yearly maximum configured,
* the spent totals used to be invisible (a bar needs a denominator).
* Pins: spent-only lines render without a bar, a configured maximum still
* renders the classic bar, and the mixed case shows one of each.
* Since #125 the budgets render as KPI tiles in the stats strip; the #104
* semantics survive the move: a tile without a maximum shows the plain
* spent total and no bar, a configured maximum keeps its mini bar, and the
* mixed case shows one of each.
*/
import { expect } from "@open-wc/testing";
@@ -25,27 +27,37 @@ function handlers(budget: Record<string, unknown>) {
};
}
describe("dashboard budget: spent-only display (#104)", () => {
describe("budget KPI tiles: spent-only display (#104)", () => {
beforeEach(() => resetTaskSeq());
it("no maximum set: renders spent lines without bars", async () => {
it("no maximum set: tiles show spent totals without bars", async () => {
const { el } = await mountPanel([obj("e1", [task()])], handlers({
monthly_budget: 0, yearly_budget: 0, monthly_spent: 9, yearly_spent: 429.6,
}));
const items = sr(el).querySelectorAll(".budget-spent-only");
expect(items.length).to.equal(2);
expect(items[0].textContent).to.contain("9.00 €");
expect(items[1].textContent).to.contain("429.60 €");
expect(sr(el).querySelectorAll(".budget-bar").length).to.equal(0);
const tiles = sr(el).querySelectorAll(".budget-tile");
expect(tiles.length).to.equal(2);
expect(tiles[0].textContent).to.contain("9.00 €");
expect(tiles[1].textContent).to.contain("429.60 €");
expect(sr(el).querySelectorAll(".budget-tile-bar").length).to.equal(0);
});
it("mixed: monthly maximum renders a bar, yearly stays spent-only", async () => {
it("mixed: monthly maximum renders a mini bar, yearly stays spent-only", async () => {
const { el } = await mountPanel([obj("e1", [task()])], handlers({
monthly_budget: 150, yearly_budget: 0, monthly_spent: 9, yearly_spent: 429.6,
}));
expect(sr(el).querySelectorAll(".budget-bar").length).to.equal(1);
const spentOnly = sr(el).querySelectorAll(".budget-spent-only");
expect(spentOnly.length).to.equal(1);
expect(spentOnly[0].textContent).to.contain("429.60 €");
const tiles = sr(el).querySelectorAll(".budget-tile");
expect(tiles.length).to.equal(2);
expect(sr(el).querySelectorAll(".budget-tile-bar").length).to.equal(1);
expect(tiles[0].textContent).to.contain("9.00 / 150 €");
expect(tiles[1].textContent).to.contain("429.60 €");
});
it("tiles live INSIDE the stats strip (#125)", async () => {
const { el } = await mountPanel([obj("e1", [task()])], handlers({
monthly_budget: 150, yearly_budget: 1500, monthly_spent: 9, yearly_spent: 429.6,
}));
const strip = sr(el).querySelector(".stats-bar");
expect(strip, "stats strip rendered").to.exist;
expect(strip!.querySelectorAll(".budget-tile").length).to.equal(2);
});
});
@@ -17,4 +17,5 @@ export const LS_KEYS = {
groupBy: "maintenance_supporter_groupby",
objectView: "maintenance_supporter_object_view",
objectsCache: "msp-objects-cache",
gettingStartedDismissed: "msp-gs-dismissed",
} as const;
@@ -835,5 +835,11 @@
"update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.",
"update_reload": "Načíst znovu",
"battery_fleet_forecast_overdue": "Předpovězené datum uplynulo — baterie stále hlásí dobrý stav. Pokud jste ji vyměnili, zaznamenejte výměnu; jinak byla předpověď mylná.",
"cost_from_parts": "Použít ≈ {amount} z dílů"
"cost_from_parts": "Použít ≈ {amount} z dílů",
"new_menu": "Nový",
"dismiss": "Skrýt",
"gs_label": "Začínáme — tyto tipy zmizí, jak vaše nastavení poroste",
"gs_setups_chip": "Navrhovaná nastavení: nalezeno {n} zařízení s předpřipravenými spouštěči",
"gs_adopt_chip": "{n} problémových senzorů se může stát údržbovými úkoly",
"gs_fleet_chip": "Jedno kliknutí nastaví flotilu baterií"
}
@@ -835,5 +835,11 @@
"update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.",
"update_reload": "Genindlæs",
"battery_fleet_forecast_overdue": "Forudsagt dato er overskredet — batteriet melder stadig god tilstand. Hvis du har skiftet det, registrér udskiftningen; ellers ramte prognosen forbi.",
"cost_from_parts": "Brug ≈ {amount} fra dele"
"cost_from_parts": "Brug ≈ {amount} fra dele",
"new_menu": "Ny",
"dismiss": "Afvis",
"gs_label": "Kom godt i gang — disse tips forsvinder, efterhånden som opsætningen vokser",
"gs_setups_chip": "Foreslåede opsætninger fandt {n} enheder med forudindstillede udløsere",
"gs_adopt_chip": "{n} problemsensorer kan blive vedligeholdelsesopgaver",
"gs_fleet_chip": "Ét klik opsætter batteriflåden"
}
@@ -835,5 +835,11 @@
"update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.",
"update_reload": "Neu laden",
"battery_fleet_forecast_overdue": "Prognosedatum überschritten — die Batterie meldet sich weiterhin gesund. Falls du sie gewechselt hast, trage den Wechsel nach; andernfalls lag die Prognose daneben.",
"cost_from_parts": "≈ {amount} aus Teilen übernehmen"
"cost_from_parts": "≈ {amount} aus Teilen übernehmen",
"new_menu": "Neu",
"dismiss": "Ausblenden",
"gs_label": "Erste Schritte — diese Hinweise verschwinden, wenn dein Setup wächst",
"gs_setups_chip": "Vorgeschlagene Setups: {n} Geräte mit vorverdrahteten Auslösern gefunden",
"gs_adopt_chip": "{n} Problem-Sensoren können zu Wartungsaufgaben werden",
"gs_fleet_chip": "Ein Klick richtet die Batterieflotte ein"
}
@@ -835,5 +835,11 @@
"update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.",
"update_reload": "Reload",
"battery_fleet_forecast_overdue": "Predicted date passed — the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",
"cost_from_parts": "Use ≈ {amount} from parts"
"cost_from_parts": "Use ≈ {amount} from parts",
"new_menu": "New",
"dismiss": "Dismiss",
"gs_label": "Getting started — these hints retire as your setup grows",
"gs_setups_chip": "Suggested setups found {n} devices with pre-wired triggers",
"gs_adopt_chip": "{n} problem sensors can become maintenance tasks",
"gs_fleet_chip": "One click sets up the battery fleet"
}
@@ -835,5 +835,11 @@
"update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.",
"update_reload": "Recargar",
"battery_fleet_forecast_overdue": "Fecha prevista superada: la batería sigue informando buen estado. Si la cambiaste, registra el reemplazo; si no, la previsión falló.",
"cost_from_parts": "Usar ≈ {amount} de las piezas"
"cost_from_parts": "Usar ≈ {amount} de las piezas",
"new_menu": "Nuevo",
"dismiss": "Descartar",
"gs_label": "Primeros pasos: estas sugerencias desaparecen a medida que crece tu configuración",
"gs_setups_chip": "Configuraciones sugeridas: {n} dispositivos con disparadores preconfigurados",
"gs_adopt_chip": "{n} sensores de problemas pueden convertirse en tareas de mantenimiento",
"gs_fleet_chip": "Un clic configura la flota de baterías"
}
@@ -835,5 +835,11 @@
"update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.",
"update_reload": "Lataa uudelleen",
"battery_fleet_forecast_overdue": "Ennustettu päivä on ohitettu — akku ilmoittaa yhä hyvästä kunnosta. Jos vaihdoit sen, kirjaa vaihto; muuten ennuste oli pielessä.",
"cost_from_parts": "Käytä ≈ {amount} osista"
"cost_from_parts": "Käytä ≈ {amount} osista",
"new_menu": "Uusi",
"dismiss": "Hylkää",
"gs_label": "Aloitus — nämä vihjeet poistuvat asennuksen kasvaessa",
"gs_setups_chip": "Ehdotetut asetukset löysivät {n} laitetta valmiilla laukaisimilla",
"gs_adopt_chip": "{n} ongelma-anturia voi muuttua huoltotehtäviksi",
"gs_fleet_chip": "Yksi napsautus määrittää akkukannan"
}
@@ -835,5 +835,11 @@
"update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.",
"update_reload": "Recharger",
"battery_fleet_forecast_overdue": "Date prévue dépassée — la batterie se signale toujours en bon état. Si vous l'avez remplacée, enregistrez le remplacement ; sinon la prévision était erronée.",
"cost_from_parts": "Reprendre ≈ {amount} des pièces"
"cost_from_parts": "Reprendre ≈ {amount} des pièces",
"new_menu": "Nouveau",
"dismiss": "Ignorer",
"gs_label": "Premiers pas — ces conseils disparaissent à mesure que votre configuration grandit",
"gs_setups_chip": "Configurations suggérées : {n} appareils avec déclencheurs pré-câblés trouvés",
"gs_adopt_chip": "{n} capteurs de problème peuvent devenir des tâches de maintenance",
"gs_fleet_chip": "Un clic configure le parc de piles"
}
@@ -835,5 +835,11 @@
"update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।",
"update_reload": "पुनः लोड करें",
"battery_fleet_forecast_overdue": "अनुमानित तिथि बीत गई — बैटरी अब भी अच्छी स्थिति बता रही है। यदि आपने इसे बदला है, तो बदलाव दर्ज करें; अन्यथा पूर्वानुमान गलत था।",
"cost_from_parts": "पुर्ज़ों से ≈ {amount} लें"
"cost_from_parts": "पुर्ज़ों से ≈ {amount} लें",
"new_menu": "नया",
"dismiss": "हटाएँ",
"gs_label": "शुरुआत — सेटअप बढ़ने पर ये संकेत हट जाते हैं",
"gs_setups_chip": "सुझाए गए सेटअप: पूर्व-निर्धारित ट्रिगर वाले {n} उपकरण मिले",
"gs_adopt_chip": "{n} समस्या सेंसर रखरखाव कार्य बन सकते हैं",
"gs_fleet_chip": "एक क्लिक में बैटरी बेड़ा सेट करें"
}
@@ -835,5 +835,11 @@
"update_banner": "A Maintenance Supporter újabb verziója érhető el a szerveren — töltse újra az oldalt a panel frissítéséhez.",
"update_reload": "Újratöltés",
"battery_fleet_forecast_overdue": "Az előrejelzett dátum elmúlt — az elem továbbra is jó állapotot jelez. Ha kicserélted, rögzítsd a cserét; különben az előrejelzés tévedett.",
"cost_from_parts": "≈ {amount} átvétele az alkatrészekből"
"cost_from_parts": "≈ {amount} átvétele az alkatrészekből",
"new_menu": "Új",
"dismiss": "Elrejtés",
"gs_label": "Első lépések — a tippek eltűnnek, ahogy a beállítás bővül",
"gs_setups_chip": "Javasolt beállítások: {n} eszköz előre bekötött triggerekkel",
"gs_adopt_chip": "{n} problémaérzékelő karbantartási feladattá válhat",
"gs_fleet_chip": "Egy kattintás beállítja az elemflottát"
}
@@ -835,5 +835,11 @@
"update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.",
"update_reload": "Ricarica",
"battery_fleet_forecast_overdue": "Data prevista superata — la batteria risulta ancora in buono stato. Se l'hai sostituita, registra la sostituzione; altrimenti la previsione era errata.",
"cost_from_parts": "Usa ≈ {amount} dai ricambi"
"cost_from_parts": "Usa ≈ {amount} dai ricambi",
"new_menu": "Nuovo",
"dismiss": "Ignora",
"gs_label": "Primi passi — questi suggerimenti scompaiono man mano che la configurazione cresce",
"gs_setups_chip": "Configurazioni suggerite: trovati {n} dispositivi con trigger preconfigurati",
"gs_adopt_chip": "{n} sensori di problemi possono diventare attività di manutenzione",
"gs_fleet_chip": "Un clic configura la flotta di batterie"
}
@@ -835,5 +835,11 @@
"update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。",
"update_reload": "再読み込み",
"battery_fleet_forecast_overdue": "予測日を過ぎましたが、電池はまだ正常と報告しています。交換済みなら交換を記録してください。そうでなければ予測が外れています。",
"cost_from_parts": "部品から ≈ {amount} を適用"
"cost_from_parts": "部品から ≈ {amount} を適用",
"new_menu": "新規",
"dismiss": "閉じる",
"gs_label": "はじめに — セットアップが進むとこれらのヒントは消えます",
"gs_setups_chip": "推奨セットアップ:トリガー設定済みのデバイスを{n}台検出",
"gs_adopt_chip": "{n}個の問題センサーをメンテナンスタスクにできます",
"gs_fleet_chip": "ワンクリックで電池フリートを設定"
}
@@ -835,5 +835,11 @@
"update_banner": "서버에 Maintenance Supporter의 새 버전이 있습니다 — 패널을 업데이트하려면 새로 고침하세요.",
"update_reload": "새로 고침",
"battery_fleet_forecast_overdue": "예측 날짜가 지났지만 배터리는 여전히 정상으로 보고됩니다. 교체했다면 교체를 기록하세요. 아니라면 예측이 빗나간 것입니다.",
"cost_from_parts": "부품에서 ≈ {amount} 적용"
"cost_from_parts": "부품에서 ≈ {amount} 적용",
"new_menu": "새로 만들기",
"dismiss": "닫기",
"gs_label": "시작하기 — 설정이 늘어나면 이 힌트는 사라집니다",
"gs_setups_chip": "추천 설정: 트리거가 준비된 기기 {n}대 발견",
"gs_adopt_chip": "문제 센서 {n}개를 유지보수 작업으로 만들 수 있습니다",
"gs_fleet_chip": "클릭 한 번으로 배터리 플릿 설정"
}
@@ -835,5 +835,11 @@
"update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.",
"update_reload": "Last inn på nytt",
"battery_fleet_forecast_overdue": "Forutsagt dato er passert — batteriet melder fortsatt god tilstand. Hvis du byttet det, registrer byttet; ellers bommet prognosen.",
"cost_from_parts": "Bruk ≈ {amount} fra deler"
"cost_from_parts": "Bruk ≈ {amount} fra deler",
"new_menu": "Ny",
"dismiss": "Avvis",
"gs_label": "Kom i gang — tipsene forsvinner etter hvert som oppsettet vokser",
"gs_setups_chip": "Foreslåtte oppsett fant {n} enheter med ferdigkoblede utløsere",
"gs_adopt_chip": "{n} problemsensorer kan bli vedlikeholdsoppgaver",
"gs_fleet_chip": "Ett klikk setter opp batteriflåten"
}
@@ -835,5 +835,11 @@
"update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.",
"update_reload": "Herladen",
"battery_fleet_forecast_overdue": "Voorspelde datum verstreken — de batterij meldt zich nog steeds gezond. Heb je hem vervangen, registreer dan de vervanging; anders zat de voorspelling ernaast.",
"cost_from_parts": "≈ {amount} uit onderdelen overnemen"
"cost_from_parts": "≈ {amount} uit onderdelen overnemen",
"new_menu": "Nieuw",
"dismiss": "Verbergen",
"gs_label": "Aan de slag — deze tips verdwijnen naarmate je installatie groeit",
"gs_setups_chip": "Voorgestelde setups: {n} apparaten met vooraf ingestelde triggers gevonden",
"gs_adopt_chip": "{n} probleemsensoren kunnen onderhoudstaken worden",
"gs_fleet_chip": "Eén klik stelt het batterijpark in"
}
@@ -835,5 +835,11 @@
"update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.",
"update_reload": "Załaduj ponownie",
"battery_fleet_forecast_overdue": "Przewidywana data minęła — bateria nadal zgłasza dobry stan. Jeśli ją wymieniono, zapisz wymianę; w przeciwnym razie prognoza była błędna.",
"cost_from_parts": "Użyj ≈ {amount} z części"
"cost_from_parts": "Użyj ≈ {amount} z części",
"new_menu": "Nowy",
"dismiss": "Odrzuć",
"gs_label": "Pierwsze kroki — te wskazówki znikają wraz z rozwojem konfiguracji",
"gs_setups_chip": "Sugerowane konfiguracje: znaleziono {n} urządzeń z gotowymi wyzwalaczami",
"gs_adopt_chip": "{n} czujników problemów może stać się zadaniami konserwacji",
"gs_fleet_chip": "Jedno kliknięcie konfiguruje flotę baterii"
}
@@ -835,5 +835,11 @@
"update_banner": "Há uma versão mais nova do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
"update_reload": "Recarregar",
"battery_fleet_forecast_overdue": "Data prevista ultrapassada — a bateria ainda reporta bom estado. Se você a trocou, registre a troca; caso contrário, a previsão errou.",
"cost_from_parts": "Usar ≈ {amount} das peças"
"cost_from_parts": "Usar ≈ {amount} das peças",
"new_menu": "Novo",
"dismiss": "Dispensar",
"gs_label": "Primeiros passos — estas dicas somem conforme a configuração cresce",
"gs_setups_chip": "Configurações sugeridas: {n} dispositivos com gatilhos pré-configurados",
"gs_adopt_chip": "{n} sensores de problema podem virar tarefas de manutenção",
"gs_fleet_chip": "Um clique configura a frota de pilhas"
}
@@ -835,5 +835,11 @@
"update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
"update_reload": "Recarregar",
"battery_fleet_forecast_overdue": "Data prevista ultrapassada — a bateria continua a reportar bom estado. Se a substituiu, registe a substituição; caso contrário, a previsão falhou.",
"cost_from_parts": "Usar ≈ {amount} das peças"
"cost_from_parts": "Usar ≈ {amount} das peças",
"new_menu": "Novo",
"dismiss": "Dispensar",
"gs_label": "Primeiros passos — estas dicas desaparecem à medida que a configuração cresce",
"gs_setups_chip": "Configurações sugeridas: {n} dispositivos com gatilhos pré-configurados",
"gs_adopt_chip": "{n} sensores de problema podem tornar-se tarefas de manutenção",
"gs_fleet_chip": "Um clique configura a frota de pilhas"
}
@@ -835,5 +835,11 @@
"update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.",
"update_reload": "Перезагрузить",
"battery_fleet_forecast_overdue": "Прогнозируемая дата прошла — батарея по-прежнему сообщает о хорошем состоянии. Если вы её заменили, зафиксируйте замену; иначе прогноз оказался неверным.",
"cost_from_parts": "Взять ≈ {amount} из запчастей"
"cost_from_parts": "Взять ≈ {amount} из запчастей",
"new_menu": "Создать",
"dismiss": "Скрыть",
"gs_label": "Первые шаги — эти подсказки исчезнут по мере роста настройки",
"gs_setups_chip": "Рекомендуемые настройки: найдено {n} устройств с готовыми триггерами",
"gs_adopt_chip": "{n} датчиков проблем могут стать задачами обслуживания",
"gs_fleet_chip": "Один клик настроит парк батарей"
}
@@ -835,5 +835,11 @@
"update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.",
"update_reload": "Ladda om",
"battery_fleet_forecast_overdue": "Förutsagt datum har passerat — batteriet rapporterar fortfarande god status. Om du bytte det, registrera bytet; annars slog prognosen fel.",
"cost_from_parts": "Använd ≈ {amount} från delar"
"cost_from_parts": "Använd ≈ {amount} från delar",
"new_menu": "Ny",
"dismiss": "Avfärda",
"gs_label": "Kom igång — tipsen försvinner när din installation växer",
"gs_setups_chip": "Föreslagna uppsättningar hittade {n} enheter med förkopplade utlösare",
"gs_adopt_chip": "{n} problemsensorer kan bli underhållsuppgifter",
"gs_fleet_chip": "Ett klick konfigurerar batteriflottan"
}
@@ -835,5 +835,11 @@
"update_banner": "Sunucuda Maintenance Supporter'ın daha yeni bir sürümü var — paneli güncellemek için sayfayı yeniden yükleyin.",
"update_reload": "Yeniden yükle",
"battery_fleet_forecast_overdue": "Öngörülen tarih geçti — pil hâlâ sağlıklı görünüyor. Değiştirdiyseniz değişimi kaydedin; aksi halde tahmin yanılmış demektir.",
"cost_from_parts": "Parçalardan ≈ {amount} kullan"
"cost_from_parts": "Parçalardan ≈ {amount} kullan",
"new_menu": "Yeni",
"dismiss": "Kapat",
"gs_label": "Başlarken — kurulumunuz büyüdükçe bu ipuçları kaybolur",
"gs_setups_chip": "Önerilen kurulumlar {n} cihaz buldu (hazır tetikleyicilerle)",
"gs_adopt_chip": "{n} sorun sensörü bakım görevine dönüşebilir",
"gs_fleet_chip": "Tek tıkla pil filosunu kurun"
}
@@ -835,5 +835,11 @@
"update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.",
"update_reload": "Перезавантажити",
"battery_fleet_forecast_overdue": "Прогнозована дата минула — батарея й далі повідомляє про добрий стан. Якщо ви її замінили, зафіксуйте заміну; інакше прогноз не справдився.",
"cost_from_parts": "Узяти ≈ {amount} із запчастин"
"cost_from_parts": "Узяти ≈ {amount} із запчастин",
"new_menu": "Створити",
"dismiss": "Приховати",
"gs_label": "Перші кроки — ці підказки зникнуть у міру зростання налаштування",
"gs_setups_chip": "Рекомендовані налаштування: знайдено {n} пристроїв із готовими тригерами",
"gs_adopt_chip": "{n} датчиків проблем можуть стати завданнями обслуговування",
"gs_fleet_chip": "Один клік налаштує парк батарей"
}
@@ -835,5 +835,11 @@
"update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。",
"update_reload": "重新加载",
"battery_fleet_forecast_overdue": "预测日期已过——电池仍报告状态良好。如果您已更换电池,请记录更换;否则说明预测有误。",
"cost_from_parts": "采用配件合计 ≈ {amount}"
"cost_from_parts": "采用配件合计 ≈ {amount}",
"new_menu": "新建",
"dismiss": "忽略",
"gs_label": "入门提示——随着配置的完善,这些提示会自动消失",
"gs_setups_chip": "推荐配置发现 {n} 台设备(含预设触发器)",
"gs_adopt_chip": "{n} 个问题传感器可转换为维护任务",
"gs_fleet_chip": "一键设置电池车队"
}
@@ -141,7 +141,12 @@ export class MaintenanceSupporterPanel extends LitElement {
// occasional-use — collapsed behind two toggle buttons so the task list
// starts above the fold on phones. Desktop renders them inline as before.
@state() private _filtersOpen = false;
@state() private _actionsMenuOpen = false;
// #125: the one "New" menu replacing the six action buttons, plus the
// getting-started chip discovery counts (fetched once, young installs only).
@state() private _newMenuOpen = false;
@state() private _gsSetupsCount = 0;
@state() private _gsAdoptCount = 0;
private _gsLoaded = false;
// Battery Fleet: offer one-click setup only when Battery Notes is present
// and the fleet isn't set up yet.
@state() private _batteryFleetSetupAvailable = false;
@@ -434,6 +439,10 @@ export class MaintenanceSupporterPanel extends LitElement {
if (objResult) {
this._objects = hydrateObjects((objResult as { objects: MaintenanceObjectResponse[] }).objects);
writeObjectsCache(this._objects, (statsResult as StatisticsResponse | null) ?? this._stats ?? null);
// #125: getting-started discovery runs AFTER the objects are known —
// judging youth on the pre-load empty list would bill every mature
// install for two discovery calls.
this._maybeLoadGettingStarted();
}
// A data refresh means the open task's history may have grown (complete,
// history edit) — the truncated list payload can't tell, so refetch.
@@ -2137,6 +2146,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<span class="stat-value" style="color: #ff5722">${s.triggered}</span>
<span class="stat-label">${t("triggered", L)}</span>
</div>
${this._features.budget ? this._renderBudgetTiles() : nothing}
</div>
`
: nothing}
@@ -2296,7 +2306,6 @@ export class MaintenanceSupporterPanel extends LitElement {
(this._activeViewId ? 1 : 0);
return html`
${this._features.budget ? this._renderBudgetBar() : nothing}
${this.narrow ? html`
<div class="mobile-controls">
@@ -2307,15 +2316,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-icon icon="mdi:filter-variant"></ha-icon>
${t("filter_label", L)}${activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}
</ha-button>
${!isOperator ? html`
<ha-button
class="mobile-toggle ${this._actionsMenuOpen ? "active" : ""}"
@click=${() => { this._actionsMenuOpen = !this._actionsMenuOpen; }}
>
<ha-icon icon="mdi:plus"></ha-icon>
${t("add", L)}
</ha-button>
` : nothing}
${!isOperator ? this._renderNewMenu(L) : nothing}
</div>
` : nothing}
@@ -2439,39 +2440,10 @@ export class MaintenanceSupporterPanel extends LitElement {
${this._bulkMode ? t("cancel", L) : t("bulk_select", L)}
</ha-button>
` : nothing}
${!isOperator && !this.narrow ? this._renderNewMenu(L) : nothing}
</div>
${!isOperator ? html`
<div
class="actions-bar ${this.narrow && !this._actionsMenuOpen ? "collapsed" : ""}"
@click=${() => { if (this.narrow) this._actionsMenuOpen = false; }}
>
<ha-button
@click=${() => this._ui<MaintenanceTaskDialog>("maintenance-task-dialog").then((d) => d?.openCreate("", this._objects))}
>
${t("new_task", L)}
</ha-button>
<ha-button
@click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}
>
${t("new_object", L)}
</ha-button>
<ha-button @click=${() => this._openTemplateGallery()}>
<ha-icon icon="mdi:view-grid-plus-outline"></ha-icon> ${t("templates_from", L)}
</ha-button>
<ha-button @click=${() => this._openAdoptProblemSensors()}>
<ha-icon icon="mdi:alert-circle-check-outline"></ha-icon> ${t("adopt_problem_button", L)}
</ha-button>
<ha-button @click=${() => this._openSuggestedSetups()}>
<ha-icon icon="mdi:auto-fix"></ha-icon> ${t("setups_button", L)}
</ha-button>
${this._batteryFleetSetupAvailable ? html`
<ha-button @click=${() => this._setupBatteryFleet()}>
<ha-icon icon="mdi:battery-sync"></ha-icon> ${t("battery_fleet_setup_button", L)}
</ha-button>
` : nothing}
</div>
` : nothing}
${!isOperator ? this._renderGettingStartedChips(L) : nothing}
${rows.length === 0
? html`
@@ -3016,49 +2988,39 @@ export class MaintenanceSupporterPanel extends LitElement {
}
}
private _renderBudgetBar() {
/** Budget as KPI tiles in the stats strip (#125) — replaces the old
* full-width budget row. Same semantics as before, incl. #104: a budget
* WITHOUT a maximum still shows its plain spent total (no denominator,
* no bar), so "what did I spend" always has an answer. Being part of the
* strip, the tiles now show on every tab — budget is a global figure
* like the overdue count. */
private _renderBudgetTiles() {
const b = this._budget;
if (!b) return nothing;
const L = this._lang;
const cs = b.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
const bars: { label: string; spent: number; budget: number }[] = [];
if (b.monthly_budget > 0) bars.push({ label: t("budget_monthly", L), spent: b.monthly_spent, budget: b.monthly_budget });
if (b.yearly_budget > 0) bars.push({ label: t("budget_yearly", L), spent: b.yearly_spent, budget: b.yearly_budget });
// #104: budget tracking enabled WITHOUT a maximum — the spent totals were
// invisible (a bar needs a denominator). Show plain spent lines instead,
// so "what did I spend" always has a dashboard answer.
const spentOnly: { label: string; spent: number }[] = [];
if (!(b.monthly_budget > 0)) spentOnly.push({ label: t("budget_monthly", L), spent: b.monthly_spent || 0 });
if (!(b.yearly_budget > 0)) spentOnly.push({ label: t("budget_yearly", L), spent: b.yearly_spent || 0 });
const tile = (label: string, spent: number, budget: number | null) => {
if (budget !== null) {
const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
const color = pct >= 100 ? "var(--error-color, #f44336)" : pct >= b.alert_threshold_pct ? "var(--warning-color, #ff9800)" : "var(--success-color, #4caf50)";
return html`
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} / ${budget.toFixed(2)} ${cs}">
<span class="stat-value budget-tile-value">${spent.toFixed(2)} / ${budget.toFixed(0)} ${cs}</span>
<div class="budget-tile-bar"><div style="width:${pct}%; background:${color}"></div></div>
<span class="stat-label">${label}</span>
</div>
`;
}
return html`
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} ${cs}">
<span class="stat-value budget-tile-value">${spent.toFixed(2)} ${cs}</span>
<span class="stat-label">${label}</span>
</div>
`;
};
return html`
<div class="budget-bars">
${bars.map((bar) => {
const pct = Math.min(100, Math.max(0, (bar.spent / bar.budget) * 100));
const color = pct >= 100 ? "var(--error-color, #f44336)" : pct >= b.alert_threshold_pct ? "var(--warning-color, #ff9800)" : "var(--success-color, #4caf50)";
return html`
<div class="budget-item">
<div class="budget-label">
<span>${bar.label}</span>
<span>${bar.spent.toFixed(2)} / ${bar.budget.toFixed(2)} ${cs}</span>
</div>
<div class="budget-bar">
<div class="budget-bar-fill" style="width:${pct}%; background:${color}"></div>
</div>
</div>
`;
})}
${spentOnly.map(
(s) => html`
<div class="budget-item budget-spent-only">
<div class="budget-label">
<span>${s.label}</span>
<span>${s.spent.toFixed(2)} ${cs}</span>
</div>
</div>
`,
)}
</div>
${tile(t("budget_monthly", L), b.monthly_spent || 0, b.monthly_budget > 0 ? b.monthly_budget : null)}
${tile(t("budget_yearly", L), b.yearly_spent || 0, b.yearly_budget > 0 ? b.yearly_budget : null)}
`;
}
@@ -3314,6 +3276,150 @@ export class MaintenanceSupporterPanel extends LitElement {
* Render compact task header with status chip and action buttons.
*/
// Object-detail ⋮ menu — same click-away pattern as the task-detail menu.
/** #125: the one "New ▾" menu replacing the six action buttons. Same
* popup pattern as the object/task menus; on narrow it lives in the
* mobile-controls row and replaced the old collapsed actions bar. */
private _renderNewMenu(L: string) {
return html`
<div class="new-menu-wrapper">
<ha-button appearance="filled" class="new-menu-button"
@click=${(e: Event) => { e.stopPropagation(); this._toggleNewMenu(); }}>
<ha-icon icon="mdi:plus"></ha-icon> ${t("new_menu", L)}
<ha-icon icon="mdi:menu-down"></ha-icon>
</ha-button>
${this._newMenuOpen ? html`
<div class="popup-menu new-menu-popup" @click=${(e: Event) => e.stopPropagation()}>
<div class="popup-menu-item" @click=${() => { this._closeNewMenu(); void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog").then((d) => d?.openCreate("", this._objects)); }}>
<ha-icon icon="mdi:clipboard-plus-outline"></ha-icon> ${t("new_task", L)}
</div>
<div class="popup-menu-item" @click=${() => { this._closeNewMenu(); void this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate()); }}>
<ha-icon icon="mdi:package-variant-closed-plus"></ha-icon> ${t("new_object", L).replace(/^\+\s*/, "")}
</div>
<div class="popup-menu-item" @click=${() => { this._closeNewMenu(); this._openTemplateGallery(); }}>
<ha-icon icon="mdi:view-grid-plus-outline"></ha-icon> ${t("templates_from", L)}
</div>
<div class="popup-menu-divider"></div>
<div class="popup-menu-item" @click=${() => { this._closeNewMenu(); this._openAdoptProblemSensors(); }}>
<ha-icon icon="mdi:alert-circle-check-outline"></ha-icon> ${t("adopt_problem_button", L)}
</div>
<div class="popup-menu-item" @click=${() => { this._closeNewMenu(); this._openSuggestedSetups(); }}>
<ha-icon icon="mdi:auto-fix"></ha-icon> ${t("setups_button", L)}
</div>
${this._batteryFleetSetupAvailable ? html`
<div class="popup-menu-item" @click=${() => { this._closeNewMenu(); this._setupBatteryFleet(); }}>
<ha-icon icon="mdi:battery-sync"></ha-icon> ${t("battery_fleet_setup_button", L)}
</div>
` : nothing}
</div>
` : nothing}
</div>
`;
}
private _toggleNewMenu(): void {
this._newMenuOpen = !this._newMenuOpen;
if (this._newMenuOpen) {
setTimeout(() => {
const handler = () => { this._newMenuOpen = false; document.removeEventListener("click", handler); };
document.addEventListener("click", handler);
}, 0);
}
}
private _closeNewMenu(): void {
this._newMenuOpen = false;
}
// ── #125: getting-started chips ──────────────────────────────────────────
// Dismissible onboarding hints while the instance is YOUNG (few objects/
// tasks — deliberately a maturity signal, not install age). The fleet chip
// is content-driven instead (available && not configured), mirroring the
// old inline button's own retirement rule. Nothing ever moves: the New
// menu is the stable home from day one; these are visibly temporary.
/** Young = the discovery hints still earn their place. Fleet objects don't
* count — a one-click fleet setup shouldn't age the install by itself. */
private _isYoungInstall(): boolean {
const objs = this._objects.filter((o) => !o.object?.battery_fleet);
const tasks = objs.reduce((n, o) => n + o.tasks.length, 0);
return objs.length < 3 && tasks < 8;
}
private _gsDismissed(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(LS_KEYS.gettingStartedDismissed) || "[]"));
} catch {
return new Set();
}
}
private _dismissGettingStarted(id: string): void {
const next = this._gsDismissed();
next.add(id);
try { localStorage.setItem(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ }
this.requestUpdate();
}
/** Discovery counts for the chips — fetched ONCE per panel life and ONLY
* while the install is young, so mature installs pay nothing. */
private _maybeLoadGettingStarted(): void {
if (this._gsLoaded || !this._isYoungInstall()) return;
this._gsLoaded = true;
this.hass.connection
.sendMessagePromise<{ setups: unknown[] }>({ type: "maintenance_supporter/integration_setups/discover" })
.then((r) => { this._gsSetupsCount = (r.setups || []).length; })
.catch(() => { this._gsSetupsCount = 0; });
this.hass.connection
.sendMessagePromise<{ sensors: unknown[] }>({ type: "maintenance_supporter/problem_sensors/discover" })
.then((r) => { this._gsAdoptCount = (r.sensors || []).length; })
.catch(() => { this._gsAdoptCount = 0; });
}
private _renderGettingStartedChips(L: string) {
const dismissed = this._gsDismissed();
const young = this._isYoungInstall();
const chips: { id: string; icon: string; text: string; run: () => void }[] = [];
if (young && this._gsSetupsCount > 0 && !dismissed.has("setups")) {
chips.push({
id: "setups", icon: "mdi:auto-fix",
text: t("gs_setups_chip", L).replace("{n}", String(this._gsSetupsCount)),
run: () => this._openSuggestedSetups(),
});
}
if (young && this._gsAdoptCount > 0 && !dismissed.has("adopt")) {
chips.push({
id: "adopt", icon: "mdi:alert-circle-check-outline",
text: t("gs_adopt_chip", L).replace("{n}", String(this._gsAdoptCount)),
run: () => this._openAdoptProblemSensors(),
});
}
if (this._batteryFleetSetupAvailable && !dismissed.has("fleet")) {
chips.push({
id: "fleet", icon: "mdi:battery-sync",
text: t("gs_fleet_chip", L),
run: () => this._setupBatteryFleet(),
});
}
if (chips.length === 0) return nothing;
return html`
<div class="gs-chips-wrap">
<div class="gs-chips-label">${t("gs_label", L)}</div>
<div class="gs-chips">
${chips.map((c) => html`
<div class="gs-chip" @click=${() => c.run()}>
<ha-icon icon="${c.icon}"></ha-icon>
<span>${c.text}</span>
<span class="gs-chip-x" title="${t("dismiss", L)}"
@click=${(e: Event) => { e.stopPropagation(); this._dismissGettingStarted(c.id); }}>
<ha-icon icon="mdi:close"></ha-icon>
</span>
</div>
`)}
</div>
</div>
`;
}
private _toggleObjMenu(): void {
this._objMenuOpen = !this._objMenuOpen;
if (this._objMenuOpen) {
@@ -3838,9 +3838,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -55,10 +55,9 @@ export const panelStyles = css`
gap: 8px;
}
/* Narrow-viewport disclosure (UX 2026-07): the collapsed classes are only
/* Narrow-viewport disclosure (UX 2026-07): the collapsed class is only
ever set when the host is narrow desktop always renders inline. */
.filter-bar.collapsed,
.actions-bar.collapsed {
.filter-bar.collapsed {
display: none;
}
@@ -68,29 +67,64 @@ export const panelStyles = css`
padding: 8px 0 0;
}
.mobile-controls .mobile-toggle {
.mobile-controls .mobile-toggle,
.mobile-controls .new-menu-wrapper {
flex: 1;
}
.mobile-controls .new-menu-wrapper .new-menu-button {
width: 100%;
}
.mobile-controls .mobile-toggle.active {
--ha-button-background: var(--primary-color);
}
.actions-bar {
/* #125: the one "New" menu that replaced the six-button actions bar. */
.new-menu-wrapper {
position: relative;
margin-left: auto;
}
.new-menu-popup {
right: 0;
left: auto;
min-width: 256px;
}
.new-menu-popup .popup-menu-item {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 4px 0 8px;
/* Left-aligned on purpose: flex-end mimicked the pre-v2.37 look (buttons
trailing the filter row), but on wide desktops it strands the primary
"new task" action at the far right of an otherwise empty row. */
justify-content: flex-start;
align-items: center;
gap: 10px;
}
.new-menu-popup .popup-menu-item ha-icon {
--mdc-icon-size: 18px;
color: var(--secondary-text-color);
}
:host([narrow]) .new-menu-popup { right: 0; left: auto; }
:host([narrow]) .actions-bar {
flex-direction: column;
align-items: stretch;
/* #125: getting-started chips — visibly temporary onboarding hints. */
.gs-chips-wrap { margin: 2px 0 12px; }
.gs-chips-label {
font-size: 11px;
color: var(--secondary-text-color);
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 7px;
}
.gs-chips { display: flex; gap: 10px; flex-wrap: wrap; }
.gs-chip {
display: flex;
align-items: center;
gap: 9px;
background: var(--secondary-background-color);
border: 1px dashed var(--divider-color);
border-radius: 16px;
padding: 7px 8px 7px 13px;
font-size: 12.5px;
cursor: pointer;
}
.gs-chip ha-icon { --mdc-icon-size: 16px; color: var(--primary-color); }
.gs-chip-x { display: inline-flex; padding: 0 3px; }
.gs-chip-x ha-icon { --mdc-icon-size: 14px; color: var(--secondary-text-color); }
.filter-field {
display: flex;
@@ -366,7 +366,10 @@ export const sharedStyles = css`
label length. */
.stats-bar {
display: grid;
grid-template-columns: repeat(5, 1fr);
/* auto-fit instead of a fixed 5: with the budget feature on, two KPI
tiles join the strip (#125) and on narrow screens the tiles wrap
instead of crushing. */
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
gap: 16px;
padding: 16px;
}
@@ -905,38 +908,25 @@ export const sharedStyles = css`
50% { opacity: 0.7; }
}
/* Budget bars */
.budget-bars {
display: flex;
gap: 16px;
padding: 8px 16px;
flex-wrap: wrap;
/* Budget KPI tiles in the stats strip (#125) replaced the full-width
budget-bars row. */
.stat-item.budget-tile .budget-tile-value {
font-size: 15px;
padding-top: 5px;
white-space: nowrap;
}
.budget-item {
flex: 1;
min-width: 200px;
}
.budget-label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--secondary-text-color);
margin-bottom: 4px;
}
.budget-bar {
.budget-tile-bar {
width: 100%;
height: 6px;
max-width: 130px;
height: 4px;
border-radius: 2px;
background: var(--divider-color);
border-radius: 3px;
overflow: hidden;
margin-top: 5px;
}
.budget-bar-fill {
.budget-tile-bar > div {
height: 100%;
border-radius: 3px;
border-radius: 2px;
transition: width 0.3s;
}
@@ -1444,21 +1434,9 @@ export const sharedStyles = css`
.weibull-info-row { flex-direction: column; gap: 8px; }
/* One compact line instead of two stacked rows the bars are glanceable
info, not the page's main content (UX 2026-07). The label stacks above
the value: side-by-side, the yearly pair wrapped raggedly on 360px
screens; a uniform two-line stack reads as intended at every width. */
.budget-bars { gap: 12px; padding: 6px 8px; }
.budget-item { min-width: 0; }
.budget-label {
font-size: 11px;
margin-bottom: 2px;
flex-direction: column;
align-items: flex-start;
gap: 0;
}
.budget-label span { white-space: nowrap; }
.budget-bar { height: 4px; }
/* Budget tiles on narrow screens (#125): slightly smaller value so the
"x / y €" pair fits the wrapped grid cell. */
.stat-item.budget-tile .budget-tile-value { font-size: 13px; }
.group-card { min-width: 0; max-width: 100%; }