diff --git a/custom_components/maintenance_supporter/__init__.py b/custom_components/maintenance_supporter/__init__.py index 51b4a64f..f50052f6 100644 --- a/custom_components/maintenance_supporter/__init__.py +++ b/custom_components/maintenance_supporter/__init__.py @@ -60,6 +60,7 @@ from .const import ( CONF_WEEKLY_DIGEST_ENABLED, DEFAULT_PANEL_ENABLED, DEFAULT_WARNING_DAYS, + DOCUMENT_TEXT_INDEX_KEY, DOMAIN, EVENT_UNSUBS_KEY, GLOBAL_UNIQUE_ID, @@ -137,12 +138,15 @@ type MaintenanceSupporterConfigEntry = ConfigEntry[MaintenanceSupporterData] # --- Service Schemas --- SERVICE_COMPLETE_SCHEMA = vol.Schema( { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_ENTITY_ID): vol.All(cv.entity_ids, vol.Length(min=1)), vol.Optional("notes"): vol.All(cv.string, vol.Length(max=2000)), vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)), vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)), # Meter readings (v2.20, #83): recorded value for `reading` tasks. vol.Optional("reading_value"): vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), + # #161 phase 2: {reading name: value} for a task with reading slots — + # automations know meters by name; resolved case-insensitively. + vol.Optional("reading_values"): {cv.string: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)}, # #128: who did it — a person ENTITY (validated picker, no free text); # resolved to the linked HA user id. Omitted -> the calling user. vol.Optional("completed_by"): cv.entity_id, @@ -155,14 +159,14 @@ SERVICE_COMPLETE_SCHEMA = vol.Schema( SERVICE_RESET_SCHEMA = vol.Schema( { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_ENTITY_ID): vol.All(cv.entity_ids, vol.Length(min=1)), vol.Optional("date"): cv.date, } ) SERVICE_SKIP_SCHEMA = vol.Schema( { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_ENTITY_ID): vol.All(cv.entity_ids, vol.Length(min=1)), vol.Optional("reason"): vol.All(cv.string, vol.Length(max=2000)), } ) @@ -455,6 +459,24 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: await doc_store.async_load() hass.data[DOMAIN][DOCUMENT_STORE_KEY] = doc_store + # Full-text index over the blobs (#171): extraction meta loads now, the + # sidecars are read on the first search, new blobs are extracted as they + # arrive and the existing library is backfilled after HA started. + from .helpers.document_text import DocumentTextIndex + + text_index = DocumentTextIndex(hass, doc_store) + await text_index.async_load() + doc_store.text_index = text_index + hass.data[DOMAIN][DOCUMENT_TEXT_INDEX_KEY] = text_index + + # Reference numbers (#170): the object-level counter lives in its own + # small Store; objects are numbered when their entry is set up. + from .helpers.reference_numbers import REFERENCE_NUMBERS_KEY, ReferenceNumbers + + reference_numbers = ReferenceNumbers(hass) + await reference_numbers.async_load() + hass.data[DOMAIN][REFERENCE_NUMBERS_KEY] = reference_numbers + # Authenticated upload + serve endpoints for document blobs (the blobs live # under /config, so they must never be exposed via an unauthenticated path). @@ -536,11 +558,15 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: # Per-task values must not fan out: a reading belongs to ONE # meter, cost/duration would multiply into the budget and the # averages, and one physical scan proves exactly one tag. - for field in ("reading_value", "cost", "duration", "via_tag_scan"): + for field in ("reading_value", "reading_values", "cost", "duration"): if call.data.get(field) is not None: raise ServiceValidationError( f"'{field}' can only be used with a single entity_id" ) + # An explicit ``via_tag_scan: false`` claims nothing — only a + # TRUE flag is the one-tag-one-task proof that must not fan out. + if call.data.get("via_tag_scan"): + raise ServiceValidationError("'via_tag_scan' can only be used with a single entity_id") # #128: explicit person beats the call context; the context covers the # common case for free (a dashboard tap propagates the tapping user). if call.data.get("completed_by"): @@ -549,12 +575,29 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: completed_by = call.context.user_id if call.context else None async def _one(coordinator: MaintenanceCoordinator, task_id: str) -> None: + reading_values = None + if call.data.get("reading_values"): + from .helpers.reading_slots import resolve_reading_values_by_name + + slot_task = coordinator._get_merged_tasks_data().get(task_id) or {} + try: + reading_values = ( + resolve_reading_values_by_name( + slot_task.get("readings") or [], + call.data["reading_values"], + default_unit=slot_task.get("reading_unit"), + ) + or None + ) + except ValueError as err: + raise ServiceValidationError(str(err)) from err await coordinator.complete_maintenance( task_id=task_id, notes=call.data.get("notes"), cost=call.data.get("cost"), duration=call.data.get("duration"), reading_value=call.data.get("reading_value"), + reading_values=reading_values, completed_by=completed_by, completed_at=call.data.get("completed_at"), # Proof of presence: an automation that reacted to the physical @@ -1409,6 +1452,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf # HA-started so the store has finished loading. entry.async_on_unload(async_at_started(hass, _check_document_storage_issues)) + # Backfill the document search index (#171) a couple of minutes after + # start — pypdf is CPU work, and boot is the wrong moment for it. + entry.async_on_unload(async_at_started(hass, _schedule_document_text_backfill)) + # A global entry now exists — clear the orphan repair issue immediately # (e.g. right after the repair flow recreated it, when HA is already # started and the async_at_started check above won't fire again). @@ -1544,6 +1591,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf if retranslate_seeded_texts(hass, entry, normalize_language(hass)): _LOGGER.info("Retranslated the battery fleet's seeded texts for %s", entry.title) + # #170: give this object (and any other still unnumbered one) its + # reference number before the first refresh numbers the tasks. + from .helpers.reference_numbers import REFERENCE_NUMBERS_KEY, ReferenceNumbers + + reference_numbers = hass.data[DOMAIN].get(REFERENCE_NUMBERS_KEY) + if isinstance(reference_numbers, ReferenceNumbers): + await reference_numbers.async_assign_objects() + coordinator = MaintenanceCoordinator(hass, entry, store) entry.runtime_data = MaintenanceSupporterData(coordinator=coordinator, store=store) await coordinator.async_config_entry_first_refresh() @@ -1573,10 +1628,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf result["pruned"], result["orphans_removed"], ) - if result.get("trigger_healed"): + if result.get("trigger_healed") or result["added"]: # #156: the recovery flag only reaches the LIVE trigger - # after a reload — do it once; the next start finds it set. - _LOGGER.info("Battery-fleet trigger healed (auto_complete_on_recovery) — reloading %s", entry.title) + # after a reload — and so does the stock SENSOR of a part + # added here: the sensor platform was set up before this + # ran, so the new part had no entity until the next + # restart (bug review 2026-09-04). Reload once; the next + # start finds both in place and adds nothing. + _LOGGER.info( + "Battery-fleet reconcile needs a reload of %s (trigger healed: %s, parts added: %s)", + entry.title, + bool(result.get("trigger_healed")), + result["added"] or "none", + ) hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) entry.async_on_unload(async_at_started(hass, _fleet_parts_at_start)) @@ -1693,6 +1757,16 @@ def _verify_notify_service(hass: HomeAssistant) -> None: _DOC_STORAGE_ISSUE_ID = "document_storage_issues" +@callback +def _schedule_document_text_backfill(hass: HomeAssistant) -> None: + """Kick off the delayed full-text backfill (see helpers/document_text).""" + from .helpers.document_text import DocumentTextIndex + + index = hass.data.get(DOMAIN, {}).get(DOCUMENT_TEXT_INDEX_KEY) + if isinstance(index, DocumentTextIndex): + index.schedule_backfill() + + async def _check_document_storage_issues(hass: HomeAssistant) -> None: """Sync the document-storage repair issue with the current on-disk reality. @@ -1912,6 +1986,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon shopping_sync = hass.data.get(DOMAIN, {}).pop(SHOPPING_SYNC_KEY, None) if shopping_sync is not None: shopping_sync.async_teardown() + text_index = hass.data.get(DOMAIN, {}).get(DOCUMENT_TEXT_INDEX_KEY) + if text_index is not None: + text_index.cancel() # Flush a pending debounced store save BEFORE tearing down — belt and # suspenders next to the store cache: disk is current the moment the entry @@ -2020,11 +2097,15 @@ def _get_coordinator_for_entity(hass: HomeAssistant, entity_id: str) -> Maintena return None config_entry = hass.config_entries.async_get_entry(config_entry_id) - if config_entry is None: + # Only OUR entries: a foreign integration's entry may carry its own + # runtime_data, and reading ``.coordinator`` off it raised a bare + # AttributeError that escaped the per-task error collection and aborted + # the rest of a multi-entity call (bug review 2026-09-04). + if config_entry is None or config_entry.domain != DOMAIN: return None - runtime_data: MaintenanceSupporterData | None = getattr(config_entry, "runtime_data", None) - if runtime_data is None: + runtime_data = getattr(config_entry, "runtime_data", None) + if not isinstance(runtime_data, MaintenanceSupporterData): return None return runtime_data.coordinator diff --git a/custom_components/maintenance_supporter/config_flow_helpers.py b/custom_components/maintenance_supporter/config_flow_helpers.py index 371e8833..2b2d68c6 100644 --- a/custom_components/maintenance_supporter/config_flow_helpers.py +++ b/custom_components/maintenance_supporter/config_flow_helpers.py @@ -32,6 +32,11 @@ _LOGGER = logging.getLogger(__name__) # Hardcoded English labels for the weekday/occurrence sub-options keep the # config-flow i18n surface small; the kind names are translated via strings.json. CALENDAR_KIND_VALUES = (KIND_WEEKDAYS, KIND_NTH_WEEKDAY, KIND_DAY_OF_MONTH) +# #168: every kind that produces a due DATE takes a time of day — the +# interval, the three calendar kinds and a one-off. Sensor-based and +# manual tasks have no due date to refine. Mirrors SCHEDULE_TIME_KINDS in +# the panel's task-dialog.ts. +SCHEDULE_TIME_KINDS = ("time_based", "one_time", *CALENDAR_KIND_VALUES) _WEEKDAY_LABELS = ( "Monday", "Tuesday", diff --git a/custom_components/maintenance_supporter/config_flow_options_global.py b/custom_components/maintenance_supporter/config_flow_options_global.py index 31dc11f2..a4f77d62 100644 --- a/custom_components/maintenance_supporter/config_flow_options_global.py +++ b/custom_components/maintenance_supporter/config_flow_options_global.py @@ -369,7 +369,7 @@ async def _send_test_to( ) -> str: """Send the test payload to every resolved service; "success" if any went.""" try: - from .helpers.notification_manager import async_dispatch_notify + from .helpers.notify_hooks import KIND_TEST, async_emit_and_dispatch, notification_context push_msg = _get_test_result_text(hass, "push_message") service_data: dict[str, Any] = { @@ -389,9 +389,12 @@ async def _send_test_to( test_actions.append({"action": "MS_TEST_SNOOZE", "title": "\U0001f4a4 Snooze"}) service_data["data"] = {"actions": test_actions} # Dual-path: legacy notify service OR notify entity (send_message). + # #165: the test walks the same hook as a real notification, so the + # event and the extra-data template can be verified from Settings. + context = notification_context(hass, KIND_TEST) sent_any = False for service in services: - if await async_dispatch_notify(hass, service, service_data, blocking=True): + if await async_emit_and_dispatch(hass, service, service_data, context, blocking=True): sent_any = True return "success" if sent_any else "failed" except Exception: # noqa: BLE001 - any failure mode reports "failed" to the UI diff --git a/custom_components/maintenance_supporter/config_flow_options_task_crud.py b/custom_components/maintenance_supporter/config_flow_options_task_crud.py index 48ca85d0..08f9cd1d 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_crud.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_crud.py @@ -10,6 +10,7 @@ from homeassistant.helpers import selector from .config_flow_helpers import ( CALENDAR_KIND_VALUES, + SCHEDULE_TIME_KINDS, apply_season_ends, calendar_current, calendar_schema, @@ -49,6 +50,7 @@ from .const import ( ScheduleType, ) from .helpers.global_options import get_default_warning_days +from .helpers.reading_slots import parse_reading_slots_text, reading_slots_text from .helpers.schedule import ( read_legacy_fields, ) @@ -273,6 +275,14 @@ class TaskCrudMixin: updated_task[CONF_TASK_READING_UNIT] = ru else: updated_task.pop(CONF_TASK_READING_UNIT, None) + # #161 phase 2: reading slots, one "Name | Unit" per line. Ids + # are kept for lines whose name already exists (delta chain). + if "readings_text" in user_input: + slots = parse_reading_slots_text(user_input.get("readings_text") or "", task.get("readings")) + if slots: + updated_task["readings"] = slots + else: + updated_task.pop("readings", None) from .helpers.sanitize import cap_task_fields @@ -389,16 +399,6 @@ class TaskCrudMixin: CONF_TASK_INTERVAL_ANCHOR, default=sched["interval_anchor"], ): interval_anchor_selector(), - **( - { - vol.Optional( - CONF_TASK_SCHEDULE_TIME, - default=task.get("schedule_time", ""), - ): _OptionalTimeSelector(), - } - if self._get_global_options().get(CONF_ADVANCED_SCHEDULE_TIME, False) - else dict[Any, Any]() - ), } if sched["schedule_type"] == ScheduleType.TIME_BASED else dict[Any, Any]() @@ -421,6 +421,20 @@ class TaskCrudMixin: if sched["schedule_type"] == ScheduleType.TIME_BASED or sched["schedule_type"] in CALENDAR_KIND_VALUES else dict[Any, Any]() ), + # Time of day (#168): every date-driven kind — "each Sunday at + # 21:00" needs it as much as a 7-day interval does. Only present + # while the global advanced flag is on; clear by submitting "". + **( + { + vol.Optional( + CONF_TASK_SCHEDULE_TIME, + default=task.get("schedule_time", ""), + ): _OptionalTimeSelector(), + } + if self._get_global_options().get(CONF_ADVANCED_SCHEDULE_TIME, False) + and sched["schedule_type"] in SCHEDULE_TIME_KINDS + else dict[Any, Any]() + ), vol.Optional( CONF_TASK_WARNING_DAYS, default=task.get("warning_days", get_default_warning_days(self.hass)), @@ -487,6 +501,9 @@ class TaskCrudMixin: nfc_tag_key: selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)), vol.Optional("require_tag_scan", default=bool(task.get("require_tag_scan"))): selector.BooleanSelector(), reading_unit_key: selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)), + vol.Optional("readings_text", default=reading_slots_text(task.get("readings"))): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiline=True) + ), vol.Optional("go_back", default=False): selector.BooleanSelector(), } ), diff --git a/custom_components/maintenance_supporter/config_flow_options_task_trigger.py b/custom_components/maintenance_supporter/config_flow_options_task_trigger.py index 027c8bfa..860f8cf5 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_trigger.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_trigger.py @@ -179,7 +179,15 @@ class TriggerStepsMixin(TriggerConfigMixin): async def async_step_edit_trigger_proceed(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Proceed with editing the trigger after reviewing the summary.""" - self._current_task = {} + # Seed the stored trigger so the shared steps see it as ``prev_tc``: + # the attribute step rebuilds trigger_config from the form and carries + # over only the panel-managed keys the flow has no field for + # (#53 recovery flag, #102 baseline, #149 session cap) — with an + # empty seed those were silently dropped on every options-flow edit + # (bug review 2026-09-04). The type steps also default their + # recovery/baseline/combinator fields from it. + task = self.config_entry.data.get(CONF_TASKS, {}).get(self._selected_task_id or "", {}) + self._current_task = {"trigger_config": dict(task.get("trigger_config") or {})} self._trigger_on_complete = self._save_edited_trigger self._on_cancel = self._show_task_action_menu return await self.async_step_opt_sensor_select() diff --git a/custom_components/maintenance_supporter/const.py b/custom_components/maintenance_supporter/const.py index 229b2caf..4e0c2139 100644 --- a/custom_components/maintenance_supporter/const.py +++ b/custom_components/maintenance_supporter/const.py @@ -16,6 +16,7 @@ DOMAIN = "maintenance_supporter" # at all (the #86 panel-flag desync came from exactly this literal-key class). NOTIFICATION_MANAGER_KEY = "_notification_manager" DOCUMENT_STORE_KEY = "_document_store" +DOCUMENT_TEXT_INDEX_KEY = "_document_text_index" BUDGET_CACHE_KEY = "_budget_cache" EVENT_UNSUBS_KEY = "_event_unsubs" # TOP-LEVEL hass.data key (survives the DOMAIN pop on last-entry unload): @@ -173,6 +174,9 @@ CONF_OPERATOR_WRITE_ENABLED = "operator_write_enabled" # show. Selectable from KNOWN_OBJECT_TABLE_COLUMNS only — known object fields, # never arbitrary state attributes. CONF_OBJECTS_TABLE_COLUMNS = "objects_table_columns" +# #169 follow-up: per-member avatar overrides {user_id: {initials, color}} +# — shapes/palette in helpers/member_display.py. +CONF_MEMBER_DISPLAY = "member_display" # v2.21: template-gallery curation — ids of built-in templates the admin has # hidden from the "From template" pickers (panel gallery + config flow). The @@ -200,6 +204,7 @@ MAX_ADOPTED_NOTES = 100 # these; the WS update handler drops anything outside this set. KNOWN_OBJECT_TABLE_COLUMNS = [ "name", + "ref_no", # #170 reference number — opt-in column "manufacturer", "model", "serial_number", @@ -273,6 +278,13 @@ CONF_NOTIFICATION_TITLE_STYLE = "notification_title_style" # MATCHING that view's task-selecting filters (label + responsible user) send # reminders. "" = no scope (all tasks). See helpers/saved_views.view_matches_task. CONF_NOTIFY_SCOPE_VIEW_ID = "notify_scope_view_id" +# #165: your own notification rule — fire the event and send nothing +# yourself (an automation routes), and/or merge template-rendered extra +# keys into every notify payload's `data` (Ticker category, Pushover +# priority, Telegram parse_mode …). +CONF_NOTIFY_EVENT_ONLY = "notify_event_only" +CONF_NOTIFY_EXTRA_DATA = "notify_extra_data" +MAX_NOTIFY_EXTRA_DATA_LENGTH = 2000 NOTIFICATION_TITLE_STYLES = ("default", "object_name", "task_name") # --- Config Keys: Notification Actions --- @@ -373,6 +385,18 @@ BATTERY_FLEET_INCLUDED = "battery_fleet_included" # vacuums, smart rings — the #107 filter) in the roster as rechargeables, so # "needs a recharge" can notify like "needs a swap". Default off: #107 stays. BATTERY_FLEET_TRACK_SELF_CHARGING = "battery_fleet_track_self_charging" +# D#162: a Battery Notes note WITHOUT a level sensor (only the type sensor, +# the last-replaced timestamp and the "replaced" button exist) has no +# reading that could ever fire the fleet task — the forecast is all there +# is. Default ON: a passed predicted date on such a note counts as due +# (low → task trigger). Stored as False to switch off; absent means on. +BATTERY_FLEET_DUE_WITHOUT_SENSOR = "battery_fleet_due_without_sensor" +# Bug review 2026-09-04: ``batt_`` part ids the user DELETED. The +# start-up reconcile re-minted them on every boot as long as the type was +# still in the fleet, so a deleted type-part never stayed deleted. Written +# by part/delete, honoured by the reconcile, cleared by an explicit +# Battery-Fleet setup (the user asked for the full set again). +BATTERY_FLEET_REMOVED_PARTS = "battery_fleet_removed_parts" # Task-side link: task["consumes_parts"] = [{"part_id", "quantity"}] — a # completion decrements each linked part's stock. CONF_TASK_CONSUMES_PARTS = "consumes_parts" @@ -402,6 +426,9 @@ CONF_TASK_NFC_TAG = "nfc_tag_id" # v2.20 (#83): display unit for `reading`-type tasks ("kWh", "m³", ...). CONF_TASK_READING_UNIT = "reading_unit" MAX_READING_UNIT_LENGTH = 32 +# #161 phase 2: named reading slots ([{id, name, unit}]) — several values per +# completion; shapes/caps live in helpers/reading_slots.py. +CONF_TASK_READINGS = "readings" MAX_NFC_TAG_LENGTH = 256 CONF_TASK_INTERVAL_ANCHOR = "interval_anchor" CONF_TASK_SCHEDULE_TIME = "schedule_time" @@ -569,6 +596,9 @@ EVENT_TRIGGER_DEACTIVATED = f"{DOMAIN}_trigger_deactivated" # state mutation has persisted. Power users wire HA automations on these; # the integration's own action_listener also subscribes to the COMPLETED # event to dispatch the per-task on_complete_action service-call. +# #165: one event per notification the integration is about to send — +# the hook for user-written routing (see helpers/notify_hooks.py). +EVENT_NOTIFICATION = f"{DOMAIN}_notification" EVENT_TASK_COMPLETED = f"{DOMAIN}_task_completed" EVENT_TASK_SKIPPED = f"{DOMAIN}_task_skipped" EVENT_TASK_RESET = f"{DOMAIN}_task_reset" diff --git a/custom_components/maintenance_supporter/coordinator.py b/custom_components/maintenance_supporter/coordinator.py index 3ca58e67..dde754de 100644 --- a/custom_components/maintenance_supporter/coordinator.py +++ b/custom_components/maintenance_supporter/coordinator.py @@ -15,6 +15,7 @@ from homeassistant.util import dt as dt_util if TYPE_CHECKING: from .calendar import MaintenanceCalendar from .todo import MaintenanceTodoList +from homeassistant.helpers import entity_registry as er from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -47,6 +48,8 @@ from .const import ( MaintenanceStatus, ScheduleType, TriggerEntityState, + slugify_object_name, + task_unique_id, ) from .helpers.budget import compute_spend from .helpers.entry_tasks import write_task @@ -160,6 +163,20 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): obj_data.get("name"), ) + def _assign_task_refs(self) -> None: + """#170: number tasks that have none yet (every creation path ends in + a refresh, so this is the one place), and completions recorded before + the numbering existed — oldest first, so a first start after the + update yields the numbering a user would have written down by hand. + No-op when everything is numbered.""" + from .helpers.reference_numbers import assign_history_refs, assign_task_refs + + new_data = assign_task_refs(dict(self.entry.data)) + if new_data is not None: + self.hass.config_entries.async_update_entry(self.entry, data=new_data) + if assign_history_refs(self._store, self.entry.data.get(CONF_TASKS, {})): + self._store.async_delay_save() + @property def maintenance_object(self) -> MaintenanceObject: """Return the maintenance object from config entry data.""" @@ -176,6 +193,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Seasonal pause (N3): auto-resume on the first refresh on/after # paused_until, then continue this refresh un-paused. await self._async_maybe_auto_resume() + self._assign_task_refs() obj = self.maintenance_object tasks = self.tasks @@ -946,8 +964,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): checklist_state: dict[str, bool] | None = None, feedback: str | None = None, completed_by: str | None = None, - photo_doc_id: str | None = None, + photo_doc_ids: list[str] | None = None, reading_value: float | None = None, + reading_values: list[dict[str, Any]] | None = None, restock_quantity: float | None = None, used_parts: list[dict[str, Any]] | None = None, auto: bool = False, @@ -984,6 +1003,16 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): translation_key="completed_at_in_future", ) + # #161 phase 2: a task with reading slots records the per-slot + # snapshot, never the single scalar — refused HERE so an old cached + # bundle or an automation cannot mix the two shapes on one entry. + if reading_value is not None and merged[task_id].get("readings"): + raise ServiceValidationError( + "This task records named readings — use reading_values instead of reading_value", + translation_domain=DOMAIN, + translation_key="reading_slots_required", + ) + # Required completion details. Checked HERE — the one point every # surface funnels through — so a task demanding a note cannot be # closed out from a button, the to-do list, an NFC tag, a @@ -1013,7 +1042,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): notes=notes, cost=cost, duration=duration, - photo_doc_id=photo_doc_id, + photo_doc_ids=photo_doc_ids, completed_by=completed_by, ) if missing: @@ -1183,22 +1212,30 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): checklist_state=checklist_state, feedback=feedback, completed_by=completed_by, - photo_doc_id=photo_doc_id, + photo_doc_ids=photo_doc_ids, reading_value=reading_value, + reading_values=reading_values, used_parts=enriched_used, auto=auto, completed_at=completed_at, ) + # #170: the completion's reference number ("8.3-2") — handed out here, + # in the one place every completion path passes, from the task's + # high-water counter, so a deleted or backdated entry never renumbers. + if task.history: + from .helpers.reference_numbers import next_history_ref + + task.history[-1]["ref_no"] = next_history_ref(self._store, task_id, task.history) # #73: a completed cycle retires its in-cycle checklist ticks — the # snapshot that matters is in the history entry above. A pure backfill # closed no current cycle, so the live ticks stay. if is_latest: self._store.clear_checklist_progress(task_id) - # Link the completion photo to this task so it also surfaces under the - # object's documents and is deref'd correctly on cleanup. Best-effort: - # a bad/removed doc_id must never block the completion itself. - if photo_doc_id: + # Link the completion photos to this task so they also surface under + # the object's documents and are deref'd correctly on cleanup. + # Best-effort: a bad/removed doc_id must never block the completion. + for photo_doc_id in photo_doc_ids or (): await self._link_completion_photo(photo_doc_id, task_id) # Update adaptive scheduling if enabled. Gated on is_latest: a pure @@ -1294,6 +1331,10 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): duration=duration, feedback=feedback, completed_by=completed_by, + # Recorded readings (#83 scalar / #161 phase 2 slots) so an + # automation can forward a meter value without reading history. + reading_value=reading_value, + reading_values=reading_values, # #133: the history entry's own timestamp — identical to what # the history records, so automations can attribute backdated # completions to the right period instead of time_fired. @@ -1427,10 +1468,6 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): as_missed: bool = False, ) -> None: """Skip the current maintenance cycle for a task.""" - # Same as reset: skipping restarts the cycle — clear the double-tap - # window so a follow-up completion counts. - self._recent_manual_completions.pop(task_id, None) - self._clear_notification_state(task_id) merged = self._get_merged_tasks_data() if task_id not in merged: _LOGGER.error("Task %s not found in entry %s", task_id, self.entry.title) @@ -1443,6 +1480,29 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): f"Skipping is disabled for task {merged[task_id].get('name', task_id)!r}" ) + # Same gate as complete_maintenance: a retired / disabled / paused + # task must not get a new cycle from a stale notification button or + # an old NFC sticker (bug review 2026-09-04). + _td = merged[task_id] + if ( + _td.get("archived_at") is not None + or _td.get("enabled") is False + or self.entry.data.get(CONF_OBJECT, {}).get("paused_at") is not None + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_inactive_skip", + translation_placeholders={"task_name": str(_td.get("name", task_id))}, + ) + + # Only now that the skip is going to happen: like reset, skipping + # restarts the cycle — clear the double-tap window so a follow-up + # completion counts, and forget the notification bookkeeping. Doing + # this BEFORE the gates re-armed the "task overdue" push for a locked + # task on every refused skip (bug review 2026-09-04). + self._recent_manual_completions.pop(task_id, None) + self._clear_notification_state(task_id) + task = MaintenanceTask.from_dict(merged[task_id]) # Skipping an already-overdue task means it lapsed → record it as MISSED # (not a deliberate skip). An explicit as_missed=True always wins. @@ -1514,7 +1574,12 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): Guarantees that every EVENT_TASK_COMPLETED/SKIPPED/RESET payload carries the four identification keys (entry_id, task_id, task_name, - object_name) — listeners can rely on them being present. + object_name) — listeners can rely on them being present — plus + ``entity_id``: the task's sensor entity (None only when the entity + platform has not registered it yet). HA's logbook filters custom + events by that key inside the event data, so without it a + completion never appeared on the sensor's own timeline, and + automations get the entity to act on without a registry lookup. Variant-specific fields (notes, cost, reason, reset_date, …) are passed via **extra. """ @@ -1523,9 +1588,15 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): "task_id": task_id, "task_name": task.name, "object_name": self.maintenance_object.name, + "entity_id": self.task_sensor_entity_id(task_id), **extra, } + def task_sensor_entity_id(self, task_id: str) -> str | None: + """The registered entity_id of a task's status sensor, if any.""" + unique_id = task_unique_id(slugify_object_name(self.maintenance_object.name), task_id) + return er.async_get(self.hass).async_get_entity_id("sensor", DOMAIN, unique_id) + async def async_apply_suggested_interval(self, task_id: str, interval: int) -> None: """Apply a suggested interval to a task (static config → ConfigEntry).""" tasks_data = dict(self.entry.data.get(CONF_TASKS, {})) diff --git a/custom_components/maintenance_supporter/entity/triggers/runtime.py b/custom_components/maintenance_supporter/entity/triggers/runtime.py index 037c6072..159a745b 100644 --- a/custom_components/maintenance_supporter/entity/triggers/runtime.py +++ b/custom_components/maintenance_supporter/entity/triggers/runtime.py @@ -371,6 +371,12 @@ class RuntimeTrigger(BaseTrigger): """Reset accumulated runtime (called after maintenance completion).""" super().reset() self._accumulated_seconds = 0.0 + # A fresh start is a fresh session for the #149 cap too: the session + # that ran up to the completion has been booked (and cleared), the + # one starting now may book the cap again. Leaving the old booking in + # place froze a still-ON entity at zero until it next turned off + # (bug review 2026-09-04). + self._session_booked = 0.0 # If entity is currently ON, keep tracking from now (fresh start) if self._on_since_dt is not None: now = dt_util.utcnow() diff --git a/custom_components/maintenance_supporter/entity/triggers/state_change.py b/custom_components/maintenance_supporter/entity/triggers/state_change.py index 8583dee0..50bcee02 100644 --- a/custom_components/maintenance_supporter/entity/triggers/state_change.py +++ b/custom_components/maintenance_supporter/entity/triggers/state_change.py @@ -92,6 +92,28 @@ class StateChangeTrigger(BaseTrigger): if self._restored_pending_dt is None: self._restored_pending_state = None + def _in_alert_state(self, state: str | None) -> bool | None: + """Whether *state* is the latch's ALERT state (#167). + + A single-shot trigger (target_changes == 1) is a latch that recovers + when the entity leaves its alert state. What "alert" means follows + the configured pattern: + + - To-state set: alert == the To-state (leaving it recovers). + - Only a From-state set ("from ok to any" — a dock/error sensor with + a dozen fault states): alert == anything BUT the From-state, so the + latch recovers when the entity returns to it. Before #167 the + recovery was hard-wired to the To-state and such a latch never + cleared — although the dialog offers both fields as optional and + auto-complete-on-recovery right next to them. + - Neither set: no latch semantics (None) — a pure change counter. + """ + if self._to_state is not None: + return _norm_state(state) == self._to_state + if self._from_state is not None: + return _norm_state(state) != self._from_state + return None + async def async_setup(self) -> None: """Set up state change trigger. @@ -150,7 +172,7 @@ class StateChangeTrigger(BaseTrigger): """ if self._change_count < self._target_changes: return - if self._to_state is not None and self._target_changes == 1 and _norm_state(live_state) != self._to_state: + if self._target_changes == 1 and self._in_alert_state(live_state) is False: self._change_count = 0 self._current_value = 0.0 self._triggered = False @@ -386,16 +408,13 @@ class StateChangeTrigger(BaseTrigger): # Latch recovery: a single-shot state alarm (target_changes == 1 — an # adopted problem sensor or an appliance event) clears when the entity - # leaves its alert state. Reset the counter so the next occurrence can - # fire again, and run the deactivation path — which auto-completes on - # recovery when opted in. Multi-count triggers keep accumulating and - # only reset on manual completion, so they are untouched here. - elif ( - self._to_state is not None - and self._target_changes == 1 - and self._triggered - and _norm_state(new_val) != self._to_state - ): + # leaves its alert state (see _in_alert_state for what that means on + # a from-only pattern, #167). Reset the counter so the next + # occurrence can fire again, and run the deactivation path — which + # auto-completes on recovery when opted in. Multi-count triggers keep + # accumulating and only reset on manual completion, so they are + # untouched here. + elif self._target_changes == 1 and self._triggered and self._in_alert_state(new_val) is False: self._change_count = 0 self._current_value = 0.0 self._persist_runtime_soon() diff --git a/custom_components/maintenance_supporter/export.py b/custom_components/maintenance_supporter/export.py index 7a50e2b3..81c8f0b7 100644 --- a/custom_components/maintenance_supporter/export.py +++ b/custom_components/maintenance_supporter/export.py @@ -11,9 +11,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import ( + BATTERY_FLEET_DUE_WITHOUT_SENSOR, BATTERY_FLEET_EXCLUDED, BATTERY_FLEET_INCLUDED, BATTERY_FLEET_OBJECT_FLAG, + BATTERY_FLEET_REMOVED_PARTS, BATTERY_FLEET_TASK_FLAG, BATTERY_FLEET_TRACK_SELF_CHARGING, CONF_OBJECT, @@ -35,13 +37,17 @@ def _export_documents(doc_store: Any, object_id: str) -> list[dict[str, Any]]: file metadata pointing at a missing blob — the storage-hygiene repair issue catches those as dangling. Web-links round-trip fully. ``task_ids`` are carried too (as the OLD task ids); the importer remaps them onto the fresh - task ids so a doc's task links survive a backup/restore. + task ids so a doc's task links survive a backup/restore. The doc's own + ``id`` rides along for the same reason: history entries (completion + photos) and spare parts (``doc_id``) point at documents by id, and the + importer mints fresh ids — without the old one it could not remap them. """ out: list[dict[str, Any]] = [] for d in doc_store.for_object(object_id): if d.get("kind") == "weblink": out.append( { + "id": d.get("id"), "kind": "weblink", "url": d.get("url"), "title": d.get("title"), @@ -53,6 +59,7 @@ def _export_documents(doc_store: Any, object_id: str) -> list[dict[str, Any]]: else: out.append( { + "id": d.get("id"), "kind": "file", "hash": d.get("hash"), "title": d.get("title"), @@ -133,6 +140,9 @@ def _build_export_object( "priority": tdata.get("priority", "normal"), "labels": tdata.get("labels") or [], "earliest_completion_days": tdata.get("earliest_completion_days"), + # #170: reference number — a restore keeps "8.3" on the booklets + # already printed; a colliding import is renumbered on setup. + "ref_no": tdata.get("ref_no"), "on_complete_action": tdata.get("on_complete_action"), "quick_complete_defaults": tdata.get("quick_complete_defaults"), "assignee_pool": tdata.get("assignee_pool") or [], @@ -142,6 +152,8 @@ def _build_export_object( "required_completion_fields": tdata.get("required_completion_fields"), "rotation_strategy": tdata.get("rotation_strategy"), "reading_unit": tdata.get("reading_unit"), + # #161 phase 2: reading slots (history carries the per-slot values). + "readings": tdata.get("readings"), # Task phases (#139): defs + cycle are static config; the cursor is # Store-merged by merged_tasks, so a restore resumes mid-cycle # instead of silently restarting at step one. @@ -210,6 +222,10 @@ def _build_export_object( # restore — with every task still archived (reason "object") and # object/unarchive refusing because the object "isn't archived". "archived_at": obj_data.get("archived_at"), + # #170: the object's reference number + its task counter (the + # high-water mark, so numbers are not reused after a restore). + "ref_no": obj_data.get("ref_no"), + "next_task_ref": obj_data.get("next_task_ref"), } # Battery fleet identity — only emitted for the fleet object so a plain # object's export stays byte-identical to earlier versions. The importer @@ -219,6 +235,9 @@ def _build_export_object( export_obj[BATTERY_FLEET_EXCLUDED] = list(obj_data.get(BATTERY_FLEET_EXCLUDED) or []) export_obj[BATTERY_FLEET_INCLUDED] = list(obj_data.get(BATTERY_FLEET_INCLUDED) or []) export_obj[BATTERY_FLEET_TRACK_SELF_CHARGING] = bool(obj_data.get(BATTERY_FLEET_TRACK_SELF_CHARGING)) + # D#162: on unless explicitly switched off (absent = on). + export_obj[BATTERY_FLEET_DUE_WITHOUT_SENSOR] = obj_data.get(BATTERY_FLEET_DUE_WITHOUT_SENSOR) is not False + export_obj[BATTERY_FLEET_REMOVED_PARTS] = list(obj_data.get(BATTERY_FLEET_REMOVED_PARTS) or []) return { "entry_id": entry.entry_id, @@ -322,7 +341,7 @@ def export_maintenance_data( # Global settings the export deliberately leaves behind: HA user ids are # instance-bound (and the panel-access allowlist is security-relevant), and # the adopted-task stash is transient re-adopt state. -_NON_PORTABLE_SETTINGS = ("admin_panel_user_ids", "adopted_task_notes", "shopping_list_entity") +_NON_PORTABLE_SETTINGS = ("admin_panel_user_ids", "adopted_task_notes", "shopping_list_entity", "member_display") def build_settings_export(hass: HomeAssistant) -> dict[str, Any]: diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/_panel-utils.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/_panel-utils.ts index 4044b63e..cff78746 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/_panel-utils.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/_panel-utils.ts @@ -65,6 +65,7 @@ export function obj(entryId: string, tasks: unknown[], name = "Pool Pump") { export async function mountPanel( objects: unknown[], extraHandlers: Record = {}, + opts: { user?: { id: string; is_admin: boolean } } = {}, ) { const { hass, sent, subscriptions } = createMockHass({ handlers: { @@ -83,7 +84,7 @@ export async function mountPanel( }, }); // The panel derives write access from hass.user (no user → read-only). - (hass as Record).user = { id: "admin-1", is_admin: true }; + (hass as Record).user = opts.user ?? { id: "admin-1", is_admin: true }; (hass as Record).areas = {}; const el = await fixture }>(html` 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 0a6abf12..d38fb367 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 @@ -4,7 +4,7 @@ * The completion dialog is the money path of the whole product; until now it * was only covered by the lazy-load tripwire. These tests pin the exact * outgoing WS payload (notes / cost / duration / feedback / checklist_state / - * photo_doc_id) and the error path (server refusal keeps the dialog open). + * photo_doc_ids) and the error path (server refusal keeps the dialog open). */ import { expect, fixture, html } from "@open-wc/testing"; @@ -100,6 +100,7 @@ describe("complete-dialog", () => { expect("duration" in msg).to.be.false; expect("feedback" in msg).to.be.false; expect("checklist_state" in msg).to.be.false; + expect("photo_doc_ids" in msg).to.be.false; expect("photo_doc_id" in msg).to.be.false; expect("completed_at" in msg).to.be.false; }); @@ -153,43 +154,149 @@ describe("complete-dialog", () => { expect(el.shadowRoot!.textContent).to.include("future"); }); - it("attaches an uploaded photo as photo_doc_id", async () => { - const { el, sent } = await mount(); - - // Stub the document-upload endpoint the photo picker posts to. + /** #161: stub the multipart upload route; every call mints the next id. */ + function stubUpload(ids: string[]) { const realFetch = window.fetch; const uploads: RequestInit[] = []; + let n = 0; window.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { uploads.push(init!); - return { - ok: true, - status: 200, - json: async () => ({ id: "doc-photo-1", deduped: false }), - } as Response; + const id = ids[n++] ?? `doc-photo-${n}`; + return { ok: true, status: 200, json: async () => ({ id, deduped: false }) } as Response; }) as typeof window.fetch; + return { uploads, restore: () => { window.fetch = realFetch; } }; + } + function pickFiles(el: MaintenanceCompleteDialog, selector: string, names: string[]) { + const fileInput = el.shadowRoot!.querySelector(selector)!; + const dt = new DataTransfer(); + for (const name of names) dt.items.add(new File(["fake-png"], name, { type: "image/png" })); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change")); + } + + const CAMERA = '.photo-pick-camera input[type="file"]'; + const GALLERY = '.photo-pick-gallery input[type="file"]'; + + it("falls back to one photo per pick inside the Android Companion app (#161 follow-up)", async () => { + // The Android app's file chooser drops a multi-select on the floor + // (parseResult ignores ClipData) — the gallery picker is single there. + (window as unknown as { externalApp?: unknown }).externalApp = {}; try { - const fileInput = el.shadowRoot!.querySelector( - '.photo-pick input[type="file"]', - )!; - const dt = new DataTransfer(); - dt.items.add(new File(["fake-png"], "done.png", { type: "image/png" })); - fileInput.files = dt.files; - fileInput.dispatchEvent(new Event("change")); + const { el } = await mount(); + const gallery = el.shadowRoot!.querySelector(GALLERY)!; + expect(gallery.multiple, "no multi-select affordance on Android").to.equal(false); + expect(el.shadowRoot!.querySelector(".photo-pick-gallery span")!.textContent!.trim()).to.equal("Choose photo"); + expect(el.shadowRoot!.querySelector(".photo-android-hint")!.textContent).to.contain("one photo per pick"); + const { uploads, restore } = stubUpload(["p1", "p2"]); + try { + pickFiles(el, GALLERY, ["a.png"]); + await new Promise((r) => setTimeout(r, 10)); + pickFiles(el, GALLERY, ["b.png"]); + await new Promise((r) => setTimeout(r, 20)); + await el.updateComplete; + expect(uploads.length, "each single pick is added").to.equal(2); + expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(2); + } finally { + restore(); + } + } finally { + delete (window as unknown as { externalApp?: unknown }).externalApp; + } + const { el: plain } = await mount(); + expect(plain.shadowRoot!.querySelector(GALLERY)!.multiple, "browsers keep multi-select").to.equal(true); + expect(plain.shadowRoot!.querySelector(".photo-android-hint")).to.equal(null); + }); + + it("attaches the uploaded photos as photo_doc_ids (#161)", async () => { + const { el, sent } = await mount(); + const { uploads, restore } = stubUpload(["doc-photo-1", "doc-photo-2", "doc-photo-3"]); + try { + // The camera picker takes one shot; the gallery picker several at once. + pickFiles(el, CAMERA, ["done.png"]); await new Promise((r) => setTimeout(r, 10)); await el.updateComplete; + pickFiles(el, GALLERY, ["a.png", "b.png"]); + await new Promise((r) => setTimeout(r, 20)); + await el.updateComplete; - // Preview replaces the picker once the upload returns an id. - expect(el.shadowRoot!.querySelector(".photo-preview img")).to.exist; - expect(uploads.length).to.equal(1); + expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(3); + expect(uploads.length).to.equal(3); const form = uploads[0].body as FormData; expect(form.get("entry_id")).to.equal("entry1"); expect(form.get("tags")).to.equal("photo"); + // The pickers stay available while there is room under the cap. + expect(el.shadowRoot!.querySelector(CAMERA)).to.exist; clickComplete(el); await new Promise((r) => setTimeout(r, 10)); const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!; - expect(msg.photo_doc_id).to.equal("doc-photo-1"); + expect(msg.photo_doc_ids).to.deep.equal(["doc-photo-1", "doc-photo-2", "doc-photo-3"]); + expect("photo_doc_id" in msg).to.be.false; + // Completing keeps the uploads — no delete goes out. + expect(sent.some((m) => m.type === "maintenance_supporter/documents/delete")).to.be.false; + } finally { + restore(); + } + }); + + it("caps a completion at 10 photos and says so (#161)", async () => { + const { el } = await mount(); + const { uploads, restore } = stubUpload([]); + try { + pickFiles(el, GALLERY, Array.from({ length: 12 }, (_, i) => `p${i}.png`)); + await new Promise((r) => setTimeout(r, 60)); + await el.updateComplete; + expect(uploads.length).to.equal(10); + expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(10); + // At the cap the pickers give way to the limit note. + expect(el.shadowRoot!.querySelector(CAMERA)).to.equal(null); + expect(el.shadowRoot!.querySelector(".photo-limit")!.textContent).to.include("10"); + expect(el.shadowRoot!.querySelector(".error")!.textContent).to.include("10"); + } finally { + restore(); + } + }); + + it("removing a tile deletes that upload; cancelling drops the rest (#161)", async () => { + const { el, sent } = await mount(); + const { restore } = stubUpload(["doc-photo-1", "doc-photo-2"]); + try { + pickFiles(el, GALLERY, ["a.png", "b.png"]); + await new Promise((r) => setTimeout(r, 20)); + await el.updateComplete; + + const removeButtons = el.shadowRoot!.querySelectorAll(".photo-remove"); + expect(removeButtons.length).to.equal(2); + removeButtons[0].click(); + await el.updateComplete; + expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(1); + let deletes = sent.filter((m) => m.type === "maintenance_supporter/documents/delete"); + expect(deletes.map((m) => m.doc_id)).to.deep.equal(["doc-photo-1"]); + + // Cancel: the remaining upload is an orphan nobody references. + const cancel = el.shadowRoot!.querySelector(".dialog-actions ha-button")!; + cancel.click(); + await el.updateComplete; + deletes = sent.filter((m) => m.type === "maintenance_supporter/documents/delete"); + expect(deletes.map((m) => m.doc_id)).to.deep.equal(["doc-photo-1", "doc-photo-2"]); + expect(sent.some((m) => m.type === "maintenance_supporter/task/complete")).to.be.false; + } finally { + restore(); + } + }); + + it("surfaces a 413 as the too-large message and keeps the picker (#161)", async () => { + const { el } = await mount(); + const realFetch = window.fetch; + window.fetch = (async () => ({ ok: false, status: 413, json: async () => ({}) }) as Response) as typeof window.fetch; + try { + pickFiles(el, CAMERA, ["huge.png"]); + await new Promise((r) => setTimeout(r, 10)); + await el.updateComplete; + expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(0); + expect(el.shadowRoot!.querySelector(".error")!.textContent).to.include("large"); + expect(el.shadowRoot!.querySelector(CAMERA)).to.exist; } finally { window.fetch = realFetch; } diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/documents-section.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/documents-section.test.ts index cf54d806..939a7ab4 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/documents-section.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/documents-section.test.ts @@ -213,4 +213,30 @@ describe("documents-section", () => { label.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); expect(clicked, "Enter triggers the hidden file input").to.be.true; }); + + it("offers a local filter from 8 documents up and filters tolerantly (#171)", async () => { + const many = Array.from({ length: 9 }, (_, i) => ({ + ...FILE_DOC, id: `f${i}`, title: i === 4 ? "Garantie Spülmaschine" : `Rechnung ${i}`, filename: `r${i}.pdf`, tags: i === 4 ? ["warranty"] : ["invoice"], + })); + const { el } = await mount(true, many); + const input = el.shadowRoot!.querySelector(".doc-filter input"); + expect(input, "filter box shown").to.exist; + expect(el.shadowRoot!.querySelectorAll(".doc-row").length).to.equal(9); + input!.value = "spuel garant"; + input!.dispatchEvent(new Event("input")); + await el.updateComplete; + const rows = [...el.shadowRoot!.querySelectorAll(".doc-row")]; + expect(rows.length).to.equal(1); + expect(rows[0].textContent).to.contain("Garantie Spülmaschine"); + input!.value = "nichts"; + input!.dispatchEvent(new Event("input")); + await el.updateComplete; + expect(el.shadowRoot!.querySelector(".doc-empty")!.textContent).to.contain("No matching documents"); + expect(el.shadowRoot!.querySelector("h3")!.textContent, "count stays the full count").to.contain("(9)"); + }); + + it("keeps the list uncluttered below 8 documents — no filter box", async () => { + const { el } = await mount(); + expect(el.shadowRoot!.querySelector(".doc-filter")).to.be.null; + }); }); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/panel-shell.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/panel-shell.test.ts index 3333b481..71012ae1 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/panel-shell.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/panel-shell.test.ts @@ -1,4 +1,5 @@ import { expect } from "@open-wc/testing"; +import { setViewport } from "@web/test-runner-commands"; import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js"; describe("panel shell", () => { beforeEach(() => { @@ -156,6 +157,63 @@ describe("panel shell", () => { expect(textOf(overdueSection)).to.include("Late"); }); + it("Today rows name the responsible person, except under the person filter (#169)", async () => { + localStorage.setItem("msp-overview-tab", "today"); + const { el } = await mountPanel( + [ + obj("e1", [ + task({ name: "Assigned", status: "overdue", days_until_due: -1, responsible_user_id: "u-anna" }), + task({ name: "Nobody", status: "overdue", days_until_due: -2 }), + ]), + ], + { "maintenance_supporter/users/list": () => ({ users: [{ id: "u-anna", name: "Anna" }] }) }, + ); + await new Promise((r) => setTimeout(r, 30)); + await el.updateComplete; + const rowOf = (name: string) => + [...sr(el).querySelectorAll(".today-row")].find((r) => r.querySelector(".today-task")?.textContent?.trim() === name)!; + expect(rowOf("Assigned").querySelector(".today-person")!.textContent).to.include("Anna"); + expect(rowOf("Nobody").querySelector(".today-person")).to.equal(null); + + // Filtering by that person makes the chip redundant — every row is hers. + (el as unknown as { _filterUser: string | null })._filterUser = "u-anna"; + await el.updateComplete; + expect(sr(el).querySelector(".today-person")).to.equal(null); + }); + + it("Today person chip stays readable on a phone next to a long object name (#169)", async () => { + localStorage.setItem("msp-overview-tab", "today"); + await setViewport({ width: 360, height: 800 }); + try { + const { el } = await mountPanel( + [ + obj("e1", [task({ name: "Descale and clean the filter thoroughly", status: "overdue", days_until_due: -248, responsible_user_id: "u-max" })], + "Dishwasher in the basement utility room 12345"), + ], + { "maintenance_supporter/users/list": () => ({ users: [{ id: "u-max", name: "Maximiliane Schneider-Hoffmann" }] }) }, + ); + await new Promise((r) => setTimeout(r, 30)); + await el.updateComplete; + const line = sr(el).querySelector(".today-object")!; + const chip = line.querySelector(".today-person")!; + const lineBox = line.getBoundingClientRect(); + const chipBox = chip.getBoundingClientRect(); + // Narrow: the avatar alone (initials in the member's colour), fully inside the line. + const avatar = chip.querySelector(".person-avatar")!; + expect(avatar.textContent!.trim()).to.equal("MS"); + expect(getComputedStyle(chip.querySelector(".person-name")!).display, "name hidden on narrow").to.equal("none"); + const visible = Math.min(chipBox.right, lineBox.right) - Math.max(chipBox.left, lineBox.left); + expect(visible, "avatar visible inside the line").to.be.greaterThan(16); + expect(chipBox.right, "chip does not overflow the line").to.be.at.most(lineBox.right + 1); + // The object text gave way (ellipsis), not the person. + const text = line.querySelector(".today-object-text")!; + expect(text.scrollWidth).to.be.greaterThan(text.clientWidth); + expect(chip.getAttribute("title")).to.equal("Maximiliane Schneider-Hoffmann"); + } finally { + await setViewport({ width: 1280, height: 800 }); + } + }); + it("virtualizes the table above the threshold and moves the window on scroll", async () => { const many = Array.from({ length: 150 }, (_, i) => task({ name: `Bulk ${String(i).padStart(3, "0")}`, days_until_due: (i % 40) + 1 }), 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 f86d6590..4799100d 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 @@ -21,6 +21,7 @@ function mockHass(opts: { vacationStart?: string | null; vacationEnd?: string | null; exemptIds?: string[]; + previewRows?: Array>; } = {}) { const settingsResponse = { ...DEFAULT_SETTINGS_RESPONSE, @@ -51,7 +52,7 @@ function mockHass(opts: { "maintenance_supporter/vacation/end_now": () => ({ ...settingsResponse.vacation, enabled: false, is_active: false, }), - "maintenance_supporter/vacation/preview": () => ({ rows: [], window_end: null }), + "maintenance_supporter/vacation/preview": () => ({ rows: opts.previewRows ?? [], window_end: null }), }, }); } @@ -144,4 +145,42 @@ describe("settings-view vacation section", () => { const endNow = vacationSection(el)!.querySelector(".vac-end-now"); expect(endNow, "no end-now in default state").to.not.exist; }); + it("the preview hides Skip for a task whose skip rule is off (#150 rule in the preview)", async () => { + // Bug review 2026-09-04: every time-based row offered Skip — the + // backend then rejected it for allow_skip=false tasks. + const row = (task_id: string, extra: Record) => ({ + task_id, + entry_id: "e1", + object_name: "Pool", + task_name: task_id, + kind: "time_based", + confidence: "deterministic", + events: [{ date: "2099-06-12", status: "due_soon" }], + will_suppress: true, + ...extra, + }); + const { el } = await mount({ + vacationStart: "2099-06-10", + vacationEnd: "2099-06-20", + previewRows: [ + row("Open", { allow_skip: true }), + row("Locked", { allow_skip: false }), + row("Legacy", {}), + row("Sensor", { kind: "sensor_based", allow_skip: true }), + ], + }); + const section = vacationSection(el)!; + section.querySelector(".vac-preview-toolbar button")!.click(); + await new Promise((r) => setTimeout(r, 30)); + await el.updateComplete; + const rows = Array.from(section.querySelectorAll(".vac-preview-row")); + expect(rows.length).to.equal(4); + const skipLabel = rows[0].querySelectorAll(".vac-preview-actions button")[1].textContent!.trim(); + const hasSkip = (r: Element) => + Array.from(r.querySelectorAll(".vac-preview-actions button")).some((b) => b.textContent!.trim() === skipLabel); + expect(hasSkip(rows[0]), "allow_skip=true keeps Skip").to.be.true; + expect(hasSkip(rows[1]), "allow_skip=false hides Skip").to.be.false; + expect(hasSkip(rows[2]), "absent field = allowed (older backend)").to.be.true; + expect(hasSkip(rows[3]), "sensor rows never had Skip").to.be.false; + }); }); 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 fe60cf71..d3f199f3 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 @@ -125,4 +125,15 @@ describe("storage-section-card", () => { expect(el.shadowRoot!.querySelector(".obj-name")!.textContent!.trim()).to.equal("01234567"); expect(el.shadowRoot!.querySelector(".obj-row.clickable"), "unknown object not clickable").to.not.exist; }); + + it("shows how much of the library the full-text search can see (#171)", async () => { + const el = await mount({ ...SUMMARY, search_index: { total: 12, indexed: 9, no_text: 2, unsupported: 1, pending: 0 } }); + await expand(el); + const status = el.shadowRoot!.querySelector(".index-status")!; + expect(status.textContent!.replace(/\s+/g, " ")).to.contain("9 of 12 files indexed"); + expect(status.textContent).to.contain("3 without text layer"); + const none = await mount({ ...SUMMARY, search_index: { total: 0, indexed: 0, no_text: 0, unsupported: 0, pending: 0 } }); + await expand(none); + expect(none.shadowRoot!.querySelector(".index-status"), "no line without files").to.be.null; + }); }); 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 dce5d8cd..787c8cbc 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 @@ -13,8 +13,8 @@ import "../components/task-dialog.js"; import type { MaintenanceTaskDialog } from "../components/task-dialog"; import { createMockHass } from "./_test-utils.js"; -async function mountCreate(states: Record): Promise { - const { hass } = createMockHass({ states }); +async function mountCreate(states: Record, language = "en"): Promise { + const { hass } = createMockHass({ states, language }); const el = await fixture(html` `); @@ -59,6 +59,37 @@ describe("task-dialog live trigger hint", () => { expect(text).to.include("1.5 bar"); }); + it("formats the figures in the user's number format (bug review 2026-09-04)", async () => { + // Decimal comma + thousands separators for a de user — like the entity + // card next to the dialog; the hint used raw String(n). + const el = await mountCreate( + { + "sensor.odometer": { state: "100000", attributes: { unit_of_measurement: "km" } }, + "sensor.pressure": { state: "1.25", attributes: { unit_of_measurement: "bar" } }, + }, + "de", + ); + (el as any)._triggerEntityId = "sensor.odometer"; + (el as any)._triggerEntityIds = ["sensor.odometer"]; + (el as any)._triggerType = "counter"; + (el as any)._triggerDeltaMode = true; + (el as any)._triggerTargetValue = "15000"; + await el.updateComplete; + let text = hint(el)!; + expect(text).to.include("100.000 km"); + expect(text).to.include("115.000 km"); + expect(text).to.include("15.000 km"); + + (el as any)._triggerEntityId = "sensor.pressure"; + (el as any)._triggerEntityIds = ["sensor.pressure"]; + (el as any)._triggerType = "threshold"; + (el as any)._triggerAbove = "1.5"; + await el.updateComplete; + text = hint(el)!; + expect(text).to.include("1,3 bar"); // 1.25 rounded to one decimal, decimal comma + expect(text).to.include("1,5 bar"); + }); + it("renders nothing without a bound entity or without targets", async () => { const el = await mountCreate({ "sensor.pressure": { state: "1.2", attributes: {} }, @@ -137,3 +168,28 @@ describe("task-dialog live trigger hint", () => { expect(text).to.include("100 h"); }); }); + +describe("task-dialog state-change latch hint (#167)", () => { + const latchHint = (el: MaintenanceTaskDialog): boolean => + [...el.shadowRoot!.querySelectorAll(".field-help")].some((n) => /latch/i.test(n.textContent || "")); + + it("explains the latch (incl. the from-only recovery) for a single transition with a pattern", async () => { + const el = await mountCreate({ "sensor.dock_error": { state: "ok", attributes: {} } }); + (el as any)._triggerEntityId = "sensor.dock_error"; + (el as any)._triggerEntityIds = ["sensor.dock_error"]; + (el as any)._triggerType = "state_change"; + (el as any)._triggerFromState = "ok"; + (el as any)._triggerTargetChanges = "1"; + await el.updateComplete; + expect(latchHint(el), "from-only, target 1").to.equal(true); + + (el as any)._triggerTargetChanges = "3"; + await el.updateComplete; + expect(latchHint(el), "a counter is no latch").to.equal(false); + + (el as any)._triggerTargetChanges = ""; + (el as any)._triggerFromState = ""; + await el.updateComplete; + expect(latchHint(el), "no pattern = no latch semantics").to.equal(false); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts index 6616ceed..0c8fedfb 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts @@ -183,4 +183,17 @@ describe("task-documents in part mode", () => { const { el } = await mountPart([P_LINKED]); expect(el.shadowRoot!.querySelector(".tdoc-page")).to.not.exist; }); + + it("offers a local filter once 8+ documents are linked (#171)", async () => { + const many = Array.from({ length: 8 }, (_, i) => ({ ...LINKED, id: `l${i}`, title: i === 2 ? "Schaltplan" : `Manual ${i}`, filename: `m${i}.pdf` })); + const { el } = await mount(true, many); + const input = el.shadowRoot!.querySelector(".doc-filter input"); + expect(input, "filter box shown").to.exist; + input!.value = "schaltpl"; + input!.dispatchEvent(new Event("input")); + await el.updateComplete; + const rows = [...el.shadowRoot!.querySelectorAll(".tdoc-row")]; + expect(rows.length).to.equal(1); + expect(rows[0].textContent).to.contain("Schaltplan"); + }); }); 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 d83c1808..b386f643 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 @@ -35,6 +35,14 @@ interface BatteryRow { * healthy. Never escalates to low/task — the row just shows the * discrepancy (usual cause: an unrecorded swap). */ forecast_overdue?: boolean; + /** D#162: a Battery Notes note whose device has NO battery level at + * all — read from its type sensor. No level bar, never offline; the + * forecast is the whole signal and (option) a passed one is `low`. */ + no_sensor?: boolean; + /** D#162 follow-up: the note has a replaced button → per-row Replaced action. */ + can_mark_replaced?: boolean; + /** ISO date the forecast anchors on (Battery Notes' last replaced). */ + last_replaced?: string | null; } interface RosterRow extends BatteryRow { status: "low" | "soon" | "ok"; @@ -69,6 +77,9 @@ interface Overview { excluded?: { entity_id: string; device_name: string }[]; // #135 follow-up: fleet-wide "track self-charging devices" opt-in. track_self_charging?: boolean; + // D#162: a passed forecast on a sensorless note counts as due (default + // on — an older backend omits the field, which renders as on too). + due_without_sensor?: boolean; } export class MaintenanceBatteryFleetSection extends LitElement { @@ -213,13 +224,22 @@ export class MaintenanceBatteryFleetSection extends LitElement { // #135 follow-up: fleet-wide opt-in that keeps self-charging devices // (phones, vacuums, smart rings) in the roster as rechargeables. private async _setTrackSelf(e: Event): Promise { - const enabled = (e.target as HTMLInputElement).checked; + await this._setFleetOption("set_track_self_charging", (e.target as HTMLInputElement).checked); + } + + // D#162: fleet-wide option — a passed forecast on a sensorless note is + // due (fires the task). Default on; only the opt-out is stored. + private async _setDueWithoutSensor(e: Event): Promise { + await this._setFleetOption("set_due_without_sensor", (e.target as HTMLInputElement).checked); + } + + private async _setFleetOption(command: "set_track_self_charging" | "set_due_without_sensor", enabled: boolean): Promise { if (this._marking) return; this._marking = true; this._error = ""; try { await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/battery_fleet/set_track_self_charging", + type: `maintenance_supporter/battery_fleet/${command}`, enabled, }); await this._load(); @@ -428,7 +448,9 @@ export class MaintenanceBatteryFleetSection extends LitElement { ${b.device_name} ${b.available === false ? html`${t("battery_fleet_offline", L)}` - : nothing} + : b.no_sensor + ? html`${t("battery_fleet_no_sensor", L)}` + : nothing} ${b.quantity}× ${b.battery_type} ${b.rechargeable ? html` html`
${b.device_name} - ${t("battery_fleet_status_" + b.status, L)} + ${b.no_sensor && b.status === "low" + ? t("battery_fleet_status_due", L) + : t("battery_fleet_status_" + b.status, L)} ${b.quantity}× ${b.battery_type} + ${b.no_sensor + ? html`${t("battery_fleet_no_sensor", L)}` + : nothing} ${b.rechargeable ? html`${b.level}%` : nothing} + ${b.no_sensor || b.can_mark_replaced + ? html`` + : nothing} ${(() => { const jump = this._history?.[b.entity_id]?.jump; if (!jump || this._recorded.includes(b.entity_id)) return nothing; @@ -564,6 +603,16 @@ export class MaintenanceBatteryFleetSection extends LitElement { ${t("battery_fleet_track_self", L)}
${t("battery_fleet_track_self_hint", L)}
+ +
${t("battery_fleet_due_without_sensor_hint", L)}
` : nothing} @@ -712,6 +761,13 @@ export class MaintenanceBatteryFleetSection extends LitElement { .bf-bar { grid-column: 6; } + /* D#162: the roster's "No sensor" chip takes the level bar's slot + * (such a row has no bar); column 2 stays the status chip's. */ + .bf-status + .bf-type + .bf-nosensor { + grid-column: 6; + justify-self: start; + white-space: nowrap; + } .bf-level { grid-column: 7; justify-self: end; @@ -719,6 +775,14 @@ export class MaintenanceBatteryFleetSection extends LitElement { .bf-row .bf-mark { grid-column: 8; } + /* D#162: the sensorless row's Replaced action sits in the percentage + * slot (always empty for such a row) instead of the row-action column + * - a new max-content track there widens EVERY row via the subgrid + * and pushed the phone roster 34 px past its edge. */ + .bf-row .bf-mark.bf-replaced { + grid-column: 7; + justify-self: end; + } .bf-predicted { grid-column: 9; justify-self: end; @@ -791,6 +855,13 @@ export class MaintenanceBatteryFleetSection extends LitElement { .bf-row .bf-mark { grid-row: 2; } + /* The roster's "No sensor" chip would widen the shared bar track + * for EVERY row (subgrid) - on phones the missing percentage and + * the "Due" status already tell the story, so it yields like the + * bar and the sparkline do. */ + .bf-status + .bf-type + .bf-nosensor { + display: none; + } } .bf-spark-line { fill: none; 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 7660f739..40d3c4b2 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts @@ -2,11 +2,18 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import type { HomeAssistant, TaskPartLink } from "../types"; -import { t, nativeFieldStyles, formatCost } from "../styles"; +import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types"; +import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots"; +import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles"; import { describeWsError } from "../ws-errors"; import { partLinkKey, type LinkedPart } from "../helpers/shared-parts"; +import { isAndroidCompanion } from "../helpers/companion"; import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels"; +import { + MAX_COMPLETION_PHOTOS, + discardUploadedPhotos, + uploadCompletionPhoto, +} from "../helpers/photo-upload"; import "./ms-date-field"; export class MaintenanceCompleteDialog extends LitElement { @@ -20,6 +27,14 @@ export class MaintenanceCompleteDialog extends LitElement { // v2.20 (#83): task type + unit drive the reading-value field below. @property() public taskType = ""; @property() public readingUnit = ""; + /** #161 phase 2: the task's reading slots — one field each instead of + * the single value; [] keeps the scalar field. */ + @property({ attribute: false }) public readings: ReadingSlot[] = []; + /** The task's dated slot snapshots (oldest first) — the "last: …" hint + * and the lower-than-last warning compare against the last value BEFORE + * the completion moment, so a backdated entry is judged by its own + * date rather than by the newest reading. */ + @property({ attribute: false }) public readingHistory: ReadingHistoryEntry[] = []; /** 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 @@ -61,10 +76,15 @@ export class MaintenanceCompleteDialog extends LitElement { @state() private _error = ""; @state() private _checklistState: Record = {}; @state() private _feedback: string = "needed"; - @state() private _photoDocId = ""; - @state() private _photoPreview = ""; + /** #161: the photos attached so far, in pick order (preview = object URL). */ + @state() private _photos: Array<{ id: string; preview: string }> = []; + /** Docs uploaded by THIS dialog session; dropped again on Cancel so an + * abandoned completion leaves no orphan files behind. */ + private _uploadedIds: string[] = []; @state() private _photoUploading = false; @state() private _readingValue = ""; + /** #161 phase 2: typed text per slot id (parsed on save; "" = unread). */ + @state() private _readingValues: Record = {}; @state() private _restockQty = ""; /** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = now). */ @state() private _completedAt = ""; @@ -94,10 +114,12 @@ export class MaintenanceCompleteDialog extends LitElement { .filter(([, done]) => done), ); this._feedback = "needed"; - this._photoDocId = ""; - this._photoPreview = ""; + this._photos.forEach((p) => URL.revokeObjectURL(p.preview)); + this._photos = []; + this._uploadedIds = []; this._photoUploading = false; this._readingValue = ""; + this._readingValues = {}; this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : ""; this._completedAt = ""; // #99: prefill "parts used" with the task's fixed links — the user can @@ -118,45 +140,52 @@ export class MaintenanceCompleteDialog extends LitElement { this._feedback = value; } + /** The Android Companion app answers a multi-select with an empty file + * list (its chooser ignores the intent's ClipData — #161 follow-up), so + * the gallery picker is single-select there and a hint says each pick + * is added. Evaluated once per dialog; the host does not change. */ + private readonly _singlePick = isAndroidCompanion(); + + /** #161: both pickers (camera = one shot, gallery = multiple) land here. + * Files upload one after another so a slow connection still shows + * progress tile by tile; anything beyond the cap is dropped with a + * note rather than silently. */ private async _onPhotoInput(e: Event): Promise { const input = e.target as HTMLInputElement; - const file = input.files?.[0]; + const files = Array.from(input.files ?? []); input.value = ""; // allow re-picking the same file - if (!file) return; + if (files.length === 0) return; + const room = MAX_COMPLETION_PHOTOS - this._photos.length; + const accepted = files.slice(0, Math.max(room, 0)); this._photoUploading = true; this._error = ""; try { - const form = new FormData(); - form.append("entry_id", this.entryId); - form.append("tags", "photo"); - form.append("file", file, file.name); - const resp = await fetch("/api/maintenance_supporter/document/upload", { - method: "POST", - headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` }, - body: form, - }); - if (!resp.ok) { - this._error = resp.status === 413 - ? t("doc_too_large", this.lang) - : t("doc_upload_failed", this.lang); - return; + for (const file of accepted) { + const id = await uploadCompletionPhoto(this.hass, this.entryId, file); + this._uploadedIds = [...this._uploadedIds, id]; + this._photos = [...this._photos, { id, preview: URL.createObjectURL(file) }]; } - const doc = (await resp.json()) as { id?: string }; - if (doc.id) { - this._photoDocId = doc.id; - this._photoPreview = URL.createObjectURL(file); + if (files.length > accepted.length) { + this._error = t("photos_limit", this.lang).replace("{max}", String(MAX_COMPLETION_PHOTOS)); } - } catch { - this._error = t("doc_upload_failed", this.lang); + } catch (e) { + const key = e instanceof Error && e.message === "doc_too_large" ? "doc_too_large" : "doc_upload_failed"; + this._error = t(key, this.lang); } finally { this._photoUploading = false; } } - private _removePhoto(): void { - if (this._photoPreview) URL.revokeObjectURL(this._photoPreview); - this._photoDocId = ""; - this._photoPreview = ""; + /** ✕ on a tile: drop it from the completion AND delete the upload — + * the file only ever existed for this dialog session. */ + private _removePhoto(id: string): void { + const gone = this._photos.find((p) => p.id === id); + if (gone) URL.revokeObjectURL(gone.preview); + this._photos = this._photos.filter((p) => p.id !== id); + if (this._uploadedIds.includes(id)) { + this._uploadedIds = this._uploadedIds.filter((x) => x !== id); + void discardUploadedPhotos(this.hass, [id]); + } } private async _complete(): Promise { @@ -183,8 +212,8 @@ export class MaintenanceCompleteDialog extends LitElement { if (this.adaptiveEnabled) { data.feedback = this._feedback; } - if (this._photoDocId) { - data.photo_doc_id = this._photoDocId; + if (this._photos.length > 0) { + data.photo_doc_ids = this._photos.map((p) => p.id); } // Scan fallback: the backend accepts via_tag_scan on task/complete so a // require_tag_scan task can still be finished from the dialog the scan @@ -204,7 +233,18 @@ export class MaintenanceCompleteDialog extends LitElement { // normalisation as the history-edit dialog). data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt; } - if (this._readingValue !== "") { + if (this.readings.length > 0) { + // #161 phase 2: only the meters actually read go out; the backend + // snapshots name + unit per slot id. + const values: Record = {}; + for (const slot of this.readings) { + const raw = (this._readingValues[slot.id] ?? "").trim(); + if (raw === "") continue; + const num = parseFloat(raw.replace(",", ".")); + if (!isNaN(num)) values[slot.id] = num; + } + if (Object.keys(values).length > 0) data.reading_values = values; + } else if (this._readingValue !== "") { const rv = parseFloat(this._readingValue); if (!isNaN(rv)) data.reading_value = rv; } @@ -226,6 +266,7 @@ export class MaintenanceCompleteDialog extends LitElement { ); } await this.hass.connection.sendMessagePromise(data); + this._uploadedIds = []; // attached now — Cancel cleanup must not touch them this._open = false; this.dispatchEvent(new CustomEvent("task-completed")); } catch (e) { @@ -235,13 +276,37 @@ export class MaintenanceCompleteDialog extends LitElement { } } + /** #161 phase 2: one field per slot with the previous value as a hint; + * a value below the last one gets a warning (meters count up — a + * replaced meter or a typo), never a block. */ + private _renderReadingField(slot: ReadingSlot, L: string) { + const at = this._completedAt ? new Date(this._completedAt).getTime() : NaN; + const last = lastReadingBefore(this.readingHistory, slot.id, isNaN(at) ? undefined : at); + const unit = slot.unit || this.readingUnit; + const raw = (this._readingValues[slot.id] ?? "").trim(); + const num = raw === "" ? NaN : parseFloat(raw.replace(",", ".")); + const below = last !== undefined && !isNaN(num) && num < last.value; + const lastText = last !== undefined ? formatNumber(last.value, L, { maximumFractionDigits: 3 }) : ""; + return html` + `; + } + /** Required details the user has not supplied yet (drives Save + markers). */ private get _missingRequired(): string[] { const filled: Record = { notes: this._notes.trim() !== "", cost: this._cost.trim() !== "", duration: this._duration.trim() !== "", - photo: this._photoDocId !== "", + photo: this._photos.length > 0, // "Who did it" is filled in server-side from the authenticated // connection (websocket/tasks_actions.py), so the dialog satisfies it // as long as we ARE a logged-in user. Claiming it is always satisfied @@ -300,6 +365,11 @@ export class MaintenanceCompleteDialog extends LitElement { private _close(): void { this._open = false; + if (this._uploadedIds.length > 0) { + const orphans = this._uploadedIds; + this._uploadedIds = []; + void discardUploadedPhotos(this.hass, orphans); + } } /** Seed the backdate field with the current minute (local, seconds zeroed). */ @@ -330,7 +400,12 @@ export class MaintenanceCompleteDialog extends LitElement { `)}
` : nothing} - ${this.taskType === "reading" + ${this.readings.length > 0 + ? html`
+ ${t("readings_section", L)} + ${this.readings.map((slot) => this._renderReadingField(slot, L))} +
` + : this.taskType === "reading" ? html`