diff --git a/custom_components/maintenance_supporter/const.py b/custom_components/maintenance_supporter/const.py index ee0e52b..a9f8cd2 100644 --- a/custom_components/maintenance_supporter/const.py +++ b/custom_components/maintenance_supporter/const.py @@ -287,6 +287,12 @@ STRATEGY_CHUNKS_URL = f"{STRATEGY_DIR_URL}/chunks" # STRATEGY_DIR_URL. The shim has no relative imports, so a flat URL is fine. STRATEGY_SHIM_URL = "/maintenance_supporter_strategy_shim.js" CALENDAR_CARD_URL = "/maintenance_supporter_calendar_card" +# Panel code-split chunks (perf wave 2, item 5). The panel entry is served +# from a versioned FILE url (PANEL_URL + content hash, see panel.py), which +# gives relative imports nothing to resolve against — so esbuild rewrites +# its chunk imports to ABSOLUTE urls under this prefix (publicPath in +# esbuild.mjs; the two values must stay in sync). +PANEL_CHUNKS_URL = "/maintenance_supporter_panelfiles/panel-chunks" # Runtime-loaded UI translations (frontend/locales/.json), served as a # directory so the panel/card fetch the active language on demand. Mirrors # LOCALES_BASE in frontend-src/styles.ts: only EN is bundled into the JS (as the diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/complete-dialog.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/complete-dialog.test.ts index 9048e23..bbebc6b 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/complete-dialog.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/complete-dialog.test.ts @@ -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 = {}) { + const { hass } = createMockHass({}); + const el = await fixture(html` + + `); + Object.assign(el, over); + el.open(); + await el.updateComplete; + return el; + } + + const chip = (el: MaintenanceCompleteDialog) => + el.shadowRoot!.querySelector(".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(".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); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/panel-deeplink.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/panel-deeplink.test.ts index 4554e69..61595b2 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/panel-deeplink.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/panel-deeplink.test.ts @@ -20,7 +20,9 @@ function setDeepLink(query: string) { } async function settleRaf(el: { updateComplete: Promise }) { - // 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 }).updateComplete; @@ -30,6 +32,15 @@ function completeDialog(el: HTMLElement): MaintenanceCompleteDialog | null { return sr(el).querySelector("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 { + 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"); diff --git a/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts b/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts index 88162cc..113a714 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts @@ -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`~${this._predictedDate(b.days_until)}${b.forecast_overdue ? html`` : nothing}~${this._predictedDate(b.days_until)}` : nothing} `; + } + private _close(): void { this._open = false; } @@ -317,6 +363,7 @@ export class MaintenanceCompleteDialog extends LitElement { (this._cost = (e.target as HTMLInputElement).value)} /> + ${this._renderCostSuggestion(L)}