diff --git a/.HA_VERSION b/.HA_VERSION index ba11f4fa..22750a87 100644 --- a/.HA_VERSION +++ b/.HA_VERSION @@ -1 +1 @@ -2026.8.3 \ No newline at end of file +2026.9.0 \ No newline at end of file diff --git a/.ha_run.lock b/.ha_run.lock index 88ab2a33..e0d6ee12 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.8.3", "start_ts": 1788369992.7159514} \ No newline at end of file +{"pid": 70, "version": 1, "ha_version": "2026.9.0", "start_ts": 1788457712.680675} \ No newline at end of file diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources index dc371585..418171bf 100644 --- a/.storage/lovelace_resources +++ b/.storage/lovelace_resources @@ -31,7 +31,7 @@ }, { "id": "53837b51a371459b95ffc0989ce615fb", - "url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749400", + "url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749410", "type": "module" }, { @@ -196,7 +196,7 @@ }, { "id": "e92ef0caff41454e9f49ea966ed99e41", - "url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374110", + "url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374111", "type": "module" }, { @@ -315,7 +315,7 @@ "type": "module" }, { - "id": "3ea9e48c0f2140d7b50b8c69d7d0f331", + "id": "bdf8a9bb0de54fc99bf6c66ab68ea6ed", "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", "type": "module" } diff --git a/custom_components/maintenance_supporter/__init__.py b/custom_components/maintenance_supporter/__init__.py index 5c3f801f..51b4a64f 100644 --- a/custom_components/maintenance_supporter/__init__.py +++ b/custom_components/maintenance_supporter/__init__.py @@ -83,6 +83,9 @@ from .const import ( SIGNAL_NEW_OBJECT_ENTRY, SIGNAL_OBJECT_ENTRY_REMOVED, STORES_CACHE_KEY, + TriggerType, + slugify_object_name, + task_unique_id, ) from .const import ( DOCUMENT_STORE_KEY as _DS_KEY, @@ -393,6 +396,30 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None: ) +# Mirrors renderers/progress.ts: the configured limit and the unit the panel +# shows next to the live reading, per trigger type. Runtime accumulates HOURS +# regardless of the watched entity (a switch has no unit_of_measurement), +# state_change counts transitions (no unit), threshold/counter carry the +# source entity's unit. +def _trigger_target_and_unit(hass: HomeAssistant, tc: dict[str, Any]) -> tuple[float | None, str | None]: + ttype = tc.get("type") + if ttype == TriggerType.RUNTIME: + return tc.get("trigger_runtime_hours"), "h" + if ttype == TriggerType.STATE_CHANGE: + return tc.get("trigger_target_changes"), None + if ttype == TriggerType.COMPOUND: + return None, None + unit: str | None = None + if tc.get("entity_id"): + st = hass.states.get(tc["entity_id"]) + if st: + unit = st.attributes.get("unit_of_measurement") + if ttype == TriggerType.COUNTER: + return tc.get("trigger_target_value"), unit + above, below = tc.get("trigger_above"), tc.get("trigger_below") + return (above if above is not None else below), unit + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Maintenance Supporter integration.""" return await _async_setup_shared(hass) @@ -701,6 +728,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: wanted_entry = call.data.get("entry_id") wanted_status = call.data.get("status") tasks: list[dict[str, Any]] = [] + ent_reg = er.async_get(hass) for ce in hass.config_entries.async_entries(DOMAIN): if ce.unique_id == GLOBAL_UNIQUE_ID: continue @@ -711,6 +739,9 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: if coordinator is None or not coordinator.data: continue object_name = aggregate_object_name(ce) + # #151 follow-up: the task sensor's REGISTERED entity_id (users + # rename them), so a row can be fed straight into complete/skip. + object_slug = slugify_object_name((ce.data.get(CONF_OBJECT) or {}).get("name", "unknown")) for task_id, task in coordinator.data.get(CONF_TASKS, {}).items(): status = str(task.get("_status", "")) if status == "archived": @@ -727,15 +758,14 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: except ValueError: days_since = None tc = task.get("trigger_config") or {} - trigger_unit: str | None = None - if tc.get("entity_id"): - st = hass.states.get(tc["entity_id"]) - if st: - trigger_unit = st.attributes.get("unit_of_measurement") + trigger_target, trigger_unit = _trigger_target_and_unit(hass, tc) tasks.append( { "entry_id": ce.entry_id, "task_id": task_id, + "entity_id": ent_reg.async_get_entity_id( + "sensor", DOMAIN, task_unique_id(object_slug, task_id) + ), "object_name": object_name, "name": task.get("name"), "status": status, @@ -745,7 +775,12 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: "priority": task.get("priority", "normal"), "last_performed": lp, "days_since_last_completed": days_since, + "trigger_type": tc.get("type"), "trigger_current_value": task.get("_trigger_current_value"), + # Delta-mode counters: progress since the last service + # (what the panel bar shows); None for every other trigger. + "trigger_current_delta": task.get("_trigger_current_delta"), + "trigger_target": trigger_target, "trigger_unit": trigger_unit, } ) diff --git a/custom_components/maintenance_supporter/config_flow_trigger.py b/custom_components/maintenance_supporter/config_flow_trigger.py index c35e77e6..4552d3ee 100644 --- a/custom_components/maintenance_supporter/config_flow_trigger.py +++ b/custom_components/maintenance_supporter/config_flow_trigger.py @@ -64,6 +64,7 @@ from .const import ( ) from .helpers.global_options import get_default_warning_days from .helpers.task_fields import INTERVAL_DAYS_RANGE, WARNING_DAYS_RANGE +from .helpers.trigger_fallback import threshold_limits_overlap # Domains allowed for trigger entity selection. # Includes all domains from entity_attributes.DOMAIN_ATTRIBUTE_MAP plus @@ -498,10 +499,16 @@ class TriggerConfigMixin: equals = user_input.get(CONF_TRIGGER_EQUALS) not_equals = user_input.get(CONF_TRIGGER_NOT_EQUALS) + tc = self._current_task["trigger_config"] if above is None and below is None and equals is None and not_equals is None: errors["base"] = "invalid_threshold" + elif threshold_limits_overlap( + # Blank fields keep the stored value — judge the merged pair (#156). + above if above is not None else tc.get(CONF_TRIGGER_ABOVE), + below if below is not None else tc.get(CONF_TRIGGER_BELOW), + ): + errors["base"] = "overlapping_threshold" else: - tc = self._current_task["trigger_config"] if above is not None: tc[CONF_TRIGGER_ABOVE] = above if below is not None: @@ -967,6 +974,7 @@ class TriggerConfigMixin: ) -> ConfigFlowResult: """Configure a compound condition's type-specific settings.""" cond = self._current_compound_condition + errors: dict[str, str] = {} if user_input is not None: cancel = await self._mixin_check_go_back(user_input) @@ -976,19 +984,24 @@ class TriggerConfigMixin: if condition_type == TriggerType.THRESHOLD: above = user_input.get(CONF_TRIGGER_ABOVE) below = user_input.get(CONF_TRIGGER_BELOW) - if above is not None: - cond["trigger_above"] = above - if below is not None: - cond["trigger_below"] = below - equals = user_input.get(CONF_TRIGGER_EQUALS) - if equals is not None: - cond["trigger_equals"] = equals - not_equals = user_input.get(CONF_TRIGGER_NOT_EQUALS) - if not_equals is not None: - cond["trigger_not_equals"] = not_equals - for_min = user_input.get(CONF_TRIGGER_FOR_MINUTES) - if for_min: - cond["trigger_for_minutes"] = for_min + if threshold_limits_overlap(above, below): + # Store nothing from the refused attempt: a field left + # blank on the retry must not inherit its value (#156). + errors["base"] = "overlapping_threshold" + else: + if above is not None: + cond["trigger_above"] = above + if below is not None: + cond["trigger_below"] = below + equals = user_input.get(CONF_TRIGGER_EQUALS) + if equals is not None: + cond["trigger_equals"] = equals + not_equals = user_input.get(CONF_TRIGGER_NOT_EQUALS) + if not_equals is not None: + cond["trigger_not_equals"] = not_equals + for_min = user_input.get(CONF_TRIGGER_FOR_MINUTES) + if for_min: + cond["trigger_for_minutes"] = for_min elif condition_type == TriggerType.COUNTER: cond["trigger_target_value"] = user_input.get(CONF_TRIGGER_TARGET_VALUE, 0) cond["trigger_delta_mode"] = user_input.get(CONF_TRIGGER_DELTA_MODE, False) @@ -1010,9 +1023,10 @@ class TriggerConfigMixin: if len(entity_ids) > 1 and user_input.get(CONF_TRIGGER_ENTITY_LOGIC): cond["entity_logic"] = user_input[CONF_TRIGGER_ENTITY_LOGIC] - self._compound_conditions.append(cond) - self._current_compound_condition = {} - return await on_complete() + if not errors: + self._compound_conditions.append(cond) + self._current_compound_condition = {} + return await on_complete() schema_fields: dict[Any, Any] = {} if condition_type == TriggerType.THRESHOLD: @@ -1082,6 +1096,7 @@ class TriggerConfigMixin: return self.async_show_form( step_id=step_id, data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)), + errors=errors, ) async def _trigger_compound_review( diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/_test-utils.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/_test-utils.ts index 7a494895..7a4d00b2 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/_test-utils.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/_test-utils.ts @@ -114,6 +114,23 @@ export interface CreateMockHassResult { subscriptions: Array<{ msg: SentMessage; push: (event: unknown) => void }>; } +/** + * Pick a value in an `` the way HA's selector would report it + * (#163): the wrapper renders `` (undefined under web-test-runner, + * so it is an inert unknown element) and converts the selector's own value + * format — "YYYY-MM-DD", "HH:MM:SS", "YYYY-MM-DD HH:MM:SS" — into the value + * contract the consumers store. Dispatching on the inner selector exercises + * that conversion end-to-end. + */ +export function pickDateField(field: Element | null, haValue: string | undefined): void { + if (!field) throw new Error("pickDateField: no "); + const sel = field.shadowRoot?.querySelector("ha-selector"); + if (!sel) throw new Error("pickDateField: rendered no "); + sel.dispatchEvent(new CustomEvent("value-changed", { + bubbles: true, composed: true, detail: { value: haValue }, + })); +} + export interface CreateMockHassOptions { /** Override the default settings response (deep-merge not done — pass full shape). */ settingsResponse?: typeof DEFAULT_SETTINGS_RESPONSE; 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 827e431d..0a6abf12 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 @@ -10,7 +10,7 @@ import { expect, fixture, html } from "@open-wc/testing"; import "../components/complete-dialog.js"; import type { MaintenanceCompleteDialog } from "../components/complete-dialog"; -import { createMockHass } from "./_test-utils.js"; +import { createMockHass, pickDateField } from "./_test-utils.js"; type MountOpts = { checklist?: string[]; @@ -104,12 +104,28 @@ describe("complete-dialog", () => { expect("completed_at" in msg).to.be.false; }); + /** The optional backdate starts as a "Set date & time" button (#163): the + * HA datetime selector has no empty state, so the field only appears once + * the user asks for it, seeded with the current minute. */ + async function openBackdate(el: MaintenanceCompleteDialog) { + const pick = el.shadowRoot!.querySelector(".backdate-pick")!; + expect(pick, "backdate seed button rendered").to.exist; + expect(el.shadowRoot!.querySelector("ms-date-field"), "no field before the seed click").to.be.null; + pick.click(); + await el.updateComplete; + const field = el.shadowRoot!.querySelector("ms-date-field")!; + expect(field, "backdate field rendered after the seed click").to.exist; + await (field as unknown as { updateComplete: Promise }).updateComplete; + return field; + } + it("sends completed_at with seconds re-added when a backdate is picked (#133)", async () => { const { el, sent } = await mount(); - const dt = el.shadowRoot!.querySelector('input[type="datetime-local"]')!; - expect(dt, "backdate field rendered").to.exist; - dt.value = "2026-01-10T14:30"; - dt.dispatchEvent(new Event("change")); + const field = await openBackdate(el); + // Seeded with the current minute, in the contract format. + expect((field as unknown as { value: string }).value).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00$/); + // HA's datetime selector speaks "YYYY-MM-DD HH:MM:SS". + pickDateField(field, "2026-01-10 14:30:00"); await el.updateComplete; clickComplete(el); @@ -124,10 +140,9 @@ describe("complete-dialog", () => { const { el, sent } = await mount(); const future = new Date(Date.now() + 48 * 3600 * 1000); const pad = (n: number) => String(n).padStart(2, "0"); - const v = `${future.getFullYear()}-${pad(future.getMonth() + 1)}-${pad(future.getDate())}T12:00`; - const dt = el.shadowRoot!.querySelector('input[type="datetime-local"]')!; - dt.value = v; - dt.dispatchEvent(new Event("change")); + const v = `${future.getFullYear()}-${pad(future.getMonth() + 1)}-${pad(future.getDate())} 12:00:00`; + const field = await openBackdate(el); + pickDateField(field, v); await el.updateComplete; clickComplete(el); @@ -245,7 +260,9 @@ describe("complete-dialog cost suggestion from parts (#104 follow-up)", () => { c.click(); await el.updateComplete; const cost = [...el.shadowRoot!.querySelectorAll(".field-input")][1]; - expect(cost.value).to.equal("17.00"); + // Machine value for the — never profile-formatted + // ("17,00" would be rejected by the input under a decimal_comma profile). + expect(cost.value).to.equal("17"); expect(chip(el), "chip hides once cost is set").to.equal(null); }); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/date-format.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/date-format.test.ts index 21af0736..bb5ffd74 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/date-format.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/date-format.test.ts @@ -3,31 +3,32 @@ * English UI always showed mm/dd/yyyy even with dd/mm/yyyy configured). */ import { expect } from "@open-wc/testing"; -import { formatDate, formatDateTime, setDateTimePrefs } from "../styles"; +import { formatDate, formatDateTime, formatDateShort, formatTimeOfDay, formatWeekday, formatMonth, weekdayName, monthName, setProfilePrefs } from "../styles"; +import { fmtDateTick, fmtDateTime } from "../renderers/chart-utils"; -const reset = () => setDateTimePrefs({ date_format: undefined, time_format: undefined }, null); +const reset = () => setProfilePrefs({ date_format: undefined, time_format: undefined, number_format: undefined }, null); describe("formatDate with HA profile date_format (#97)", () => { afterEach(reset); it("DMY renders dd/mm/yyyy regardless of language", () => { - setDateTimePrefs({ date_format: "DMY" }); + setProfilePrefs({ date_format: "DMY" }); expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026"); expect(formatDate("2026-08-10", "de")).to.equal("10/08/2026"); }); it("MDY renders mm/dd/yyyy regardless of language", () => { - setDateTimePrefs({ date_format: "MDY" }); + setProfilePrefs({ date_format: "MDY" }); expect(formatDate("2026-08-10", "de")).to.equal("08/10/2026"); }); it("YMD renders yyyy-mm-dd", () => { - setDateTimePrefs({ date_format: "YMD" }); + setProfilePrefs({ date_format: "YMD" }); expect(formatDate("2026-08-10", "en")).to.equal("2026-08-10"); }); it('"language" / unset keeps the language-derived format (backwards compatible)', () => { - setDateTimePrefs({ date_format: "language" }); + setProfilePrefs({ date_format: "language" }); expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026"); // en → en-US expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026"); // de → de-DE reset(); @@ -35,7 +36,7 @@ describe("formatDate with HA profile date_format (#97)", () => { }); it("pl/cs/sv derive their own locale, not en-US (live-check find)", () => { - setDateTimePrefs({ date_format: "language" }); + setProfilePrefs({ date_format: "language" }); // These three were missing from langToLocale and silently rendered // US-ordered dates. Day must come before month for all of them. expect(formatDate("2026-08-10", "pl")).to.equal("10.08.2026"); @@ -45,7 +46,7 @@ describe("formatDate with HA profile date_format (#97)", () => { }); it("null/invalid input unchanged by prefs", () => { - setDateTimePrefs({ date_format: "DMY" }); + setProfilePrefs({ date_format: "DMY" }); expect(formatDate(null, "en")).to.equal("—"); }); }); @@ -54,12 +55,12 @@ describe("formatDateTime with HA profile time_format (#97)", () => { afterEach(reset); it("24 forces 24h clock even for en", () => { - setDateTimePrefs({ date_format: "DMY", time_format: "24" }); + setProfilePrefs({ date_format: "DMY", time_format: "24" }); expect(formatDateTime("2026-08-10T14:30:00", "en")).to.equal("10/08/2026 14:30"); }); it("12 forces a 12h clock even for de", () => { - setDateTimePrefs({ date_format: "DMY", time_format: "12" }); + setProfilePrefs({ date_format: "DMY", time_format: "12" }); const out = formatDateTime("2026-08-10T14:30:00", "de"); expect(out.startsWith("10/08/2026 ")).to.be.true; expect(out).to.match(/2:30/); @@ -71,34 +72,73 @@ describe("server country regionalizes the language default (#140)", () => { afterEach(reset); it('"en" + AU renders dd/mm/yyyy without any profile setting', () => { - setDateTimePrefs({ date_format: "language" }, "AU"); + setProfilePrefs({ date_format: "language" }, "AU"); expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026"); }); it('"en" + US keeps mm/dd/yyyy; "de" + AT stays dd.mm.yyyy', () => { - setDateTimePrefs({ date_format: "language" }, "US"); + setProfilePrefs({ date_format: "language" }, "US"); expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026"); - setDateTimePrefs({ date_format: "language" }, "AT"); + setProfilePrefs({ date_format: "language" }, "AT"); expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026"); }); it("an explicit profile format always beats the country", () => { - setDateTimePrefs({ date_format: "MDY" }, "AU"); + setProfilePrefs({ date_format: "MDY" }, "AU"); expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026"); - setDateTimePrefs({ date_format: "YMD" }, "AU"); + setProfilePrefs({ date_format: "YMD" }, "AU"); expect(formatDate("2026-08-10", "en")).to.equal("2026-08-10"); }); it("garbage/unset country falls back to the language mapping", () => { - setDateTimePrefs({ date_format: "language" }, "!!"); + setProfilePrefs({ date_format: "language" }, "!!"); expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026"); - setDateTimePrefs({ date_format: "language" }, null); + setProfilePrefs({ date_format: "language" }, null); expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026"); }); it("country survives a later prefs update that omits it (undefined ≠ clear)", () => { - setDateTimePrefs({ date_format: "language" }, "AU"); - setDateTimePrefs({ date_format: "language" }); + setProfilePrefs({ date_format: "language" }, "AU"); + setProfilePrefs({ date_format: "language" }); expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026"); }); }); + +describe("name-style helpers + chart labels route through styles.ts (#163 DRY)", () => { + afterEach(reset); + + it("formatTimeOfDay follows the profile time format", () => { + const d = new Date(2026, 7, 10, 14, 30); + setProfilePrefs({ time_format: "24" }); + expect(formatTimeOfDay(d, "en")).to.equal("14:30"); + setProfilePrefs({ time_format: "12" }); + expect(formatTimeOfDay(d, "de")).to.match(/2:30/); + expect(formatTimeOfDay(d, "de").toLowerCase()).to.match(/pm|nachm/); + }); + + it("formatDateShort: day+month in the UI language, optional 2-digit year", () => { + const d = new Date(2026, 6, 3); + expect(formatDateShort(d, "en")).to.equal("Jul 3"); + expect(formatDateShort(d, "de")).to.equal("3. Juli"); + expect(formatDateShort(d, "en", true)).to.match(/^Jul 3, ?26$/); + }); + + it("weekday / month names come from the UI language, indexed Mon=0 / Jan=0", () => { + expect(weekdayName(0, "en", "short")).to.equal("Mon"); + expect(weekdayName(6, "de", "long")).to.equal("Sonntag"); + expect(monthName(0, "en", "short")).to.equal("Jan"); + expect(monthName(11, "de", "long")).to.equal("Dezember"); + const sun = new Date(2026, 6, 5); // 2026-07-05 is a Sunday + expect(formatWeekday(sun, "en", "short")).to.equal("Sun"); + expect(formatMonth(sun, "de", "long")).to.equal("Juli"); + }); + + it("chart crosshair label honours the profile time format (was browser/language-only)", () => { + const ts = new Date(2026, 6, 3, 14, 30).getTime(); + setProfilePrefs({ time_format: "24" }); + expect(fmtDateTime(ts, "en")).to.equal("Jul 3, 14:30"); + setProfilePrefs({ time_format: "12" }); + expect(fmtDateTime(ts, "de")).to.match(/^3\. Juli, .*2:30/); + expect(fmtDateTick(ts, "en", false)).to.equal("Jul 3"); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/settings-view-vacation.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/settings-view-vacation.test.ts index e1d6dfc8..f86d6590 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/settings-view-vacation.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/settings-view-vacation.test.ts @@ -89,10 +89,10 @@ describe("settings-view vacation section", () => { vacationStart: "2099-06-10", vacationEnd: "2099-06-20", }); - const dateInputs = vacationSection(el)!.querySelectorAll(".vac-grid input[type=date]"); - expect(dateInputs.length, "two date inputs").to.equal(2); - expect(dateInputs[0].value).to.equal("2099-06-10"); - expect(dateInputs[1].value).to.equal("2099-06-20"); + const dateFields = vacationSection(el)!.querySelectorAll(".vac-grid ms-date-field"); + expect(dateFields.length, "two date fields").to.equal(2); + expect(dateFields[0].value).to.equal("2099-06-10"); + expect(dateFields[1].value).to.equal("2099-06-20"); }); it("shows the active badge when vacation.is_active is true", async () => { diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/storage-section-card.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/storage-section-card.test.ts index 3821175d..fe60cf71 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/storage-section-card.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/storage-section-card.test.ts @@ -60,6 +60,17 @@ describe("storage-section-card", () => { expect(el.shadowRoot!.querySelector("ha-card"), "hidden when empty").to.not.exist; }); + it("survives a malformed summary (no by_object) without throwing", async () => { + // A generic WS stub answering `{}` used to reach Object.entries(undefined) + // in render(); the card must hide itself instead of erroring. + const bare = await mount({}); + expect(bare.shadowRoot!.querySelector("ha-card"), "hidden for {}").to.not.exist; + const partial = await mount({ ...SUMMARY, by_object: undefined }); + await expand(partial); + expect(partial.shadowRoot!.querySelector("ha-card"), "card renders").to.exist; + expect(partial.shadowRoot!.querySelectorAll(".obj-row").length).to.equal(0); + }); + it("navigates to an object when its row is clicked", async () => { const el = await mount(); await expand(el); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-renderer.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-renderer.test.ts index fea261d1..cc29316c 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-renderer.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-renderer.test.ts @@ -150,11 +150,17 @@ describe("task-detail renderer", () => { promptSkip: () => { skipped++; }, })); const buttons = [...host.querySelectorAll(".task-header-actions ha-button")]; - (buttons[0] as HTMLElement).click(); // Complete (filled) + (buttons[0] as HTMLElement).click(); // Complete (accent/success) (buttons[1] as HTMLElement).click(); // Skip expect(completed).to.not.be.null; expect(completed!.name).to.equal("Filter Wechsel"); expect(skipped).to.equal(1); + // Same pairing as the task rows and the quick-actions dialog (#150 + // follow-up): filled green Complete, outlined orange Skip. + expect(buttons[0].getAttribute("appearance")).to.equal("accent"); + expect(buttons[0].getAttribute("variant")).to.equal("success"); + expect(buttons[1].getAttribute("appearance")).to.equal("outlined"); + expect(buttons[1].getAttribute("variant")).to.equal("warning"); }); it("operator mode: only Complete/Skip buttons; menu carries just QR + worksheet", () => { diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-hint.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-hint.test.ts index a1c8606f..dce5d8cd 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-hint.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-hint.test.ts @@ -75,6 +75,44 @@ describe("task-dialog live trigger hint", () => { expect(hint(el)).to.equal(null); }); + it("#156: below > above shows the overlap warning and blocks save (the limits are OR-ed)", async () => { + const { hass, sent } = createMockHass({ + states: { "sensor.low_count": { state: "1", attributes: { unit_of_measurement: "batteries" } } }, + handlers: { "maintenance_supporter/task/create": () => ({ task_id: "new1" }) }, + }); + const el = await fixture(html` + + `); + el.openCreate("e1", []); + await el.updateComplete; + (el as any)._scheduleType = "sensor_based"; + await el.updateComplete; + (el as any)._name = "Replace batteries"; + (el as any)._triggerEntityId = "sensor.low_count"; + (el as any)._triggerEntityIds = ["sensor.low_count"]; + (el as any)._triggerType = "threshold"; + (el as any)._triggerAbove = "0"; + (el as any)._triggerBelow = "5"; + await el.updateComplete; + const warn = el.shadowRoot!.querySelector(".trigger-live-hint.warn"); + expect(warn, "overlap warning rendered").to.not.equal(null); + expect(warn!.textContent).to.include("can never recover"); + // The regular hint still lists both limits above the warning. + expect(hint(el)).to.include("0 batteries"); + + await (el as any)._save(); + expect(sent.some((m) => m.type === "maintenance_supporter/task/create"), "save blocked").to.be.false; + expect(el.shadowRoot!.querySelector(".error")?.textContent).to.include("can never recover"); + + // A real band (below < above) or equal limits carry no warning. + (el as any)._triggerBelow = "0"; + await el.updateComplete; + expect(el.shadowRoot!.querySelector(".trigger-live-hint.warn")).to.equal(null); + (el as any)._triggerAbove = "5"; + await el.updateComplete; + expect(el.shadowRoot!.querySelector(".trigger-live-hint.warn")).to.equal(null); + }); + it("editing a delta task uses the since-last-completion wording (baseline is not the current reading)", async () => { const { hass } = createMockHass({ states: { "sensor.pump_hours": { state: "660", attributes: { unit_of_measurement: "h" } } }, diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/trigger-progress-delta.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/trigger-progress-delta.test.ts index 61469bff..9195ff5c 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/trigger-progress-delta.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/trigger-progress-delta.test.ts @@ -37,18 +37,18 @@ describe("renderTriggerProgress — delta-mode counter (issue #102)", () => { it("uses the exposed delta when present", async () => { const label = await labelOf(row({ trigger_current_delta: 100 })); - expect(label).to.contain("100.0 / 15000"); + expect(label).to.contain("100.0 / 15,000"); }); it("computes the delta from the baseline when only the baseline is exposed", async () => { const label = await labelOf(row({ trigger_baseline_value: 27000 })); - expect(label).to.contain("0.0 / 15000"); + expect(label).to.contain("0.0 / 15,000"); }); it("keeps the raw value for absolute-mode counters", async () => { const label = await labelOf( row({ trigger_config: { type: "counter", trigger_target_value: 30000 } as never }), ); - expect(label).to.contain("27000.0 / 30000"); + expect(label).to.contain("27,000.0 / 30,000"); }); }); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/vacation-section-card.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/vacation-section-card.test.ts index 0d2a3a3e..5a7778a3 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/vacation-section-card.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/vacation-section-card.test.ts @@ -12,7 +12,7 @@ import { expect, fixture, html } from "@open-wc/testing"; import "../components/vacation-section-card.js"; import type { MaintenanceVacationSectionCard } from "../components/vacation-section-card"; -import { createMockHass } from "./_test-utils.js"; +import { createMockHass, pickDateField } from "./_test-utils.js"; const BASE_STATE = { enabled: false, @@ -51,7 +51,7 @@ describe("vacation-section-card", () => { const root = el.shadowRoot!; expect(root.querySelector("ha-card"), "card rendered").to.exist; expect(root.querySelector("ha-switch"), "no enable switch").to.be.null; - expect(root.querySelector('input[type="date"]'), "no date inputs").to.be.null; + expect(root.querySelector("ms-date-field"), "no date fields").to.be.null; expect(root.querySelector(".actions"), "no action buttons").to.be.null; }); @@ -71,11 +71,11 @@ describe("vacation-section-card", () => { it("editing dates + Save dispatches start/end/buffer_days", async () => { const { el, sent } = await mount({ isAdmin: true }); const root = el.shadowRoot!; - const [start, end] = [...root.querySelectorAll('input[type="date"]')]; - start.value = "2026-09-01"; - start.dispatchEvent(new Event("input")); - end.value = "2026-09-10"; - end.dispatchEvent(new Event("input")); + const [start, end] = [...root.querySelectorAll("ms-date-field")]; + expect(end, "two date fields").to.exist; + await Promise.all([start, end].map((f) => (f as unknown as { updateComplete: Promise }).updateComplete)); + pickDateField(start, "2026-09-01"); + pickDateField(end, "2026-09-10"); const buffer = root.querySelector('input[type="number"]')!; buffer.value = "3"; buffer.dispatchEvent(new Event("input")); 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 aaf8bb66..d83c1808 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 @@ -6,9 +6,10 @@ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale, langOf } from "../styles"; +import { t, ensureLocale, langOf, formatDate, syncLocaleFromHass } from "../styles"; import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys"; import { describeWsError } from "../ws-errors"; +import { px } from "../renderers/chart-utils"; import type { HomeAssistant } from "../types"; interface BatteryRow { @@ -100,6 +101,11 @@ export class MaintenanceBatteryFleetSection extends LitElement { } updated(changed: Map): void { + // #163: the forecast dates go through formatDate(), which reads the HA + // profile date format from a window singleton — feed it here too, so a + // stand-alone battery-fleet card (no panel / task card on the page to do + // it) still renders DD/MM vs MM/DD the way the profile says. + syncLocaleFromHass(this, changed); if (changed.has("hass") && this.hass && !this._localeReady) { this._localeReady = true; ensureLocale(this._lang).then(() => this.requestUpdate()); @@ -257,9 +263,9 @@ export class MaintenanceBatteryFleetSection extends LitElement { const tMax = Math.max(tLast, projEnd ?? tLast); const x = (t: number) => (tMax === t0 ? P : P + ((t - t0) / (tMax - t0)) * (W - 2 * P)); const y = (v: number) => P + (1 - Math.min(100, Math.max(0, v)) / 100) * (H - 2 * P); - const line = h.points.map(([t, v]) => `${x(t).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + const line = h.points.map(([t, v]) => `${px(x(t))},${px(y(v))}`).join(" "); const vLast = h.points[h.points.length - 1][1]; - const yTh = y(h.threshold).toFixed(1); + const yTh = px(y(h.threshold)); return html`` : nothing} @@ -314,8 +320,13 @@ export class MaintenanceBatteryFleetSection extends LitElement { return this._fmtDate(Date.now() + daysUntil * 864e5); } + /** Local calendar date of an epoch, in the HA profile date format (#163 — + * a direct Intl.DateTimeFormat call here ignored the profile and showed + * 9/2/2026 to a DD/MM/YYYY user). */ private _fmtDate(epochMs: number): string { - return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(new Date(epochMs)); + const d = new Date(epochMs); + const pad = (n: number) => String(n).padStart(2, "0"); + return formatDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, this._lang); } /** The grouped shopping quantities as CLICKABLE chips: a type filters the diff --git a/custom_components/maintenance_supporter/frontend-src/components/budget-section-card.ts b/custom_components/maintenance_supporter/frontend-src/components/budget-section-card.ts index d7361eae..e1012af1 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/budget-section-card.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/budget-section-card.ts @@ -8,7 +8,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles"; +import { t, syncLocaleFromHass, DEFAULT_CURRENCY_SYMBOL, langOf, formatNumber, formatCost } from "../styles"; import { registerCustomCard } from "../helpers/register-card"; import { describeWsError } from "../ws-errors"; import { sectionCardSharedStyles } from "./section-card-shared-styles"; @@ -50,10 +50,11 @@ export class MaintenanceBudgetSectionCard extends LitElement { updated(changedProps: Map): void { super.updated(changedProps); + // Standalone card: nothing else feeds the profile number format in (#163). + syncLocaleFromHass(this, changedProps); if (changedProps.has("hass") && this.hass && !this._loaded) { this._loaded = true; void this._load(); - void ensureLocale(this._lang).then(() => this.requestUpdate()); } } @@ -147,7 +148,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
- ${track.spent.toFixed(0)} ${sym} + ${formatCost(track.spent, sym, L, 0)}
`; @@ -159,7 +160,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
- ${track.spent.toFixed(0)} / ${track.budget.toFixed(0)} ${sym} + ${formatNumber(track.spent, L, 0)} / ${formatCost(track.budget, sym, L, 0)}
diff --git a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts index 36e2f77f..7660f739 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts @@ -3,10 +3,11 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { HomeAssistant, TaskPartLink } from "../types"; -import { t, nativeFieldStyles } from "../styles"; +import { t, nativeFieldStyles, formatCost } from "../styles"; import { describeWsError } from "../ws-errors"; import { partLinkKey, type LinkedPart } from "../helpers/shared-parts"; import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels"; +import "./ms-date-field"; export class MaintenanceCompleteDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -65,7 +66,7 @@ export class MaintenanceCompleteDialog extends LitElement { @state() private _photoUploading = false; @state() private _readingValue = ""; @state() private _restockQty = ""; - /** #133: optional backdated completion moment (datetime-local value; "" = now). */ + /** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = now). */ @state() private _completedAt = ""; /** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two * objects can carry the same part id, so part_id alone would merge pools. */ @@ -289,11 +290,11 @@ export class MaintenanceCompleteDialog extends LitElement { 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}` : ""}`; + const amount = formatCost(suggestion, this.currencySymbol, L); return html``; } @@ -301,6 +302,13 @@ export class MaintenanceCompleteDialog extends LitElement { this._open = false; } + /** Seed the backdate field with the current minute (local, seconds zeroed). */ + private _pickCompletedAt(): void { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + this._completedAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`; + } + render() { if (!this._open) return html``; const L = this.lang || this.hass?.language || "en"; @@ -409,13 +417,21 @@ export class MaintenanceCompleteDialog extends LitElement { .value=${this._duration} @input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} /> -