This commit is contained in:
Home Assistant Version Control
2026-08-06 15:24:19 +00:00
parent 6aaf37b9bc
commit d5da256341
93 changed files with 5199 additions and 8790 deletions
@@ -167,3 +167,74 @@ describe("complete-dialog", () => {
expect(completedEvent).to.be.false;
});
});
describe("complete-dialog cost suggestion from parts (#104 follow-up)", () => {
async function mountWithParts(over: Partial<MaintenanceCompleteDialog> = {}) {
const { hass } = createMockHass({});
const el = await fixture<MaintenanceCompleteDialog>(html`
<maintenance-complete-dialog
.hass=${hass}
.entryId=${"entry1"}
.taskId=${"task1"}
.taskName=${"Uses Parts"}
.lang=${"en"}
></maintenance-complete-dialog>
`);
Object.assign(el, over);
el.open();
await el.updateComplete;
return el;
}
const chip = (el: MaintenanceCompleteDialog) =>
el.shadowRoot!.querySelector<HTMLButtonElement>(".cost-suggestion");
it("sums selected consumed parts (qty x unit cost) and fills on click", async () => {
const el = await mountWithParts({
parts: [
{ id: "p1", name: "Filter", cost: 12.5 },
{ id: "p2", name: "O-Ring", cost: 2.25 },
{ id: "p3", name: "Unpriced", cost: null },
] as never,
consumesParts: [
{ part_id: "p1", quantity: 1 },
{ part_id: "p2", quantity: 2 },
{ part_id: "p3", quantity: 1 },
] as never,
currencySymbol: "€",
});
const c = chip(el)!;
expect(c, "suggestion chip rendered").to.exist;
expect(c.textContent).to.include("17.00");
expect(c.textContent).to.include("€");
c.click();
await el.updateComplete;
const cost = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".field-input")][1];
expect(cost.value).to.equal("17.00");
expect(chip(el), "chip hides once cost is set").to.equal(null);
});
it("buy task: restock qty x unit cost, follows the qty field", async () => {
const el = await mountWithParts({ restockDefault: 2, restockUnitCost: 4.5 });
expect(chip(el)!.textContent).to.include("9.00");
});
it("no chip when no involved part carries a price", async () => {
const el = await mountWithParts({
parts: [{ id: "p1", name: "Filter", cost: null }] as never,
consumesParts: [{ part_id: "p1", quantity: 1 }] as never,
});
expect(chip(el)).to.equal(null);
});
it("no chip once the user typed a cost themselves", async () => {
const el = await mountWithParts({
parts: [{ id: "p1", name: "Filter", cost: 5 }] as never,
consumesParts: [{ part_id: "p1", quantity: 1 }] as never,
});
expect(chip(el)).to.exist;
setInput(el, 1, "3.10");
await el.updateComplete;
expect(chip(el)).to.equal(null);
});
});
@@ -20,7 +20,9 @@ function setDeepLink(query: string) {
}
async function settleRaf(el: { updateComplete: Promise<unknown> }) {
// Deep-link dialog opens behind a requestAnimationFrame.
// Deep-link dialog opens behind a requestAnimationFrame — and since the
// dialogs became lazy code-split chunks, behind their dynamic import too.
await customElements.whenDefined("maintenance-complete-dialog");
await new Promise((r) => requestAnimationFrame(() => r(null)));
await new Promise((r) => setTimeout(r, 20));
await (el as HTMLElement & { updateComplete: Promise<unknown> }).updateComplete;
@@ -30,6 +32,15 @@ function completeDialog(el: HTMLElement): MaintenanceCompleteDialog | null {
return sr(el).querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
}
/** The dialogs are lazy code-split chunks — the open lands whenever the
* whole lazy-UI group has loaded, so poll instead of guessing a delay. */
async function waitForOpenCompleteDialog(el: HTMLElement): Promise<void> {
for (let i = 0; i < 100; i++) {
if (completeDialog(el)?.shadowRoot?.querySelector("ha-dialog")) return;
await new Promise((r) => setTimeout(r, 20));
}
}
describe("panel deep links (QR scan routing)", () => {
beforeEach(() => {
resetTaskSeq();
@@ -52,6 +63,7 @@ describe("panel deep links (QR scan routing)", () => {
expect(sr(el).querySelector(".task-header"), "task detail rendered").to.exist;
expect(sr(el).querySelector(".task-name-breadcrumb")!.textContent).to.include("Scan Me");
// …with the complete dialog open and targeted at the scanned task.
await waitForOpenCompleteDialog(el);
const dlg = completeDialog(el)!;
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "complete dialog open").to.exist;
expect(dlg.entryId).to.equal("e1");
@@ -94,6 +106,7 @@ describe("panel deep links (QR scan routing)", () => {
expect(
sent.filter((m) => m.type === "maintenance_supporter/task/quick_complete").length,
).to.equal(1);
await waitForOpenCompleteDialog(el);
const dlg = completeDialog(el)!;
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "fallback dialog open").to.exist;
expect(dlg.taskName).to.equal("No Defaults");
@@ -29,6 +29,10 @@ interface BatteryRow {
* the fleet floor, whichever is higher) — the level bar colors against
* it, not against a fixed 20 %. */
low_threshold?: number;
/** B1: the predicted date has passed while the battery still reports
* healthy. Never escalates to low/task — the row just shows the
* discrepancy (usual cause: an unrecorded swap). */
forecast_overdue?: boolean;
}
interface RosterRow extends BatteryRow {
status: "low" | "soon" | "ok";
@@ -466,13 +470,15 @@ export class MaintenanceBatteryFleetSection extends LitElement {
})()}
${b.days_until != null
? html`<span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""}"
title=${b.predicted_source === "trend"
? t("battery_fleet_predicted_trend", L)
.replace("{date}", this._predictedDate(b.days_until))
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
>~${this._predictedDate(b.days_until)}</span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
title=${b.forecast_overdue
? t("battery_fleet_forecast_overdue", L)
: b.predicted_source === "trend"
? t("battery_fleet_predicted_trend", L)
.replace("{date}", this._predictedDate(b.days_until))
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
>`
: nothing}
<button
@@ -770,6 +776,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
text-decoration: underline dotted;
text-underline-offset: 2px;
}
/* B1: passed prediction on a still-healthy battery — warn-tinted with a
calendar-alert icon; the tooltip explains (record the swap / forecast
was off). Deliberately NOT red: this is a discrepancy, not an alarm. */
.bf-predicted.bf-overdue {
color: var(--warning-color, #ff9800);
}
.bf-predicted.bf-overdue ha-icon {
--mdc-icon-size: 14px;
margin-right: 2px;
}
.bf-total {
font-size: 12px;
color: var(--secondary-text-color);
@@ -21,6 +21,11 @@ export class MaintenanceCompleteDialog extends LitElement {
@property() public readingUnit = "";
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
@property({ attribute: false }) public restockDefault: number | null = null;
/** #104 follow-up: the buy task's part unit cost — powers the cost
* suggestion (restock qty × unit cost). */
@property({ attribute: false }) public restockUnitCost: number | null = null;
/** Currency symbol for the cost suggestion ("" = plain number). */
@property() public currencySymbol = "";
/** #99: the parts offered on completion — enables the editable "parts used"
* section. Built by `partsForCompletion`: the object's own inventory plus
* every shared pool this task links to (#111), each tagged with its owner. */
@@ -215,6 +220,47 @@ export class MaintenanceCompleteDialog extends LitElement {
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
}
/** #104 follow-up: suggested cost derived from the parts this completion
* touches — the SELECTED "parts used" (qty × each part's unit cost) on a
* consuming task, or restock qty × unit cost on a buy task. Null when no
* involved part carries a price. Follows the live selection, so ticking
* a part off updates the suggestion. */
private _partsCostSuggestion(): number | null {
if (this.restockDefault !== null) {
const qty = parseFloat(this._restockQty);
if (this.restockUnitCost == null || !Number.isFinite(qty) || qty <= 0) return null;
return Math.round(this.restockUnitCost * qty * 100) / 100;
}
if (!this.parts.length) return null;
let sum = 0;
let priced = false;
for (const link of Object.values(this._usedParts)) {
const def = this.parts.find(
(pt) => partLinkKey({ part_id: pt.id, entry_id: pt.entry_id }) === partLinkKey(link),
);
if (def?.cost != null) {
sum += def.cost * (link.quantity || 1);
priced = true;
}
}
return priced ? Math.round(sum * 100) / 100 : null;
}
/** The one-click "use ≈ X from parts" chip under the cost field. Hidden
* once the user typed a cost themselves — a suggestion, never an
* overwrite. */
private _renderCostSuggestion(L: string) {
if (this._cost.trim() !== "") return nothing;
const suggestion = this._partsCostSuggestion();
if (suggestion == null || suggestion <= 0) return nothing;
const amount = `${suggestion.toFixed(2)}${this.currencySymbol ? ` ${this.currencySymbol}` : ""}`;
return html`<button
type="button"
class="cost-suggestion"
@click=${() => (this._cost = suggestion.toFixed(2))}
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>`;
}
private _close(): void {
this._open = false;
}
@@ -317,6 +363,7 @@ export class MaintenanceCompleteDialog extends LitElement {
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._cost}
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
${this._renderCostSuggestion(L)}
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
@@ -386,6 +433,19 @@ export class MaintenanceCompleteDialog extends LitElement {
margin-left: 2px;
font-weight: 600;
}
/* #104: one-click cost suggestion from parts — quiet link-style chip. */
.cost-suggestion {
align-self: flex-start;
margin-top: 4px;
padding: 0;
border: none;
background: none;
color: var(--primary-color);
font-size: 12.5px;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.dialog-title {
font-size: 18px;
font-weight: 500;
@@ -33,11 +33,22 @@ const common = {
banner: { js: `/*! maintenance_supporter frontend ${manifestVersion} */` },
};
// Panel
// Panel — code-split (perf wave 2, item 5): the dialogs + settings view the
// panel dynamic-imports land in frontend/panel-chunks/ as content-hashed
// chunks, shrinking the entry's critical parse path. publicPath makes the
// chunk imports ABSOLUTE, because the entry is served from a versioned
// file URL (/maintenance_supporter_panel_<hash>) that has no directory for
// relative imports to resolve against. Cache-safety (#124 class): the
// entry URL changes with its content hash, and a fresh entry always names
// exactly its own content-hashed chunks.
rmSync("../frontend/panel-chunks", { recursive: true, force: true });
await build({
...common,
entryPoints: ["maintenance-panel.ts"],
outfile: "../frontend/maintenance-panel.js",
outdir: "../frontend",
splitting: true,
chunkNames: "panel-chunks/[name]-[hash]",
publicPath: "/maintenance_supporter_panelfiles",
});
// Lovelace Card
@@ -16,4 +16,5 @@ export const LS_KEYS = {
objectSort: "maintenance_supporter_object_sort",
groupBy: "maintenance_supporter_groupby",
objectView: "maintenance_supporter_object_view",
objectsCache: "msp-objects-cache",
} as const;
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Flotila baterií",
"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"
"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ů"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Batteriflåde",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Batterie-Flotte",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Battery fleet",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Flota de baterías",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Paristokanta",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Parc de 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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "बैटरी फ्लीट",
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
"update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।",
"update_reload": "पुनः लोड करें"
"update_reload": "पुनः लोड करें",
"battery_fleet_forecast_overdue": "अनुमानित तिथि बीत गई — बैटरी अब भी अच्छी स्थिति बता रही है। यदि आपने इसे बदला है, तो बदलाव दर्ज करें; अन्यथा पूर्वानुमान गलत था।",
"cost_from_parts": "पुर्ज़ों से ≈ {amount} लें"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Elemflotta",
"battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Parco batterie",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "電池フリート",
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
"update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。",
"update_reload": "再読み込み"
"update_reload": "再読み込み",
"battery_fleet_forecast_overdue": "予測日を過ぎましたが、電池はまだ正常と報告しています。交換済みなら交換を記録してください。そうでなければ予測が外れています。",
"cost_from_parts": "部品から ≈ {amount} を適用"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "배터리 플릿",
"battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.",
"update_banner": "서버에 Maintenance Supporter의 새 버전이 있습니다 — 패널을 업데이트하려면 새로 고침하세요.",
"update_reload": "새로 고침"
"update_reload": "새로 고침",
"battery_fleet_forecast_overdue": "예측 날짜가 지났지만 배터리는 여전히 정상으로 보고됩니다. 교체했다면 교체를 기록하세요. 아니라면 예측이 빗나간 것입니다.",
"cost_from_parts": "부품에서 ≈ {amount} 적용"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Batteriflåte",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Batterijvloot",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Flota baterii",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Frota de baterias",
"battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.",
"update_banner": "Há uma versão mais nova do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
"update_reload": "Recarregar"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Frota de baterias",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
"update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.",
"update_reload": "Перезагрузить"
"update_reload": "Перезагрузить",
"battery_fleet_forecast_overdue": "Прогнозируемая дата прошла — батарея по-прежнему сообщает о хорошем состоянии. Если вы её заменили, зафиксируйте замену; иначе прогноз оказался неверным.",
"cost_from_parts": "Взять ≈ {amount} из запчастей"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Batteriflotta",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Pil filosu",
"battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.",
"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"
"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"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
"update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.",
"update_reload": "Перезавантажити"
"update_reload": "Перезавантажити",
"battery_fleet_forecast_overdue": "Прогнозована дата минула — батарея й далі повідомляє про добрий стан. Якщо ви її замінили, зафіксуйте заміну; інакше прогноз не справдився.",
"cost_from_parts": "Узяти ≈ {amount} із запчастин"
}
@@ -833,5 +833,7 @@
"battery_fleet_setup_button": "电池群",
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
"update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。",
"update_reload": "重新加载"
"update_reload": "重新加载",
"battery_fleet_forecast_overdue": "预测日期已过——电池仍报告状态良好。如果您已更换电池,请记录更换;否则说明预测有误。",
"cost_from_parts": "采用配件合计 ≈ {amount}"
}
@@ -2,6 +2,7 @@
import { LitElement, html, css, nothing } from "lit";
import { mergeSubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { hydrateObjects } from "./helpers/hydrate-objects";
import { property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
import type {
@@ -156,10 +157,10 @@ export class MaintenanceSupporterCard extends LitElement {
private async _loadData(): Promise<void> {
try {
const [objResult, statsResult] = await Promise.all([
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects" }),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", compact: true }),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }),
]);
this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
this._objects = hydrateObjects((objResult as { objects: MaintenanceObjectResponse[] }).objects);
this._stats = statsResult as StatisticsResponse;
} catch {
// WS not available yet
@@ -275,14 +276,16 @@ export class MaintenanceSupporterCard extends LitElement {
try {
const unsub = await this.hass.connection.subscribeMessage(
(msg: unknown) => {
const next = mergeSubscriptionEvent(
this._objects,
msg as SubscriptionEvent<MaintenanceObjectResponse>,
);
const ev = msg as SubscriptionEvent<MaintenanceObjectResponse>;
// Compact payloads: hydrate before merging (helpers/hydrate-objects).
if (ev.objects) hydrateObjects(ev.objects);
if (ev.delta) hydrateObjects(ev.delta);
const next = mergeSubscriptionEvent(this._objects, ev);
if (next !== null) this._objects = next;
},
// deltas: only changed entries arrive — see helpers/subscription-merge.
{ type: "maintenance_supporter/subscribe", deltas: true }
// compact: empty keys stripped server-side, hydrated above.
{ type: "maintenance_supporter/subscribe", deltas: true, compact: true }
);
// Detached mid-subscribe → drop the orphaned subscription.
if (!this.isConnected) {
@@ -7,6 +7,8 @@ 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";
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
import { hydrateObjects } from "./helpers/hydrate-objects";
import { daysProgress } from "./helpers/interval";
import { buildObjectReportHtml, type ReportLabels } from "./helpers/report";
import { warrantyStatus } from "./helpers/warranty";
@@ -34,20 +36,18 @@ import type {
} from "./types";
import { StatisticsService } from "./statistics-service";
import { UserService } from "./user-service";
import "./components/object-dialog";
// The heavy dialogs + the settings view are NOT imported here: they are
// esbuild code-split chunks loaded by _ensureLazyUi() right after mount
// (roadmap perf wave 2, item 5). Only their types are imported — esbuild
// tree-shakes type-only imports, so they cost the entry bundle nothing.
import type { MaintenanceObjectDialog } from "./components/object-dialog";
import "./components/documents-section";
import "./components/parts-section";
import "./components/task-documents";
import "./components/task-dialog";
import type { MaintenanceTaskDialog } from "./components/task-dialog";
import "./components/complete-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
import "./components/qr-dialog";
import type { MaintenanceQrDialog } from "./components/qr-dialog";
import "./components/adopt-problem-sensors-dialog";
import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-dialog";
import "./components/suggested-setups-dialog";
import "./components/battery-fleet-section";
import type { MaintenanceSuggestedSetupsDialog } from "./components/suggested-setups-dialog";
// v2.0.0: panel uses the extracted Calendar Card instead of its own
@@ -61,7 +61,6 @@ import type {
} from "./components/history-edit-dialog";
import "./components/confirm-dialog";
import type { MaintenanceConfirmDialog } from "./components/confirm-dialog";
import "./components/settings-view";
import "./components/storage-section-card";
import "./components/seasonal-overrides-dialog";
import type { SeasonalOverridesDialog } from "./components/seasonal-overrides-dialog";
@@ -236,8 +235,45 @@ export class MaintenanceSupporterPanel extends LitElement {
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
/** Lazy UI chunks (perf wave 2, item 5): the six dialogs + the settings
* view are code-split out of the entry bundle and fetched in parallel
* right after mount — the critical parse path shrinks while every open
* path goes through _ui(), so a click can never race a still-loading
* chunk. */
private _lazyUi: Promise<unknown> | null = null;
private _ensureLazyUi(): Promise<unknown> {
if (!this._lazyUi) {
this._lazyUi = Promise.all([
import("./components/object-dialog"),
import("./components/task-dialog"),
import("./components/complete-dialog"),
import("./components/qr-dialog"),
import("./components/adopt-problem-sensors-dialog"),
import("./components/suggested-setups-dialog"),
import("./components/settings-view"),
]).then(() => this.updateComplete);
}
return this._lazyUi;
}
/** The requested dialog element, guaranteed upgraded (chunk loaded). */
private async _ui<T extends Element>(tag: string): Promise<T | null> {
await this._ensureLazyUi();
return this.shadowRoot?.querySelector<T>(tag) ?? null;
}
connectedCallback(): void {
super.connectedCallback();
// Prefetch the lazy UI chunks AFTER the first paint settles (idle), not
// here: an eager prefetch put 7 fetches + ~280 KB of parsing in direct
// competition with the initial data load and measurably slowed first
// paint. Deep links and early clicks stay safe either way — every open
// path awaits _ui().
const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => void }).requestIdleCallback;
const kick = () => this._ensureLazyUi();
if (idle) idle(kick, { timeout: 3000 });
else window.setTimeout(kick, 1500);
window.addEventListener("popstate", this._popstateHandler);
window.addEventListener("keydown", this._paletteKeydown);
window.addEventListener("resize", this._onVirtualScroll, { passive: true });
@@ -265,6 +301,15 @@ export class MaintenanceSupporterPanel extends LitElement {
} catch {
// storage blocked — keep the defaults
}
// Skeleton from cache: paint the previous visit's task list immediately;
// the live payload replaces it through the normal _objects assignment.
if (this._objects.length === 0) {
const cached = readObjectsCache<MaintenanceObjectResponse, StatisticsResponse>();
if (cached) {
this._objects = cached.objects;
if (cached.stats) this._stats = cached.stats;
}
}
}
disconnectedCallback(): void {
@@ -378,7 +423,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private async _loadData(): Promise<void> {
const [objResult, statsResult, budgetResult, groupsResult, settingsResult, viewsResult] = await Promise.all([
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", compact: true }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/budget_status" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/groups" }).catch(() => null),
@@ -386,7 +431,10 @@ export class MaintenanceSupporterPanel extends LitElement {
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/views/list" }).catch(() => null),
]);
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
if (objResult) {
this._objects = hydrateObjects((objResult as { objects: MaintenanceObjectResponse[] }).objects);
writeObjectsCache(this._objects, (statsResult as StatisticsResponse | null) ?? this._stats ?? null);
}
// A data refresh means the open task's history may have grown (complete,
// history edit) — the truncated list payload can't tell, so refetch.
if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) {
@@ -470,11 +518,8 @@ export class MaintenanceSupporterPanel extends LitElement {
if (msAction === "add_object") {
this._deepLinkHandled = true;
cleanMsActionUrl();
requestAnimationFrame(() => {
this.shadowRoot
?.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")
?.openCreate();
});
void this._ui<MaintenanceObjectDialog>("maintenance-object-dialog")
.then((d) => d?.openCreate());
return;
}
if (msAction === "open_vacation"
@@ -486,14 +531,15 @@ export class MaintenanceSupporterPanel extends LitElement {
// Switch to the Settings tab — that's where Vacation/Budget/Groups live.
this._overviewTab = "settings";
// Hint the settings-view which sub-section to scroll to (its own
// settings-view component reads this on attribute change).
requestAnimationFrame(() => {
// settings-view component reads this on attribute change). The view is
// a lazy chunk — wait for it before poking at the element.
void this._ensureLazyUi().then(() => requestAnimationFrame(() => {
const settingsView = this.shadowRoot?.querySelector(
"maintenance-settings-view",
) as (HTMLElement & { scrollToSection?: (s: string) => void }) | null;
const target = msAction.replace("open_", ""); // "vacation" / "budget" / "groups" / "settings"
settingsView?.scrollToSection?.(target);
});
}));
return;
}
@@ -601,15 +647,25 @@ export class MaintenanceSupporterPanel extends LitElement {
try {
const unsub = await this.hass.connection.subscribeMessage(
(msg: unknown) => {
const next = mergeSubscriptionEvent(
this._objects,
msg as SubscriptionEvent<MaintenanceObjectResponse>,
);
if (next !== null) this._objects = next;
const ev = msg as SubscriptionEvent<MaintenanceObjectResponse>;
// Compact payloads: hydrate incoming entries before merging so
// everything downstream keeps seeing the full shape.
if (ev.objects) hydrateObjects(ev.objects);
if (ev.delta) hydrateObjects(ev.delta);
const next = mergeSubscriptionEvent(this._objects, ev);
if (next !== null) {
this._objects = next;
// Full snapshots are rare (subscribe start) — keep the skeleton
// cache fresh from them; per-delta writes would churn storage.
if ((msg as SubscriptionEvent<MaintenanceObjectResponse>).objects) {
writeObjectsCache(next, this._stats ?? null);
}
}
},
// deltas: only entries whose rebuilt response actually changed —
// no-op timer waves send nothing, a real change ships one object.
{ type: "maintenance_supporter/subscribe", deltas: true }
// compact: empty keys stripped server-side, hydrated above.
{ type: "maintenance_supporter/subscribe", deltas: true, compact: true }
);
// If the element was detached while the subscribe was in flight, drop the
// now-orphaned subscription instead of storing it on a dead component.
@@ -1051,9 +1107,8 @@ export class MaintenanceSupporterPanel extends LitElement {
// --- Adopt problem sensors ---
private _openAdoptProblemSensors(): void {
this.shadowRoot!
.querySelector<MaintenanceAdoptProblemSensorsDialog>("maintenance-adopt-problem-sensors-dialog")
?.open();
void this._ui<MaintenanceAdoptProblemSensorsDialog>("maintenance-adopt-problem-sensors-dialog")
.then((d) => d?.open());
}
private async _onProblemSensorsAdopted(e: CustomEvent): Promise<void> {
@@ -1069,7 +1124,8 @@ export class MaintenanceSupporterPanel extends LitElement {
const obj = this._objects.find((o) => o.entry_id === ref.entry_id);
const tk = obj?.tasks.find((task) => task.id === ref.task_id);
if (obj && tk) {
this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openEdit(ref.entry_id, tk);
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
.then((d) => d?.openEdit(ref.entry_id, tk));
}
});
} else {
@@ -1100,9 +1156,8 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _openSuggestedSetups(): void {
this.shadowRoot!
.querySelector<MaintenanceSuggestedSetupsDialog>("maintenance-suggested-setups-dialog")
?.open();
void this._ui<MaintenanceSuggestedSetupsDialog>("maintenance-suggested-setups-dialog")
.then((d) => d?.open());
}
private _onSetupsAdopted(e: CustomEvent): void {
@@ -1850,8 +1905,11 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
if (!dlg) return;
void this._ui<MaintenanceCompleteDialog>("maintenance-complete-dialog")
.then((dlg) => dlg && this._fillAndOpenCompleteDialog(dlg, entryId, taskId, taskName, checklist, adaptiveEnabled));
}
private _fillAndOpenCompleteDialog(dlg: MaintenanceCompleteDialog, entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
dlg.entryId = entryId;
dlg.taskId = taskId;
dlg.taskName = taskName;
@@ -1873,6 +1931,10 @@ export class MaintenanceSupporterPanel extends LitElement {
const objParts = this._objects.find((o) => o.entry_id === entryId)?.parts || [];
const refPart = tk?.part_ref ? objParts.find((pt) => pt.id === tk.part_ref!.part_id) : undefined;
dlg.restockDefault = tk?.part_ref ? (refPart?.restock_quantity ?? 1) : null;
// #104 follow-up: parts carry unit costs — the dialog offers their sum
// as a one-click cost suggestion (buy task: restock qty × unit cost).
dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null;
dlg.currencySymbol = this._budget?.currency_symbol || "";
// #111: a link may point at another object's pool — name that object, and
// never drop a line that fails to resolve (the old .filter(Boolean) hid it).
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
@@ -1888,13 +1950,13 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _openQrForObject(entryId: string, objectName: string): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceQrDialog>("maintenance-qr-dialog");
dlg?.openForObject(entryId, objectName);
void this._ui<MaintenanceQrDialog>("maintenance-qr-dialog")
.then((dlg) => dlg?.openForObject(entryId, objectName));
}
private _openQrForTask(entryId: string, taskId: string, objectName: string, taskName: string): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceQrDialog>("maintenance-qr-dialog");
dlg?.openForTask(entryId, taskId, objectName, taskName);
void this._ui<MaintenanceQrDialog>("maintenance-qr-dialog")
.then((dlg) => dlg?.openForTask(entryId, taskId, objectName, taskName));
}
private _onDialogEvent = async (): Promise<void> => {
@@ -2385,12 +2447,12 @@ export class MaintenanceSupporterPanel extends LitElement {
@click=${() => { if (this.narrow) this._actionsMenuOpen = false; }}
>
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openCreate("", this._objects)}
@click=${() => this._ui<MaintenanceTaskDialog>("maintenance-task-dialog").then((d) => d?.openCreate("", this._objects))}
>
${t("new_task", L)}
</ha-button>
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}
@click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}
>
${t("new_object", L)}
</ha-button>
@@ -2422,7 +2484,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-button appearance="filled" @click=${() => this._openTemplateGallery()}>
<ha-icon icon="mdi:view-grid-plus-outline"></ha-icon> ${t("templates_from", L)}
</ha-button>
<ha-button appearance="plain" @click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}>
<ha-button appearance="plain" @click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}>
${t("new_object", L)}
</ha-button>
</div>
@@ -2720,7 +2782,7 @@ export class MaintenanceSupporterPanel extends LitElement {
` : nothing}
${!isOperator ? html`
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}
@click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}
>
${t("new_object", L)}
</ha-button>
@@ -3115,12 +3177,12 @@ export class MaintenanceSupporterPanel extends LitElement {
<div class="action-buttons">
${!isOperator ? html`
<ha-button appearance="filled" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
dlg?.openCreate(obj.entry_id);
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
.then((d) => d?.openCreate(obj.entry_id));
}}>${t("add_task", L)}</ha-button>
<ha-button appearance="plain" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog");
dlg?.openEdit(obj.entry_id, o);
void this._ui<MaintenanceObjectDialog>("maintenance-object-dialog")
.then((d) => d?.openEdit(obj.entry_id, o));
}}>${t("edit", L)}</ha-button>
` : nothing}
<div class="more-menu-wrapper">
@@ -3192,8 +3254,8 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<div class="empty-state-centered">
<p class="empty">${t("no_tasks_yet", L)}</p>
<ha-button appearance="filled" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
dlg?.openCreate(obj.entry_id);
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
.then((d) => d?.openCreate(obj.entry_id));
}}>${t("add_first_task", L)}</ha-button>
</div>`
: html`<div class="task-table">${[...visibleTasks].sort((a, b) => {
@@ -3372,7 +3434,7 @@ export class MaintenanceSupporterPanel extends LitElement {
showObject: () => this._showObject(entryId),
toggleMoreMenu: () => this._toggleMoreMenu(),
closeMoreMenu: () => this._closeMoreMenu(),
openEdit: (tk) => { this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openEdit(entryId, tk); },
openEdit: (tk) => { void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog").then((d) => d?.openEdit(entryId, tk)); },
openComplete: (tk) => this._openCompleteDialog(entryId, taskId, tk.name, this._features.checklists ? tk.checklist : undefined, this._features.adaptive && !!tk.adaptive_config?.enabled),
promptSkip: () => this._promptSkipTask(entryId, taskId),
toggleArchive: (archived) => this._toggleArchiveTask(entryId, taskId, archived),
@@ -34,6 +34,10 @@ export interface MaintenanceObject {
/** Attached documents tagged as manuals — the fallback for the "manual"
* column/header when documentation_url is unset. Computed server-side. */
manual_docs?: ManualDocRef[];
/** Battery-fleet markers (v2.53 field audit): true on THE fleet object;
* excluded = entity_ids manually excluded from the fleet roster. */
battery_fleet?: boolean;
battery_fleet_excluded?: string[];
}
/** Slim reference to a manual-tagged document (subset of MaintenanceDocument). */