diff --git a/.ha_run.lock b/.ha_run.lock index 8bb28301..434f62c8 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.8.2", "start_ts": 1786865261.4982338} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.8.2", "start_ts": 1786968619.5823264} \ No newline at end of file diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources index 8c2b806f..d8b7b904 100644 --- a/.storage/lovelace_resources +++ b/.storage/lovelace_resources @@ -196,7 +196,7 @@ }, { "id": "e92ef0caff41454e9f49ea966ed99e41", - "url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374101", + "url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374102", "type": "module" }, { @@ -315,7 +315,7 @@ "type": "module" }, { - "id": "d79e36a45ffb434dae8af23a2404abe4", + "id": "3af9295bc61240279b88bc700c1ef48a", "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", "type": "module" } diff --git a/custom_components/hilo/__init__.py b/custom_components/hilo/__init__.py index 2315f034..a93e68db 100644 --- a/custom_components/hilo/__init__.py +++ b/custom_components/hilo/__init__.py @@ -80,6 +80,25 @@ PLATFORMS = COORDINATOR_AWARE_PLATFORMS + [ Platform.SWITCH, ] +# Suffixes used when building gateway-attached entity unique_ids in sensor.py +# (e.g. f"{device.identifier.lower()}-{suffix}"). Used to migrate those +# unique_ids when the gateway's identifier changes from DSN-based to MAC-based. +GATEWAY_ENTITY_SUFFIXES = [ + "defi_hilo", + "recompenses_hilo", + "notifications_hilo", + "outdoor_weather_hilo", + "hilo_gateway", + "hilo_rate_current", + "hilo_rate_low", + "hilo_rate_medium", + "hilo_rate_high", + "hilo_rate_access", + "hilo_rate_low_threshold", + "hilo_rate_reward_rate", + "hilo_cost_total", +] + @callback def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -124,9 +143,39 @@ def _async_register_custom_device( ) -async def async_setup_entry( # noqa: C901 - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +@callback +def _async_migrate_gateway_device_identifier( + hass: HomeAssistant, entry: ConfigEntry, old_dsn: str | None, new_mac: str +) -> None: + """Migrate the gateway device registry entry from DSN-based to MAC-based identity. + + Preserves the device's registry entry (and thus its device_id, so entities, + dashboards, and automations referencing it stay attached) by renaming its + identifier in place instead of letting a new device get created. + """ + if not old_dsn or old_dsn == new_mac: + return + + device_registry = dr.async_get(hass) + old_device = device_registry.async_get_device(identifiers={(DOMAIN, old_dsn)}) + if old_device is None: + return # fresh install, or already migrated + + new_device = device_registry.async_get_device(identifiers={(DOMAIN, new_mac)}) + if new_device is not None and new_device.id != old_device.id: + LOG.warning( + "Gateway device already registered under new identifier %s, skipping device migration", + new_mac, + ) + return + + device_registry.async_update_device( + old_device.id, new_identifiers={(DOMAIN, new_mac)} + ) + LOG.info("Migrated gateway device identifier %s -> %s", old_dsn, new_mac) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Hilo as config entry.""" HiloFlowHandler.async_register_implementation( hass, AuthCodeWithPKCEImplementation(hass) @@ -160,9 +209,7 @@ async def async_setup_entry( # noqa: C901 _async_standardize_config_entry(hass, entry) scan_interval = current_options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - scan_interval = ( - scan_interval if scan_interval >= MIN_SCAN_INTERVAL else MIN_SCAN_INTERVAL - ) + scan_interval = max(scan_interval, MIN_SCAN_INTERVAL) hilo = Hilo(hass, entry, api) try: @@ -176,9 +223,7 @@ async def async_setup_entry( # noqa: C901 hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = hilo - hass.async_create_task( - hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Note (ic-dev21): This is a bit of a hack to rename some entities that were created with non-standard names in early versions # HA has changed the way they name entities linked to a device by default and this breaks the naming scheme of the gateway entities. @@ -731,6 +776,29 @@ class Hilo: ) return self._events[event_id] + async def _fetch_legacy_gateway_dsn(self, new_mac: str) -> str | None: + """This function looks up the Hilo gateway device in the device registry and + returns its old DSN-based identifier if it exists. If it doesn't, + it returns Noneto use the MAC address instead.""" + device_registry = dr.async_get(self._hass) + for device in device_registry.devices.values(): + if device.manufacturer != "Hilo" or device.model != "EQ000017": + continue + for domain, identifier in device.identifiers: + if domain == DOMAIN and identifier != new_mac: + return identifier + return None + + @callback + def async_migrate_gateway_entities(self, old_dsn: str | None, new_mac: str) -> None: + """Migrate gateway-attached entity unique_ids from DSN-based to MAC-based.""" + if not old_dsn or old_dsn == new_mac: + return + for suffix in GATEWAY_ENTITY_SUFFIXES: + old_unique_id = f"{old_dsn.lower()}-{suffix}" + new_unique_id = f"{new_mac.lower()}-{suffix}" + self.async_migrate_unique_id(old_unique_id, new_unique_id, Platform.SENSOR) + async def async_init(self, scan_interval: int) -> None: """Initialize the Hilo "manager" class. @@ -772,10 +840,18 @@ class Hilo: ) ) - # Step 6: Register custom devices in HA - _async_register_custom_device( - self._hass, self.entry, self.devices.find_device(1) - ) + # Step 6: Migrate gateway identity (DSN -> MAC) if needed, then register + # custom devices in HA. + gateway = self.devices.find_device(1) + if gateway: + old_dsn = await self._fetch_legacy_gateway_dsn(gateway.identifier) + if old_dsn: + _async_migrate_gateway_device_identifier( + self._hass, self.entry, old_dsn, gateway.identifier + ) + self.async_migrate_gateway_entities(old_dsn, gateway.identifier) + _async_register_custom_device(self._hass, self.entry, gateway) + if self.track_unknown_sources: if not self.unknown_tracker_device: self.unknown_tracker_device = self.devices.generate_device( @@ -1122,7 +1198,7 @@ class Hilo: ATTR_UNIT_OF_MEASUREMENT: parent_unit, # note ic-dev21: now uses parent_unit directly ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, } - if not all(a in attrs.keys() for a in new_attrs.keys()): + if not all(a in attrs.keys() for a in new_attrs): LOG.warning( f"Fixing utility sensor: {entity} {current_state} new_attrs: {new_attrs}" ) diff --git a/custom_components/hilo/__pycache__/__init__.cpython-314.pyc b/custom_components/hilo/__pycache__/__init__.cpython-314.pyc index 6200fff6..bcb577a4 100644 Binary files a/custom_components/hilo/__pycache__/__init__.cpython-314.pyc and b/custom_components/hilo/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/hilo/__pycache__/climate.cpython-314.pyc b/custom_components/hilo/__pycache__/climate.cpython-314.pyc deleted file mode 100644 index 26af9e54..00000000 Binary files a/custom_components/hilo/__pycache__/climate.cpython-314.pyc and /dev/null differ diff --git a/custom_components/hilo/__pycache__/config_flow.cpython-314.pyc b/custom_components/hilo/__pycache__/config_flow.cpython-314.pyc index 11a294b9..18a91aa5 100644 Binary files a/custom_components/hilo/__pycache__/config_flow.cpython-314.pyc and b/custom_components/hilo/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/hilo/__pycache__/const.cpython-314.pyc b/custom_components/hilo/__pycache__/const.cpython-314.pyc index 749daf6c..9f460109 100644 Binary files a/custom_components/hilo/__pycache__/const.cpython-314.pyc and b/custom_components/hilo/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/hilo/__pycache__/entity.cpython-314.pyc b/custom_components/hilo/__pycache__/entity.cpython-314.pyc deleted file mode 100644 index 2aa918e0..00000000 Binary files a/custom_components/hilo/__pycache__/entity.cpython-314.pyc and /dev/null differ diff --git a/custom_components/hilo/__pycache__/light.cpython-314.pyc b/custom_components/hilo/__pycache__/light.cpython-314.pyc deleted file mode 100644 index 9a09a84d..00000000 Binary files a/custom_components/hilo/__pycache__/light.cpython-314.pyc and /dev/null differ diff --git a/custom_components/hilo/__pycache__/managers.cpython-314.pyc b/custom_components/hilo/__pycache__/managers.cpython-314.pyc deleted file mode 100644 index c0951e78..00000000 Binary files a/custom_components/hilo/__pycache__/managers.cpython-314.pyc and /dev/null differ diff --git a/custom_components/hilo/__pycache__/oauth2.cpython-314.pyc b/custom_components/hilo/__pycache__/oauth2.cpython-314.pyc index 8fe1567b..6c9d1c41 100644 Binary files a/custom_components/hilo/__pycache__/oauth2.cpython-314.pyc and b/custom_components/hilo/__pycache__/oauth2.cpython-314.pyc differ diff --git a/custom_components/hilo/__pycache__/sensor.cpython-314.pyc b/custom_components/hilo/__pycache__/sensor.cpython-314.pyc deleted file mode 100644 index 8af5cec8..00000000 Binary files a/custom_components/hilo/__pycache__/sensor.cpython-314.pyc and /dev/null differ diff --git a/custom_components/hilo/__pycache__/switch.cpython-314.pyc b/custom_components/hilo/__pycache__/switch.cpython-314.pyc deleted file mode 100644 index 2e97004e..00000000 Binary files a/custom_components/hilo/__pycache__/switch.cpython-314.pyc and /dev/null differ diff --git a/custom_components/hilo/manifest.json b/custom_components/hilo/manifest.json index 4bc53cd3..90c61d05 100644 --- a/custom_components/hilo/manifest.json +++ b/custom_components/hilo/manifest.json @@ -12,5 +12,5 @@ "iot_class": "cloud_push", "issue_tracker": "https://github.com/dvd-dev/hilo/issues", "requirements": ["python-hilo>=2026.3.5"], - "version": "2026.8.1" + "version": "2026.8.2" } diff --git a/custom_components/hilo/sensor.py b/custom_components/hilo/sensor.py index 46a2041f..ddf5639f 100644 --- a/custom_components/hilo/sensor.py +++ b/custom_components/hilo/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONCENTRATION_PARTS_PER_MILLION, CONF_SCAN_INTERVAL, CURRENCY_DOLLAR, PERCENTAGE, @@ -24,6 +23,7 @@ from homeassistant.const import ( Platform, UnitOfEnergy, UnitOfPower, + UnitOfRatio, UnitOfSoundPressure, UnitOfTemperature, __short_version__ as current_version, @@ -271,7 +271,7 @@ class Co2Sensor(HiloEntity, SensorEntity): """Define a Co2 sensor entity.""" _attr_device_class = SensorDeviceClass.CO2 - _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_native_unit_of_measurement = UnitOfRatio.PARTS_PER_MILLION _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, hilo, device): diff --git a/custom_components/maintenance_supporter/__init__.py b/custom_components/maintenance_supporter/__init__.py index 48d37425..c42c377c 100644 --- a/custom_components/maintenance_supporter/__init__.py +++ b/custom_components/maintenance_supporter/__init__.py @@ -88,9 +88,11 @@ from .const import ( from .coordinator import MaintenanceCoordinator from .entity.summary_coordinator import MaintenanceSummaryCoordinator from .frontend import async_register_card +from .helpers.aggregate import object_name as aggregate_object_name from .helpers.assist_sentences import async_sync as async_sync_assist_sentences from .helpers.dates import INTERVAL_UNITS from .helpers.documents import DocumentStore +from .helpers.global_options import get_global_entry from .helpers.notification_manager import NotificationManager from .helpers.schedule import normalize_task_storage from .helpers.task_fields import ( @@ -252,10 +254,7 @@ async def async_maybe_send_weekly_digest(hass: HomeAssistant, *, force: bool = F if not force and dt_util.now().weekday() != 0: # Monday only return - global_entry = next( - (e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID), - None, - ) + global_entry = get_global_entry(hass) if global_entry is None: return options = global_entry.options or global_entry.data @@ -290,10 +289,7 @@ async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: boo DEFAULT_WARRANTY_REMINDER_DAYS, ) - global_entry = next( - (e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID), - None, - ) + global_entry = get_global_entry(hass) if global_entry is None: return options = global_entry.options or global_entry.data @@ -314,7 +310,7 @@ async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: boo except (ValueError, TypeError): continue if delta == days or (force and 0 <= delta <= days): - names.append(obj.get("name") or entry.title) + names.append(aggregate_object_name(entry)) if not names: return nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY) @@ -336,10 +332,7 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None: from .const import CONF_REMINDER_LEAD_DAYS from .models.maintenance_task import MaintenanceTask - global_entry = next( - (e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID), - None, - ) + global_entry = get_global_entry(hass) if global_entry is None: return options = global_entry.options or global_entry.data @@ -368,7 +361,7 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None: continue # Merged data so last_performed reflects the Store, not stale entry data. merged = coordinator._get_merged_tasks_data() - obj_name = obj.get("name") or entry.title + obj_name = aggregate_object_name(entry) for task_id, task_data in merged.items(): if not task_data.get("enabled", True): continue @@ -678,7 +671,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: coordinator = getattr(rd, "coordinator", None) if rd else None if coordinator is None or not coordinator.data: continue - object_name = ce.data.get(CONF_OBJECT, {}).get("name", ce.title) + object_name = aggregate_object_name(ce) for task_id, task in coordinator.data.get(CONF_TASKS, {}).items(): status = str(task.get("_status", "")) if status == "archived": @@ -1345,7 +1338,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf is_fixable=True, severity=ir.IssueSeverity.WARNING, translation_key="device_link_self" if self_link else "device_link_lost", - translation_placeholders={"object": obj_data.get("name") or entry.title}, + translation_placeholders={"object": aggregate_object_name(entry)}, data={"entry_id": entry.entry_id}, ) else: diff --git a/custom_components/maintenance_supporter/binary_sensor.py b/custom_components/maintenance_supporter/binary_sensor.py index 00f3f3d7..f7afbf95 100644 --- a/custom_components/maintenance_supporter/binary_sensor.py +++ b/custom_components/maintenance_supporter/binary_sensor.py @@ -30,6 +30,7 @@ from .const import ( SIGNAL_TASK_RESET, MaintenanceStatus, slugify_object_name, + task_unique_id, ) from .coordinator import MaintenanceCoordinator from .entity.entity_base import MaintenanceEntity @@ -94,7 +95,7 @@ class MaintenanceBinarySensor(MaintenanceEntity, BinarySensorEntity): task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {}) object_slug = slugify_object_name(obj_data.get("name", "unknown")) - self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_overdue" + self._attr_unique_id = task_unique_id(object_slug, task_id, "overdue") entity_slug = task_data.get("entity_slug") if entity_slug: diff --git a/custom_components/maintenance_supporter/button.py b/custom_components/maintenance_supporter/button.py index 772174c1..dbc2c709 100644 --- a/custom_components/maintenance_supporter/button.py +++ b/custom_components/maintenance_supporter/button.py @@ -21,6 +21,7 @@ from .const import ( CONF_TASKS, GLOBAL_UNIQUE_ID, slugify_object_name, + task_unique_id, ) from .coordinator import MaintenanceCoordinator from .entity.entity_base import MaintenanceEntity @@ -89,7 +90,7 @@ class MaintenanceActionButton(MaintenanceEntity, ButtonEntity): task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {}) object_slug = slugify_object_name(obj_data.get("name", "unknown")) - self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_{action}" + self._attr_unique_id = task_unique_id(object_slug, task_id, action) self._attr_translation_key = f"button_{action}" # Custom entity_slug → stable, language-independent entity_id/name. entity_slug = task_data.get("entity_slug") diff --git a/custom_components/maintenance_supporter/calendar.py b/custom_components/maintenance_supporter/calendar.py index 3db57100..50456116 100644 --- a/custom_components/maintenance_supporter/calendar.py +++ b/custom_components/maintenance_supporter/calendar.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from .const import ( - CONF_ADVANCED_SCHEDULE_TIME, CONF_OBJECT, CONF_TASKS, DOMAIN, @@ -20,7 +19,9 @@ from .const import ( MaintenanceStatus, ScheduleType, ) +from .helpers.aggregate import merged_tasks from .helpers.dates import interval_span_days +from .helpers.global_options import is_schedule_time_enabled from .helpers.i18n import normalize_language from .models.maintenance_task import MaintenanceTask @@ -521,9 +522,7 @@ class MaintenanceCalendar(CalendarEntity): live_tasks = {} # Merge static (ConfigEntry) + dynamic (Store) task data - store = getattr(runtime_data, "store", None) if runtime_data else None - static_tasks = entry.data.get(CONF_TASKS, {}) - tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks + tasks_data = merged_tasks(entry) for task_id, task_dict in tasks_data.items(): task = MaintenanceTask.from_dict(task_dict) @@ -638,9 +637,5 @@ class MaintenanceCalendar(CalendarEntity): ) def _is_schedule_time_feature_enabled(self) -> bool: - """Lookup the global advanced flag — same approach as coordinator.""" - for ce in self._hass.config_entries.async_entries(DOMAIN): - if ce.unique_id == GLOBAL_UNIQUE_ID: - opts = ce.options or ce.data - return bool(opts.get(CONF_ADVANCED_SCHEDULE_TIME, False)) - return False + """Lookup the global advanced flag — same source as the coordinator.""" + return is_schedule_time_enabled(self._hass) diff --git a/custom_components/maintenance_supporter/config_flow.py b/custom_components/maintenance_supporter/config_flow.py index f6b1bd8c..e1f6af5a 100644 --- a/custom_components/maintenance_supporter/config_flow.py +++ b/custom_components/maintenance_supporter/config_flow.py @@ -16,14 +16,8 @@ from homeassistant.config_entries import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import selector -from .config_flow_helpers import ( - CALENDAR_KIND_VALUES, - apply_interval_unit, - calendar_schema, - interval_unit_selector, - schedule_from_calendar_input, -) from .config_flow_options_global import validate_notify_service +from .config_flow_schedule import ScheduleStepsMixin from .config_flow_trigger import TriggerConfigMixin from .const import ( CONF_DEFAULT_WARNING_DAYS, @@ -39,30 +33,15 @@ from .const import ( CONF_OBJECT_NOTES, CONF_OBJECT_SERIAL_NUMBER, CONF_OBJECT_WARRANTY_EXPIRY, - CONF_TASK_DUE_DATE, - CONF_TASK_ICON, - CONF_TASK_INTERVAL_DAYS, - CONF_TASK_INTERVAL_UNIT, - CONF_TASK_LABELS_TEXT, - CONF_TASK_NAME, - CONF_TASK_NOTES, - CONF_TASK_PRIORITY, - CONF_TASK_SCHEDULE_TYPE, - CONF_TASK_TYPE, - CONF_TASK_WARNING_DAYS, CONF_TASKS, - DEFAULT_INTERVAL_DAYS, DEFAULT_WARNING_DAYS, DOMAIN, GLOBAL_UNIQUE_ID, - MaintenanceTypeEnum, - ScheduleType, slugify_object_name, ) -from .helpers.global_options import get_default_warning_days from .helpers.i18n import normalize_language -from .helpers.schedule import KIND_WEEKDAYS, normalize_task_storage -from .helpers.task_fields import INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE +from .helpers.schedule import normalize_task_storage +from .helpers.task_fields import WARNING_DAYS_RANGE from .templates import ( TEMPLATE_CATEGORIES, ObjectTemplate, @@ -80,7 +59,7 @@ def _localized_template_default_name(template: ObjectTemplate, hass: HomeAssista return localize_template_text(template.name, normalize_language(hass)) or template.name -class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMAIN): +class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, ConfigFlow, domain=DOMAIN): """Handle a config flow for Maintenance Supporter.""" VERSION = 1 @@ -588,79 +567,16 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Add a maintenance task.""" - if user_input is not None: - if user_input.get("go_back"): - return await self.async_step_task_menu() - - self._current_task = { - "id": uuid4().hex, - CONF_TASK_NAME: user_input[CONF_TASK_NAME], - CONF_TASK_TYPE: user_input[CONF_TASK_TYPE], - CONF_TASK_SCHEDULE_TYPE: user_input[CONF_TASK_SCHEDULE_TYPE], - } - if user_input.get(CONF_TASK_ICON): - self._current_task[CONF_TASK_ICON] = user_input[CONF_TASK_ICON] - if user_input.get(CONF_TASK_PRIORITY): - self._current_task[CONF_TASK_PRIORITY] = user_input[CONF_TASK_PRIORITY] - if user_input.get(CONF_TASK_LABELS_TEXT): - self._current_task[CONF_TASK_LABELS_TEXT] = user_input[CONF_TASK_LABELS_TEXT] - - schedule = user_input[CONF_TASK_SCHEDULE_TYPE] - if schedule == ScheduleType.TIME_BASED: - return await self.async_step_time_based() - if schedule in CALENDAR_KIND_VALUES: - return await self.async_step_calendar() - if schedule == ScheduleType.SENSOR_BASED: - return await self.async_step_sensor_select() - if schedule == ScheduleType.ONE_TIME: - return await self.async_step_one_time() - # Manual - return await self.async_step_manual() - - type_options = [t.value for t in MaintenanceTypeEnum] - schedule_options = [ - ScheduleType.TIME_BASED, - *CALENDAR_KIND_VALUES, - ScheduleType.SENSOR_BASED, - ScheduleType.ONE_TIME, - ScheduleType.MANUAL, - ] - - return self.async_show_form( + return await self._schedule_add_task( + user_input, step_id="add_task", - data_schema=vol.Schema( - { - vol.Required(CONF_TASK_NAME): selector.TextSelector( - selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) - ), - vol.Required(CONF_TASK_TYPE, default=MaintenanceTypeEnum.CLEANING): selector.SelectSelector( - selector.SelectSelectorConfig( - options=type_options, - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="maintenance_type", - ) - ), - vol.Required(CONF_TASK_SCHEDULE_TYPE, default=ScheduleType.TIME_BASED): selector.SelectSelector( - selector.SelectSelectorConfig( - options=schedule_options, - mode=selector.SelectSelectorMode.LIST, - translation_key="schedule_type", - ) - ), - vol.Optional(CONF_TASK_ICON): selector.IconSelector(), - vol.Optional(CONF_TASK_PRIORITY, default="normal"): selector.SelectSelector( - selector.SelectSelectorConfig( - options=list(TASK_PRIORITIES), - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="task_priority", - ) - ), - vol.Optional(CONF_TASK_LABELS_TEXT): selector.TextSelector( - selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ), + on_go_back=self.async_step_task_menu, + time_based_step=self.async_step_time_based, + calendar_step=self.async_step_calendar, + sensor_step=self.async_step_sensor_select, + one_time_step=self.async_step_one_time, + manual_step=self.async_step_manual, + seed_id=True, description_placeholders={ "object_name": self._object_data.get(CONF_OBJECT_NAME, ""), }, @@ -668,131 +584,30 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA async def async_step_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Configure time-based schedule.""" - errors: dict[str, str] = {} - - if user_input is not None: - if user_input.get("go_back"): - return await self.async_step_add_task() - - interval = user_input.get(CONF_TASK_INTERVAL_DAYS, DEFAULT_INTERVAL_DAYS) - if interval <= 0: - errors[CONF_TASK_INTERVAL_DAYS] = "invalid_interval" - else: - self._current_task[CONF_TASK_INTERVAL_DAYS] = interval - apply_interval_unit(self._current_task, user_input) - self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get( - CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass) - ) - last_performed = user_input.get("last_performed") - if last_performed: - self._current_task["last_performed"] = str(last_performed) - - return self._save_task_and_return() - - return self.async_show_form( + return await self._schedule_time_based( + user_input, step_id="time_based", - data_schema=vol.Schema( - { - vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector( - selector.NumberSelectorConfig( - min=INTERVAL_DAYS_RANGE[0], - max=INTERVAL_DAYS_RANGE[1], - step=1, - mode=selector.NumberSelectorMode.BOX, - ) - ), - vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(), - vol.Optional("last_performed"): selector.DateSelector(), - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], - max=WARNING_DAYS_RANGE[1], - step=1, - mode=selector.NumberSelectorMode.BOX, - ) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ), - errors=errors, + on_go_back=self.async_step_add_task, + on_complete=self._save_task_and_return, ) async def async_step_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Configure a calendar recurrence kind (weekdays / nth_weekday / day_of_month) during initial setup.""" - errors: dict[str, str] = {} - kind = self._current_task.get(CONF_TASK_SCHEDULE_TYPE, KIND_WEEKDAYS) - - if user_input is not None: - if user_input.get("go_back"): - return await self.async_step_add_task() - schedule = schedule_from_calendar_input(kind, user_input) - if schedule is None: - errors["base"] = "invalid_schedule" - else: - self._current_task["schedule"] = schedule - self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get( - CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass) - ) - return self._save_task_and_return() - - schema = calendar_schema(kind).extend( - { - vol.Optional(CONF_TASK_WARNING_DAYS, default=get_default_warning_days(self.hass)): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ) - return self.async_show_form( + return await self._schedule_calendar( + user_input, step_id="calendar", - data_schema=schema, - errors=errors, + on_go_back=self.async_step_add_task, + on_complete=self._save_task_and_return, ) async def async_step_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Configure a one-time (non-recurring) task.""" - errors: dict[str, str] = {} - - if user_input is not None: - if user_input.get("go_back"): - return await self.async_step_add_task() - - due_date = user_input.get(CONF_TASK_DUE_DATE) - if not due_date: - errors[CONF_TASK_DUE_DATE] = "invalid_due_date" - else: - self._current_task[CONF_TASK_DUE_DATE] = str(due_date) - self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get( - CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass) - ) - return self._save_task_and_return() - - return self.async_show_form( + return await self._schedule_one_time( + user_input, step_id="one_time", - data_schema=vol.Schema( - { - vol.Required(CONF_TASK_DUE_DATE): selector.DateSelector(), - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], - max=WARNING_DAYS_RANGE[1], - step=1, - mode=selector.NumberSelectorMode.BOX, - ) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ), - errors=errors, + on_go_back=self.async_step_add_task, + on_complete=self._save_task_and_return, ) # --- Sensor trigger steps (thin wrappers delegating to TriggerConfigMixin) --- @@ -954,40 +769,11 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Configure manual schedule.""" - if user_input is not None: - if user_input.get("go_back"): - return await self.async_step_add_task() - - self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.MANUAL - self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get( - CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass) - ) - if user_input.get(CONF_TASK_NOTES): - self._current_task[CONF_TASK_NOTES] = user_input[CONF_TASK_NOTES] - - return self._save_task_and_return() - - return self.async_show_form( + return await self._schedule_manual( + user_input, step_id="manual", - data_schema=vol.Schema( - { - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - vol.Optional(CONF_TASK_NOTES): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.TEXT, - multiline=True, - ) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ), + on_go_back=self.async_step_add_task, + on_complete=self._save_task_and_return, ) async def async_step_finish(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: @@ -1023,49 +809,19 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA def _save_task_and_return(self) -> ConfigFlowResult: """Save the current task and return to task menu.""" - from homeassistant.util import dt as dt_util - - from .helpers.sanitize import cap_task_fields, parse_labels_text + from .config_flow_schedule import build_new_task_record task_id = self._current_task.get("id", uuid4().hex) - task_data = { - "id": task_id, - "object_id": self._object_data.get("id", ""), - "name": self._current_task.get(CONF_TASK_NAME, ""), - "type": self._current_task.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CUSTOM), - "enabled": True, - "schedule_type": self._current_task.get(CONF_TASK_SCHEDULE_TYPE, ScheduleType.TIME_BASED), - "warning_days": self._current_task.get(CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)), - "history": [], - # Anchor for next_due fallback when last_performed is None (issue #30). - "created_at": dt_util.now().date().isoformat(), - } - - # Calendar kinds carry a pre-built nested schedule; create_entry - # normalizes it (treated as authoritative over the flat fields). - if "schedule" in self._current_task: - task_data["schedule"] = self._current_task["schedule"] - - if CONF_TASK_INTERVAL_DAYS in self._current_task: - task_data["interval_days"] = int(self._current_task[CONF_TASK_INTERVAL_DAYS]) - if CONF_TASK_INTERVAL_UNIT in self._current_task: - task_data["interval_unit"] = self._current_task[CONF_TASK_INTERVAL_UNIT] - if CONF_TASK_DUE_DATE in self._current_task: - task_data["due_date"] = self._current_task[CONF_TASK_DUE_DATE] - if "last_performed" in self._current_task: - task_data["last_performed"] = self._current_task["last_performed"] - if "trigger_config" in self._current_task: - task_data["trigger_config"] = self._current_task["trigger_config"] - if CONF_TASK_NOTES in self._current_task: - task_data["notes"] = self._current_task[CONF_TASK_NOTES] - if CONF_TASK_ICON in self._current_task: - task_data["custom_icon"] = self._current_task[CONF_TASK_ICON] - if CONF_TASK_PRIORITY in self._current_task: - task_data["priority"] = self._current_task[CONF_TASK_PRIORITY] - if self._current_task.get(CONF_TASK_LABELS_TEXT): - task_data["labels"] = parse_labels_text(self._current_task[CONF_TASK_LABELS_TEXT]) - - cap_task_fields(task_data) + task_data = build_new_task_record( + self._current_task, + task_id=task_id, + object_id=self._object_data.get("id", ""), + hass=self.hass, + # No entry (and thus no Store) exists yet during setup — history and + # a backdated last_performed must ride entry.data. + seed_history=True, + include_last_performed=True, + ) self._tasks[task_id] = task_data self._current_task = {} diff --git a/custom_components/maintenance_supporter/config_flow_helpers.py b/custom_components/maintenance_supporter/config_flow_helpers.py index ebd68dc1..371e8833 100644 --- a/custom_components/maintenance_supporter/config_flow_helpers.py +++ b/custom_components/maintenance_supporter/config_flow_helpers.py @@ -157,6 +157,22 @@ def interval_unit_selector() -> selector.SelectSelector: ) +def interval_anchor_selector() -> selector.SelectSelector: + """Shared completion/planned anchor dropdown (issue #30). + + Single source for the add-task time-based step (both flows via + ScheduleStepsMixin) and the task-edit form.""" + return selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict(value="completion", label="From completion date"), + selector.SelectOptionDict(value="planned", label="From planned date (no drift)"), + ], + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ) + + def apply_interval_unit(target: dict[str, Any], user_input: dict[str, Any]) -> None: """Persist ``interval_unit`` from a flow step into ``target`` only when it differs from the implicit default ``days`` (keeps stored task dicts minimal). diff --git a/custom_components/maintenance_supporter/config_flow_options_task_add.py b/custom_components/maintenance_supporter/config_flow_options_task_add.py index 68391c3e..143be7bc 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_add.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_add.py @@ -1,43 +1,16 @@ -"""Add-task + schedule-kind steps (mixin).""" +"""Add-task + schedule-kind steps (mixin). + +Thin wrappers: the step content lives in config_flow_schedule.ScheduleStepsMixin, +shared verbatim with the setup wizard (config_flow.py) so the two can't drift. +""" from __future__ import annotations from typing import TYPE_CHECKING, Any -import voluptuous as vol from homeassistant.config_entries import ConfigFlowResult -from homeassistant.helpers import selector -from .config_flow_helpers import ( - CALENDAR_KIND_VALUES, - apply_interval_unit, - calendar_schema, - interval_unit_selector, - schedule_from_calendar_input, -) -from .const import ( - CONF_TASK_DUE_DATE, - CONF_TASK_ICON, - CONF_TASK_INTERVAL_ANCHOR, - CONF_TASK_INTERVAL_DAYS, - CONF_TASK_INTERVAL_UNIT, - CONF_TASK_LABELS_TEXT, - CONF_TASK_NAME, - CONF_TASK_NOTES, - CONF_TASK_PRIORITY, - CONF_TASK_READING_UNIT, - CONF_TASK_SCHEDULE_TYPE, - CONF_TASK_TYPE, - CONF_TASK_WARNING_DAYS, - DEFAULT_INTERVAL_DAYS, - MaintenanceTypeEnum, - ScheduleType, -) -from .helpers.global_options import get_default_warning_days -from .helpers.schedule import ( - KIND_WEEKDAYS, -) -from .helpers.task_fields import INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE +from .config_flow_schedule import ScheduleStepsMixin if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -45,7 +18,7 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant -class AddTaskMixin: +class AddTaskMixin(ScheduleStepsMixin): """Add a new task and pick its schedule kind.""" # -- provided by the assembled MaintenanceOptionsFlow -- @@ -58,257 +31,57 @@ class AddTaskMixin: def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ... async def async_step_opt_sensor_select(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: ... + def _wire_add_task_callbacks(self) -> None: + """Route the sensor-trigger sub-flow's completion/cancel to this flow.""" + self._trigger_on_complete = self._save_new_task + self._on_cancel = self._show_init_menu + async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Add a new task — step 1: name, type, schedule.""" - if user_input is not None: - if user_input.get("go_back"): - return self._show_init_menu() - - self._current_task = { - CONF_TASK_NAME: user_input[CONF_TASK_NAME], - CONF_TASK_TYPE: user_input.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CLEANING), - CONF_TASK_SCHEDULE_TYPE: user_input[CONF_TASK_SCHEDULE_TYPE], - } - if user_input.get(CONF_TASK_ICON): - self._current_task[CONF_TASK_ICON] = user_input[CONF_TASK_ICON] - if user_input.get(CONF_TASK_PRIORITY): - self._current_task[CONF_TASK_PRIORITY] = user_input[CONF_TASK_PRIORITY] - if user_input.get(CONF_TASK_LABELS_TEXT): - self._current_task[CONF_TASK_LABELS_TEXT] = user_input[CONF_TASK_LABELS_TEXT] - if user_input.get(CONF_TASK_READING_UNIT): - self._current_task[CONF_TASK_READING_UNIT] = user_input[CONF_TASK_READING_UNIT].strip() - - self._trigger_on_complete = self._save_new_task - self._on_cancel = self._show_init_menu - - schedule = user_input[CONF_TASK_SCHEDULE_TYPE] - if schedule == ScheduleType.TIME_BASED: - return await self.async_step_opt_time_based() - if schedule in CALENDAR_KIND_VALUES: - return await self.async_step_opt_calendar() - if schedule == ScheduleType.SENSOR_BASED: - return await self.async_step_opt_sensor_select() - if schedule == ScheduleType.ONE_TIME: - return await self.async_step_opt_one_time() - # Manual - return await self.async_step_opt_manual() - - type_options = [t.value for t in MaintenanceTypeEnum] - # Recurrence kinds: time-based, the calendar kinds (Phase 4), then the - # trigger/one-time/manual kinds. - schedule_options = [ - ScheduleType.TIME_BASED, - *CALENDAR_KIND_VALUES, - ScheduleType.SENSOR_BASED, - ScheduleType.ONE_TIME, - ScheduleType.MANUAL, - ] - - return self.async_show_form( + return await self._schedule_add_task( + user_input, step_id="add_task", - data_schema=vol.Schema( - { - vol.Required(CONF_TASK_NAME): selector.TextSelector( - selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) - ), - vol.Required(CONF_TASK_TYPE, default=MaintenanceTypeEnum.CLEANING): selector.SelectSelector( - selector.SelectSelectorConfig( - options=type_options, - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="maintenance_type", - ) - ), - vol.Required(CONF_TASK_SCHEDULE_TYPE, default=ScheduleType.TIME_BASED): selector.SelectSelector( - selector.SelectSelectorConfig( - options=schedule_options, - mode=selector.SelectSelectorMode.LIST, - translation_key="schedule_type", - ) - ), - vol.Optional(CONF_TASK_ICON): selector.IconSelector(), - vol.Optional(CONF_TASK_PRIORITY, default="normal"): selector.SelectSelector( - selector.SelectSelectorConfig( - options=list(TASK_PRIORITIES), - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="task_priority", - ) - ), - vol.Optional(CONF_TASK_LABELS_TEXT): selector.TextSelector( - selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) - ), - # v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³"). - vol.Optional(CONF_TASK_READING_UNIT): selector.TextSelector( - selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ), + on_go_back=self._show_init_menu, + time_based_step=self.async_step_opt_time_based, + calendar_step=self.async_step_opt_calendar, + sensor_step=self.async_step_opt_sensor_select, + one_time_step=self.async_step_opt_one_time, + manual_step=self.async_step_opt_manual, + before_dispatch=self._wire_add_task_callbacks, ) async def async_step_opt_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Configure time-based schedule for new task.""" - errors: dict[str, str] = {} - - if user_input is not None: - if user_input.get("go_back"): - return self._show_init_menu() - - interval = user_input.get(CONF_TASK_INTERVAL_DAYS) - if not interval or interval <= 0: - errors[CONF_TASK_INTERVAL_DAYS] = "invalid_interval" - else: - self._current_task[CONF_TASK_INTERVAL_DAYS] = interval - apply_interval_unit(self._current_task, user_input) - self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get( - CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass) - ) - self._current_task[CONF_TASK_INTERVAL_ANCHOR] = user_input.get(CONF_TASK_INTERVAL_ANCHOR, "completion") - last_performed = user_input.get("last_performed") - if last_performed: - self._current_task["last_performed"] = str(last_performed) - - return self._save_new_task() - - return self.async_show_form( + return await self._schedule_time_based( + user_input, step_id="opt_time_based", - data_schema=vol.Schema( - { - vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector( - selector.NumberSelectorConfig( - min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(), - vol.Optional(CONF_TASK_INTERVAL_ANCHOR, default="completion"): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict(value="completion", label="From completion date"), - selector.SelectOptionDict(value="planned", label="From planned date (no drift)"), - ], - mode=selector.SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional("last_performed"): selector.DateSelector(), - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ), - errors=errors, + on_go_back=self._show_init_menu, + on_complete=self._save_new_task, ) async def async_step_opt_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Configure a calendar recurrence kind for a new task.""" - errors: dict[str, str] = {} - kind = self._current_task.get(CONF_TASK_SCHEDULE_TYPE, KIND_WEEKDAYS) - - if user_input is not None: - if user_input.get("go_back"): - return self._show_init_menu() - schedule = schedule_from_calendar_input(kind, user_input) - if schedule is None: - errors["base"] = "invalid_schedule" - else: - self._current_task["schedule"] = schedule - self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get( - CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass) - ) - if user_input.get("last_performed"): - self._current_task["last_performed"] = str(user_input["last_performed"]) - return self._save_new_task() - - schema = calendar_schema(kind).extend( - { - vol.Optional("last_performed"): selector.DateSelector(), - vol.Optional(CONF_TASK_WARNING_DAYS, default=get_default_warning_days(self.hass)): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ) - return self.async_show_form( + return await self._schedule_calendar( + user_input, step_id="opt_calendar", - data_schema=schema, - errors=errors, - description_placeholders={"kind": kind}, + on_go_back=self._show_init_menu, + on_complete=self._save_new_task, ) async def async_step_opt_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Configure a one-time (non-recurring) task for new task.""" - errors: dict[str, str] = {} - - if user_input is not None: - if user_input.get("go_back"): - return self._show_init_menu() - - due_date = user_input.get(CONF_TASK_DUE_DATE) - if not due_date: - errors[CONF_TASK_DUE_DATE] = "invalid_due_date" - else: - self._current_task[CONF_TASK_DUE_DATE] = str(due_date) - self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get( - CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass) - ) - return self._save_new_task() - - return self.async_show_form( + return await self._schedule_one_time( + user_input, step_id="opt_one_time", - data_schema=vol.Schema( - { - vol.Required(CONF_TASK_DUE_DATE): selector.DateSelector(), - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ), - errors=errors, + on_go_back=self._show_init_menu, + on_complete=self._save_new_task, ) async def async_step_opt_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Configure manual schedule for new task.""" - if user_input is not None: - if user_input.get("go_back"): - return self._show_init_menu() - - self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.MANUAL - self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get( - CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass) - ) - if user_input.get(CONF_TASK_NOTES): - self._current_task[CONF_TASK_NOTES] = user_input[CONF_TASK_NOTES] - - return self._save_new_task() - - return self.async_show_form( + return await self._schedule_manual( + user_input, step_id="opt_manual", - data_schema=vol.Schema( - { - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - vol.Optional(CONF_TASK_NOTES): selector.TextSelector( - selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiline=True) - ), - vol.Optional("go_back", default=False): selector.BooleanSelector(), - } - ), + on_go_back=self._show_init_menu, + on_complete=self._save_new_task, ) diff --git a/custom_components/maintenance_supporter/config_flow_options_task_base.py b/custom_components/maintenance_supporter/config_flow_options_task_base.py index 522158c8..526f74f1 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_base.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_base.py @@ -17,25 +17,8 @@ from .const import ( CONF_ADVANCED_ADAPTIVE, CONF_ADVANCED_CHECKLISTS, CONF_OBJECT, - CONF_TASK_DUE_DATE, - CONF_TASK_ICON, - CONF_TASK_INTERVAL_ANCHOR, - CONF_TASK_INTERVAL_DAYS, - CONF_TASK_INTERVAL_UNIT, - CONF_TASK_LABELS_TEXT, - CONF_TASK_NAME, - CONF_TASK_NOTES, - CONF_TASK_PRIORITY, - CONF_TASK_SCHEDULE_TYPE, - CONF_TASK_TYPE, - CONF_TASK_WARNING_DAYS, CONF_TASKS, - DOMAIN, - GLOBAL_UNIQUE_ID, - MaintenanceTypeEnum, - ScheduleType, ) -from .helpers.global_options import get_default_warning_days from .helpers.schedule import ( normalize_task_storage, ) @@ -69,49 +52,15 @@ class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow): def _save_new_task(self) -> ConfigFlowResult: """Save the current task and return to init.""" - from homeassistant.util import dt as dt_util - - from .helpers.sanitize import cap_task_fields, parse_labels_text + from .config_flow_schedule import build_new_task_record task_id = uuid4().hex - task_data: dict[str, Any] = { - "id": task_id, - "object_id": self.config_entry.data.get(CONF_OBJECT, {}).get("id", ""), - "name": self._current_task.get(CONF_TASK_NAME, ""), - "type": self._current_task.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CUSTOM), - "enabled": True, - "schedule_type": self._current_task.get(CONF_TASK_SCHEDULE_TYPE, ScheduleType.TIME_BASED), - "warning_days": self._current_task.get(CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)), - # Anchor for next_due fallback when last_performed is None (issue #30). - "created_at": dt_util.now().date().isoformat(), - } - - # Calendar kinds carry a pre-built nested schedule; normalize (in - # _update_config_entry) treats it as authoritative over the flat fields. - if "schedule" in self._current_task: - task_data["schedule"] = self._current_task["schedule"] - - if CONF_TASK_INTERVAL_DAYS in self._current_task: - task_data["interval_days"] = int(self._current_task[CONF_TASK_INTERVAL_DAYS]) - if CONF_TASK_INTERVAL_UNIT in self._current_task: - task_data["interval_unit"] = self._current_task[CONF_TASK_INTERVAL_UNIT] - if CONF_TASK_DUE_DATE in self._current_task: - task_data["due_date"] = self._current_task[CONF_TASK_DUE_DATE] - anchor = self._current_task.get(CONF_TASK_INTERVAL_ANCHOR, "completion") - if anchor != "completion": - task_data["interval_anchor"] = anchor - if "trigger_config" in self._current_task: - task_data["trigger_config"] = self._current_task["trigger_config"] - if CONF_TASK_NOTES in self._current_task: - task_data["notes"] = self._current_task[CONF_TASK_NOTES] - if CONF_TASK_ICON in self._current_task: - task_data["custom_icon"] = self._current_task[CONF_TASK_ICON] - if CONF_TASK_PRIORITY in self._current_task: - task_data["priority"] = self._current_task[CONF_TASK_PRIORITY] - if self._current_task.get(CONF_TASK_LABELS_TEXT): - task_data["labels"] = parse_labels_text(self._current_task[CONF_TASK_LABELS_TEXT]) - - cap_task_fields(task_data) + task_data = build_new_task_record( + self._current_task, + task_id=task_id, + object_id=self.config_entry.data.get(CONF_OBJECT, {}).get("id", ""), + hass=self.hass, + ) new_data = dict(self.config_entry.data) new_tasks = dict(new_data.get(CONF_TASKS, {})) new_tasks[task_id] = task_data @@ -171,10 +120,9 @@ class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow): def _get_global_options(self) -> dict[str, Any]: """Get global options from the global config entry.""" - for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == GLOBAL_UNIQUE_ID: - return dict(entry.options or entry.data) - return {} + from .helpers.global_options import get_global_options + + return dict(get_global_options(self.hass)) def _build_task_action_menu(self) -> list[str]: """Build the task_action menu options list.""" 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 0235e09e..a33ccd24 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_crud.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_crud.py @@ -13,6 +13,7 @@ from .config_flow_helpers import ( apply_season_ends, calendar_current, calendar_schema, + interval_anchor_selector, interval_unit_selector, schedule_from_calendar_input, season_ends_schema, @@ -385,15 +386,7 @@ class TaskCrudMixin: vol.Optional( CONF_TASK_INTERVAL_ANCHOR, default=sched["interval_anchor"], - ): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict(value="completion", label="From completion date"), - selector.SelectOptionDict(value="planned", label="From planned date (no drift)"), - ], - mode=selector.SelectSelectorMode.DROPDOWN, - ) - ), + ): interval_anchor_selector(), **( { vol.Optional( diff --git a/custom_components/maintenance_supporter/config_flow_trigger.py b/custom_components/maintenance_supporter/config_flow_trigger.py index e5e03254..0b3f5b41 100644 --- a/custom_components/maintenance_supporter/config_flow_trigger.py +++ b/custom_components/maintenance_supporter/config_flow_trigger.py @@ -104,6 +104,58 @@ def _recovery_default(tc: dict[str, Any] | None) -> bool: return bool((tc or {}).get("auto_complete_on_recovery")) +def _recovery_field(tc: dict[str, Any] | None) -> dict[Any, Any]: + """The #53 recovery checkbox — one schema entry, six call sites.""" + return { + vol.Optional( + "auto_complete_on_recovery", + default=_recovery_default(tc), + ): selector.BooleanSelector() + } + + +def _entity_logic_field(entity_ids: list[Any]) -> dict[Any, Any]: + """The any/all selector, shown only with 2+ entities — identical on every + trigger-type step (and per compound condition).""" + if len(entity_ids) < 2: + return {} + return { + vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict(value="any", label="Any entity triggers"), + selector.SelectOptionDict(value="all", label="All entities must trigger"), + ], + mode=selector.SelectSelectorMode.LIST, + translation_key="entity_logic", + ) + ) + } + + +def _interval_warning_fields(hass: HomeAssistant) -> dict[Any, Any]: + """The safety-interval + warning-days tail shared by all four type steps.""" + return { + vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( + selector.NumberSelectorConfig( + min=INTERVAL_DAYS_RANGE[0], + max=INTERVAL_DAYS_RANGE[1], + step=1, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(), + vol.Optional( + CONF_TASK_WARNING_DAYS, + default=get_default_warning_days(hass), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX + ) + ), + } + + def _state_selector(entity_id: str | None, *, multiple: bool = False) -> Any: """State field bound to the trigger entity (#129 follow-up). @@ -460,47 +512,10 @@ class TriggerConfigMixin: vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector( selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX) ), - vol.Optional( - "auto_complete_on_recovery", - default=_recovery_default(self._current_task.get("trigger_config")), - ): selector.BooleanSelector(), + **_recovery_field(self._current_task.get("trigger_config")), } - - # Add entity_logic selector when multiple entities are selected - entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", []) - if len(entity_ids) > 1: - schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict(value="any", label="Any entity triggers"), - selector.SelectOptionDict(value="all", label="All entities must trigger"), - ], - mode=selector.SelectSelectorMode.LIST, - translation_key="entity_logic", - ) - ) - - schema_fields.update( - { - vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( - selector.NumberSelectorConfig( - min=INTERVAL_DAYS_RANGE[0], - max=INTERVAL_DAYS_RANGE[1], - step=1, - mode=selector.NumberSelectorMode.BOX, - ) - ), - vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(), - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - } - ) + schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", []))) + schema_fields.update(_interval_warning_fields(self.hass)) return self.async_show_form( step_id=step_id, @@ -583,47 +598,10 @@ class TriggerConfigMixin: step="any", ) ), - vol.Optional( - "auto_complete_on_recovery", - default=_recovery_default(prev_tc), - ): selector.BooleanSelector(), + **_recovery_field(prev_tc), } - - # Add entity_logic selector when multiple entities are selected - entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", []) - if len(entity_ids) > 1: - schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict(value="any", label="Any entity triggers"), - selector.SelectOptionDict(value="all", label="All entities must trigger"), - ], - mode=selector.SelectSelectorMode.LIST, - translation_key="entity_logic", - ) - ) - - schema_fields.update( - { - vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( - selector.NumberSelectorConfig( - min=INTERVAL_DAYS_RANGE[0], - max=INTERVAL_DAYS_RANGE[1], - step=1, - mode=selector.NumberSelectorMode.BOX, - ) - ), - vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(), - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - } - ) + schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", []))) + schema_fields.update(_interval_warning_fields(self.hass)) return self.async_show_form( step_id=step_id, @@ -688,47 +666,10 @@ class TriggerConfigMixin: mode=selector.NumberSelectorMode.BOX, ) ), - vol.Optional( - "auto_complete_on_recovery", - default=_recovery_default(self._current_task.get("trigger_config")), - ): selector.BooleanSelector(), + **_recovery_field(self._current_task.get("trigger_config")), } - - # Add entity_logic selector when multiple entities are selected - entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", []) - if len(entity_ids) > 1: - schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict(value="any", label="Any entity triggers"), - selector.SelectOptionDict(value="all", label="All entities must trigger"), - ], - mode=selector.SelectSelectorMode.LIST, - translation_key="entity_logic", - ) - ) - - schema_fields.update( - { - vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( - selector.NumberSelectorConfig( - min=INTERVAL_DAYS_RANGE[0], - max=INTERVAL_DAYS_RANGE[1], - step=1, - mode=selector.NumberSelectorMode.BOX, - ) - ), - vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(), - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - } - ) + schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", []))) + schema_fields.update(_interval_warning_fields(self.hass)) return self.async_show_form( step_id=step_id, @@ -796,47 +737,10 @@ class TriggerConfigMixin: vol.Optional(CONF_TRIGGER_ON_STATES, default=default_states): _state_selector( self._trigger_entity_id, multiple=True ), - vol.Optional( - "auto_complete_on_recovery", - default=_recovery_default(current_tc), - ): selector.BooleanSelector(), + **_recovery_field(current_tc), } - - # Add entity_logic selector when multiple entities are selected - entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", []) - if len(entity_ids) > 1: - schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict(value="any", label="Any entity triggers"), - selector.SelectOptionDict(value="all", label="All entities must trigger"), - ], - mode=selector.SelectSelectorMode.LIST, - translation_key="entity_logic", - ) - ) - - schema_fields.update( - { - vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( - selector.NumberSelectorConfig( - min=INTERVAL_DAYS_RANGE[0], - max=INTERVAL_DAYS_RANGE[1], - step=1, - mode=selector.NumberSelectorMode.BOX, - ) - ), - vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(), - vol.Optional( - CONF_TASK_WARNING_DAYS, - default=get_default_warning_days(self.hass), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - } - ) + schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", []))) + schema_fields.update(_interval_warning_fields(self.hass)) return self.async_show_form( step_id=step_id, @@ -893,10 +797,7 @@ class TriggerConfigMixin: translation_key="compound_logic", ) ), - vol.Optional( - "auto_complete_on_recovery", - default=_recovery_default(self._current_task.get("trigger_config")), - ): selector.BooleanSelector(), + **_recovery_field(self._current_task.get("trigger_config")), } return self.async_show_form( step_id=step_id, @@ -1104,18 +1005,7 @@ class TriggerConfigMixin: ): _state_selector(cond.get("entity_id"), multiple=True), } - entity_ids = cond.get("entity_ids", []) - if len(entity_ids) > 1: - schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict(value="any", label="Any entity triggers"), - selector.SelectOptionDict(value="all", label="All entities must trigger"), - ], - mode=selector.SelectSelectorMode.LIST, - translation_key="entity_logic", - ) - ) + schema_fields.update(_entity_logic_field(cond.get("entity_ids", []))) return self.async_show_form( step_id=step_id, diff --git a/custom_components/maintenance_supporter/const.py b/custom_components/maintenance_supporter/const.py index ef75fc32..88380c87 100644 --- a/custom_components/maintenance_supporter/const.py +++ b/custom_components/maintenance_supporter/const.py @@ -47,6 +47,20 @@ def slugify_object_name(name: str) -> str: return slug +def task_unique_id(object_slug: str, task_id: str, suffix: str = "") -> str: + """The unique_id of a per-task entity: + ``maintenance_supporter_{slug}_{task_id}[_suffix]``. + + ONE formatter for every builder — the sensor/binary_sensor/button + platforms, the WS entity-id resolver, and the logbook each hand-formatted + this string, so changing the scheme in one place silently broke the + others' registry lookups. (The parse-side consumers — entity_rename and + the service-target resolver — key off the same prefix/suffix contract.) + """ + base = f"maintenance_supporter_{object_slug}_{task_id}" + return f"{base}_{suffix}" if suffix else base + + PLATFORMS: list[Platform] = [ Platform.SENSOR, Platform.BINARY_SENSOR, diff --git a/custom_components/maintenance_supporter/coordinator.py b/custom_components/maintenance_supporter/coordinator.py index a10b79e2..3f76feef 100644 --- a/custom_components/maintenance_supporter/coordinator.py +++ b/custom_components/maintenance_supporter/coordinator.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import time -from collections.abc import Mapping from datetime import date, timedelta from typing import TYPE_CHECKING, Any @@ -23,7 +22,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( BUDGET_CACHE_KEY, BUDGET_CURRENCIES, - CONF_ADVANCED_SCHEDULE_TIME, CONF_BUDGET_ALERT_THRESHOLD, CONF_BUDGET_ALERTS_ENABLED, CONF_BUDGET_CURRENCY, @@ -38,7 +36,6 @@ from .const import ( EVENT_TASK_COMPLETED, EVENT_TASK_RESET, EVENT_TASK_SKIPPED, - GLOBAL_UNIQUE_ID, MANUAL_COMPLETION_DEDUP_SECONDS, MISSING_ENTITY_THRESHOLD_REFRESHES, NOTIFICATION_MANAGER_KEY, @@ -51,6 +48,8 @@ from .const import ( TriggerEntityState, ) from .helpers.budget import compute_spend +from .helpers.entry_tasks import write_task +from .helpers.global_options import get_global_options, is_schedule_time_enabled from .helpers.schedule import normalize_task_storage, read_legacy_fields from .models.maintenance_object import MaintenanceObject from .models.maintenance_task import MaintenanceTask @@ -59,6 +58,24 @@ from .storage import MaintenanceStore _LOGGER = logging.getLogger(__name__) +def _inert_task_result(task: MaintenanceTask, status: str, **extra: Any) -> dict[str, Any]: + """Coordinator payload for a task that gets NO live evaluation (archived / + paused): due fields nulled, trigger off, only cost/history-derived fields + surfaced. The archived and paused short-circuits were hand-copied twins.""" + task_result = task.to_dict() + task_result["_status"] = status + task_result["_days_until_due"] = None + task_result["_next_due"] = None + task_result["_is_done"] = task.is_done + task_result["_trigger_active"] = False + task_result["_times_performed"] = task.times_performed + task_result["_total_cost"] = task.total_cost + task_result["_average_duration"] = task.average_duration + task_result["_last_entry"] = task.last_entry + task_result.update(extra) + return task_result + + class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for a single maintenance object and its tasks.""" @@ -96,18 +113,8 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._trigger_entity_states: dict[str, str] = {} # task_id -> TriggerEntityState def _is_schedule_time_feature_enabled(self) -> bool: - """Return True iff the global advanced flag for time-of-day scheduling is on. - - Reads the global config entry's options on every call. Cheap (just a - dict lookup over the small list of integration entries) and means a - toggle in Settings takes effect on the next coordinator refresh - without needing a restart. - """ - for ce in self.hass.config_entries.async_entries(DOMAIN): - if ce.unique_id == GLOBAL_UNIQUE_ID: - opts = ce.options or ce.data - return bool(opts.get(CONF_ADVANCED_SCHEDULE_TIME, False)) - return False + """Return True iff the global advanced flag for time-of-day scheduling is on.""" + return is_schedule_time_enabled(self.hass) def _in_startup_grace_period(self) -> bool: """Return True if still within the startup grace period.""" @@ -195,17 +202,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): # are surfaced (budget keeps counting; the detail view still renders # the record). if task.archived_at is not None: - task_result = task.to_dict() - task_result["_status"] = MaintenanceStatus.ARCHIVED - task_result["_days_until_due"] = None - task_result["_next_due"] = None - task_result["_is_done"] = task.is_done - task_result["_trigger_active"] = False - task_result["_times_performed"] = task.times_performed - task_result["_total_cost"] = task.total_cost - task_result["_average_duration"] = task.average_duration - task_result["_last_entry"] = task.last_entry - result[CONF_TASKS][task_id] = task_result + result[CONF_TASKS][task_id] = _inert_task_result(task, MaintenanceStatus.ARCHIVED) continue # v2.20 (N3): tasks of a paused object are frozen — status PAUSED, @@ -214,18 +211,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): # first-class citizen in every view. `_paused` mirrors the status # for the dict-twin recomputation in helpers.status. if object_paused: - task_result = task.to_dict() - task_result["_status"] = MaintenanceStatus.PAUSED - task_result["_paused"] = True - task_result["_days_until_due"] = None - task_result["_next_due"] = None - task_result["_is_done"] = task.is_done - task_result["_trigger_active"] = False - task_result["_times_performed"] = task.times_performed - task_result["_total_cost"] = task.total_cost - task_result["_average_duration"] = task.average_duration - task_result["_last_entry"] = task.last_entry - result[CONF_TASKS][task_id] = task_result + result[CONF_TASKS][task_id] = _inert_task_result(task, MaintenanceStatus.PAUSED, _paused=True) continue # Restore live trigger state from previous coordinator data @@ -719,7 +705,6 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): # BEFORE the bundle threshold, like the vacation filter above. A stale # view id (view deleted) means no scope, never "silence everything". from .const import CONF_NOTIFY_SCOPE_VIEW_ID - from .helpers.global_options import get_global_options scope_view_id = get_global_options(self.hass).get(CONF_NOTIFY_SCOPE_VIEW_ID) or "" if scope_view_id: @@ -739,15 +724,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): from .const import ( CONF_NOTIFICATION_BUNDLE_THRESHOLD, CONF_NOTIFICATION_BUNDLING_ENABLED, - GLOBAL_UNIQUE_ID, ) - global_options: Mapping[str, Any] = {} - for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == GLOBAL_UNIQUE_ID: - global_options = entry.options or entry.data - break - + global_options = get_global_options(self.hass) bundling_enabled = global_options.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False) bundle_threshold = int(global_options.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2)) @@ -810,12 +789,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not isinstance(nm, NotificationManager) or not nm.enabled: return - global_options: Mapping[str, Any] = {} - for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == GLOBAL_UNIQUE_ID: - global_options = entry.options or entry.data - break - + global_options = get_global_options(self.hass) if not global_options.get(CONF_BUDGET_ALERTS_ENABLED, False): return @@ -1036,16 +1010,11 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) enriched_used: list[dict[str, Any]] | None = None if used_parts is not None or record_links: - own_catalog = self.entry.data.get("parts") or {} def _part_name(link: dict[str, Any]) -> str: - owner_id = link.get("entry_id") - catalog: dict[str, Any] = own_catalog - if owner_id and owner_id != self.entry.entry_id: - owner = self.hass.config_entries.async_get_entry(owner_id) - catalog = (owner.data.get("parts") or {}) if owner else {} - name = (catalog.get(link["part_id"]) or {}).get("name") - return str(name) if name else str(link["part_id"]) + from .parts_runtime import part_link_name + + return part_link_name(self.hass, self.entry, link) enriched_used = [ { @@ -1110,13 +1079,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): # writes then loses only the rotation — the completion is recorded, # and a retried completion can't double-advance the pointer. if task.responsible_user_id != pre_rotation_responsible: - new_data = dict(self.entry.data) - new_tasks = dict(new_data.get(CONF_TASKS, {})) - td = dict(new_tasks.get(task_id, {})) + td = dict(self.entry.data.get(CONF_TASKS, {}).get(task_id, {})) td["responsible_user_id"] = task.responsible_user_id - new_tasks[task_id] = td - new_data[CONF_TASKS] = new_tasks - self.hass.config_entries.async_update_entry(self.entry, data=new_data) + write_task(self.hass, self.entry, task_id, td) # Invalidate budget cache when a cost is recorded if cost is not None: diff --git a/custom_components/maintenance_supporter/diagnostics.py b/custom_components/maintenance_supporter/diagnostics.py index 1c492283..7cd68102 100644 --- a/custom_components/maintenance_supporter/diagnostics.py +++ b/custom_components/maintenance_supporter/diagnostics.py @@ -66,12 +66,10 @@ async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: Mainten diag["overview"] = _get_integration_overview(hass) else: # Object entry diagnostics — merge Store dynamic data for stats - runtime_data = getattr(entry, "runtime_data", None) - store = getattr(runtime_data, "store", None) if runtime_data else None - static_tasks = entry.data.get(CONF_TASKS, {}) - merged_tasks = store.merge_all_tasks(static_tasks) if store is not None else static_tasks + from .helpers.aggregate import merged_tasks + merged_data = dict(entry.data) - merged_data[CONF_TASKS] = merged_tasks + merged_data[CONF_TASKS] = merged_tasks(entry) diag["statistics"] = _calculate_statistics(merged_data) diag["trigger_status"] = _check_trigger_status(hass, entry.data) diff --git a/custom_components/maintenance_supporter/entity/triggers/runtime.py b/custom_components/maintenance_supporter/entity/triggers/runtime.py index 0f1ad8c6..91a4bc99 100644 --- a/custom_components/maintenance_supporter/entity/triggers/runtime.py +++ b/custom_components/maintenance_supporter/entity/triggers/runtime.py @@ -79,10 +79,16 @@ class RuntimeTrigger(BaseTrigger): async def async_setup(self) -> None: """Set up runtime trigger with state restoration.""" state = self.hass.states.get(self.entity_id) - if state is None: + if state is None or state.state in ("unavailable", "unknown"): + # No USABLE state yet (#131 family): "unavailable" must not read + # as OFF — a running device whose sensor merely connects late + # would lose its restored on_since anchor and undercount. Keep + # the restored tracking state untouched and let the first real + # state event decide. _LOGGER.info( - "Runtime trigger entity %s not yet available — listener registered, waiting for entity to appear", + "Runtime trigger entity %s not ready at setup (state=%s) — listener registered, waiting for a real state", self.entity_id, + state.state if state else "missing", ) self._unsub_listener = async_track_state_change_event( self.hass, diff --git a/custom_components/maintenance_supporter/entity/triggers/state_change.py b/custom_components/maintenance_supporter/entity/triggers/state_change.py index d75f495b..3cde2dff 100644 --- a/custom_components/maintenance_supporter/entity/triggers/state_change.py +++ b/custom_components/maintenance_supporter/entity/triggers/state_change.py @@ -31,6 +31,10 @@ class StateChangeTrigger(BaseTrigger): Triggers when count reaches target_changes. """ + # Setup saw no usable state -> reconcile on the first real one (#131). + # Class default so hand-built test instances inherit it. + _needs_latch_reconcile: bool = False + def __init__( self, hass: HomeAssistant, @@ -52,6 +56,7 @@ class StateChangeTrigger(BaseTrigger): self._change_count: int = trigger_config.get("trigger_change_count", 0) self._current_value = float(self._change_count) self._last_state: str | None = None + self._needs_latch_reconcile = False async def async_setup(self) -> None: """Set up state change trigger. @@ -61,40 +66,25 @@ class StateChangeTrigger(BaseTrigger): appears (old_state=None), so the trigger will self-heal automatically. """ state = self.hass.states.get(self.entity_id) - if state is None: + if state is None or state.state in ("unavailable", "unknown"): + # No USABLE state yet — the #131 family: trigger setup races both + # the entity's registration AND its device readiness (a Zigbee / + # Z-Wave problem sensor restores as unavailable long before it + # reports). "unavailable" must never read as "recovered" — it + # used to quietly clear a single-shot latch right here. Register + # the listener and defer the latch reconciliation to the first + # real state. + self._needs_latch_reconcile = True _LOGGER.info( - "Trigger entity %s not yet available — listener registered, waiting for entity to appear", + "Trigger entity %s not ready at setup (state=%s) — listener registered, latch check deferred", self.entity_id, + state.state if state else "missing", ) - # Register listener anyway so we catch the entity appearing self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition) return self._last_state = state.state - - # Restore triggered state from persisted change count - if self._change_count >= self._target_changes: - # Latch reconciliation: a single-shot state alarm (target_changes - # == 1) that already left its alert state while we were down is no - # longer active. Clear it quietly — the recovery transition was - # never observed, so we must NOT auto-complete for it here (that - # path only runs on a live off event, guarded against double-count). - if ( - self._to_state is not None - and self._target_changes == 1 - and _norm_state(state.state) != self._to_state - ): - self._change_count = 0 - self._current_value = 0.0 - if self.hass.is_running: - self.hass.async_create_task(self._persist_change_count()) - else: - self._triggered = True - self.entity.async_update_trigger_state( - is_triggered=True, - current_value=float(self._change_count), - trigger_entity_id=self.entity_id, - ) + self._reconcile_persisted_latch(state.state) # Register state change listener (override base: we handle events differently) self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition) @@ -108,6 +98,42 @@ class StateChangeTrigger(BaseTrigger): self._to_state, ) + def _reconcile_persisted_latch(self, live_state: str) -> None: + """Align the persisted change-count latch with the LIVE entity state. + + Runs at setup when the entity already exists, and again when the + entity first APPEARS (issue #131): trigger setup races HA's state + restoration, so a source that restores later kept a stale latch — + a problem sensor still on read OK, and a single-shot alarm that had + recovered while we were down stayed triggered. + + A single-shot state alarm (target_changes == 1) whose entity is no + longer in its alert state is cleared QUIETLY — the recovery + transition was never observed, so we must NOT auto-complete for it + here (that path only runs on a live off event, guarded against + double-count). Otherwise the latch is restored and repainted. + """ + 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: + self._change_count = 0 + self._current_value = 0.0 + self._triggered = False + if self.hass.is_running: + self.hass.async_create_task(self._persist_change_count()) + self.entity.async_update_trigger_state( + is_triggered=False, + current_value=0.0, + trigger_entity_id=self.entity_id, + ) + else: + self._triggered = True + self.entity.async_update_trigger_state( + is_triggered=True, + current_value=float(self._change_count), + trigger_entity_id=self.entity_id, + ) + @callback def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None: """Handle state transition and count matching changes.""" @@ -128,9 +154,14 @@ class StateChangeTrigger(BaseTrigger): new_val, ) self._logged_unavailable = False - # Capture initial state but don't count as a transition + # Capture initial state but don't count as a transition — and + # reconcile the persisted latch against it (issue #131): when the + # entity restores AFTER our setup, this appearance is the first + # moment the latch can be checked against reality. if new_val not in ("unavailable", "unknown"): + self._needs_latch_reconcile = False self._last_state = new_val + self._reconcile_persisted_latch(new_val) return old_val = old_state.state @@ -155,6 +186,17 @@ class StateChangeTrigger(BaseTrigger): ) self._logged_unavailable = False + # First REAL state after a setup that saw none/unavailable (#131 + # family): reconcile the persisted latch against it instead of + # counting the restore as a transition. Mid-run unavailability + # glitches never set the flag, so their observed recovery still goes + # through the normal transition/auto-complete path below. + if self._needs_latch_reconcile: + self._needs_latch_reconcile = False + self._last_state = new_val + self._reconcile_persisted_latch(new_val) + return + # Use _last_state as fallback when old_val is unavailable/unknown effective_old = old_val if old_val in ("unavailable", "unknown") and self._last_state is not None: diff --git a/custom_components/maintenance_supporter/export.py b/custom_components/maintenance_supporter/export.py index 46ccdf57..c978aad6 100644 --- a/custom_components/maintenance_supporter/export.py +++ b/custom_components/maintenance_supporter/export.py @@ -15,8 +15,8 @@ from .const import ( CONF_TASKS, DEFAULT_WARNING_DAYS, DOMAIN, - GLOBAL_UNIQUE_ID, ) +from .helpers.aggregate import get_object_entries, merged_tasks from .helpers.schedule import Schedule, read_legacy_fields _LOGGER = logging.getLogger(__name__) @@ -73,8 +73,7 @@ def _build_export_object( # Merge static + Store dynamic data for each task rd = getattr(entry, "runtime_data", None) store = getattr(rd, "store", None) if rd else None - static_tasks = entry.data.get(CONF_TASKS, {}) - tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks + tasks_data = merged_tasks(entry) ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {}) tasks = [] @@ -116,6 +115,9 @@ def _build_export_object( "entity_slug": tdata.get("entity_slug"), "adaptive_config": tdata.get("adaptive_config"), "checklist": tdata.get("checklist") or [], + # In-cycle ticks (#73) — merged_tasks overlays them from the Store; + # exporting them keeps half-done checklists across backup/restore. + "checklist_progress": tdata.get("checklist_progress"), "schedule_time": tdata.get("schedule_time"), # v2.17+ / #83 task fields — persisted and user-facing, so a JSON # backup must restore them (same field-completeness contract as #67 @@ -197,15 +199,11 @@ def _build_export_object( } -def object_entries(hass: HomeAssistant, entry_ids: set[str] | None = None) -> list[ConfigEntry]: - """The maintenance OBJECT entries (never the global hub), optionally - narrowed to a selection. Shared by every exporter so JSON/YAML/CSV apply - the same selective-export filter. ``entry_ids=None`` means all objects.""" - return [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.unique_id != GLOBAL_UNIQUE_ID and (entry_ids is None or entry.entry_id in entry_ids) - ] +# The maintenance OBJECT entries (never the global hub). Shared by every +# exporter so JSON/YAML/CSV apply the same selective-export filter — the +# implementation lives in helpers.aggregate, this module keeps the name its +# importers (csv_handler, doc_archive, WS adopt handlers) bind to. +object_entries = get_object_entries def build_export_data( @@ -284,3 +282,49 @@ def export_maintenance_data( """ data = build_export_data(hass, include_history=include_history) return serialize_export(data, fmt) + + +# 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") + + +def build_settings_export(hass: HomeAssistant) -> dict[str, Any]: + """The SECOND export: the global entry's settings. + + ``build_export_data`` deliberately carries only objects — groups, saved + views, vacation config, notification/budget settings and the feature + toggles live on the global entry and export through here instead + (2026-08 round-trip audit decision). Instance-bound keys are excluded + (see ``_NON_PORTABLE_SETTINGS``). + """ + from .const import ( + CONF_GROUPS, + CONF_SAVED_FILTER_VIEWS, + CONF_VACATION_BUFFER_DAYS, + CONF_VACATION_ENABLED, + CONF_VACATION_END, + CONF_VACATION_EXEMPT_TASK_IDS, + CONF_VACATION_START, + ) + from .helpers.global_options import get_global_options + from .helpers.settings_registry import ALLOWED_SETTING_KEYS + + opts = get_global_options(hass) + settings: dict[str, Any] = { + k: opts[k] for k in ALLOWED_SETTING_KEYS if k in opts and k not in _NON_PORTABLE_SETTINGS + } + # Structured sections with their own WS surfaces (not in the registry). + for key in ( + CONF_GROUPS, + CONF_SAVED_FILTER_VIEWS, + CONF_VACATION_ENABLED, + CONF_VACATION_START, + CONF_VACATION_END, + CONF_VACATION_BUFFER_DAYS, + CONF_VACATION_EXEMPT_TASK_IDS, + ): + if key in opts: + settings[key] = opts[key] + return {"version": 1, "global_settings": settings} diff --git a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts index 2b9b8ce9..3cb6ce2b 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts @@ -9,7 +9,7 @@ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import { UserService } from "../user-service"; import type { HAUser, HomeAssistant } from "../types"; @@ -55,7 +55,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { private _userService: UserService | null = null; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } updated(changed: Map): void { 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 113a7141..41185aca 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,7 +6,8 @@ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; +import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys"; import { describeWsError } from "../ws-errors"; import type { HomeAssistant } from "../types"; @@ -84,7 +85,7 @@ export class MaintenanceBatteryFleetSection extends LitElement { private _localeReady = false; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } connectedCallback(): void { @@ -232,24 +233,14 @@ export class MaintenanceBatteryFleetSection extends LitElement { `; } - private static readonly _SORT_KEY = "ms_bf_roster_sort"; - private static _storedSort(): "name" | "urgency" { - try { - const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY); - return v === "name" ? "name" : "urgency"; - } catch { - return "urgency"; - } + return lsGet(LS_KEYS.batteryRosterSort) === "name" ? "name" : "urgency"; } private _setSort(mode: "name" | "urgency"): void { this._rosterSort = mode; - try { - localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode); - } catch { - // storage unavailable — the toggle still works for this visit - } + // Storage may be unavailable — the toggle still works for this visit. + lsSet(LS_KEYS.batteryRosterSort, mode); } /** Urgency (the default, issue #123): low rows first — emptiest first — 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 c3390381..56876560 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,8 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles"; +import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles"; +import { registerCustomCard } from "../helpers/register-card"; import { describeWsError } from "../ws-errors"; import { sectionCardSharedStyles } from "./section-card-shared-styles"; import type { BudgetStatus, HomeAssistant } from "../types"; @@ -40,7 +41,7 @@ export class MaintenanceBudgetSectionCard extends LitElement { } private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } private get _isAdmin(): boolean { @@ -277,9 +278,7 @@ if (!customElements.get("maintenance-budget-section-card")) { ); } -(window as { customCards?: unknown[] }).customCards = - (window as { customCards?: unknown[] }).customCards || []; -((window as { customCards?: unknown[] }).customCards!).push({ +registerCustomCard({ type: "maintenance-budget-section-card", name: "Maintenance Supporter — Budget", description: "Inline monthly + yearly budget editor", diff --git a/custom_components/maintenance_supporter/frontend-src/components/documents-section.ts b/custom_components/maintenance_supporter/frontend-src/components/documents-section.ts index d6e6e014..c0063d3b 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/documents-section.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/documents-section.ts @@ -10,10 +10,12 @@ import { LitElement, html, css, nothing } from "lit"; import { isSafeHttpUrl } from "../helpers/url"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import { downloadUrl } from "../helpers/download"; +import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url"; import { formatBytes } from "../helpers/format-bytes"; +import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories"; import type { HomeAssistant } from "../types"; interface MaintenanceDocument { @@ -28,16 +30,6 @@ interface MaintenanceDocument { added_at?: string; } -const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const; -const CATEGORY_ICONS: Record = { - manual: "mdi:book-open-variant", - warranty: "mdi:shield-check", - invoice: "mdi:receipt-text-outline", - spare_parts: "mdi:cog-outline", - photo: "mdi:image-outline", - other: "mdi:file-document-outline", -}; - export class MaintenanceDocumentsSection extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public entryId!: string; @@ -67,16 +59,11 @@ export class MaintenanceDocumentsSection extends LitElement { } private async _sign(doc: MaintenanceDocument): Promise { - const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${doc.id}`, - expires: 300, - }); - return signed.path; + return signDocumentPath(this.hass, doc.id); } private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } updated(changed: Map): void { @@ -210,12 +197,7 @@ export class MaintenanceDocumentsSection extends LitElement { private async _download(doc: MaintenanceDocument): Promise { try { - const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${doc.id}`, - expires: 30, - }); - downloadUrl(signed.path, doc.filename || doc.title || "document"); + await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"); } catch (e) { this._error = describeWsError(e, this._lang); } @@ -228,15 +210,9 @@ export class MaintenanceDocumentsSection extends LitElement { this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc)); return; } - // Open the tab synchronously (in the click gesture) so it isn't popup-blocked, - // then point it at the freshly signed URL once it resolves. - const win = window.open("about:blank", "_blank"); try { - const url = await this._sign(doc); - // Absolute URL so it always resolves against the blank popup (about:blank). - if (win) win.location.href = new URL(url, window.location.origin).href; + await openSignedDocument(this.hass, doc.id); } catch (e) { - if (win) win.close(); this._error = describeWsError(e, this._lang); } } diff --git a/custom_components/maintenance_supporter/frontend-src/components/group-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/group-dialog.ts index 9c9594b7..32313da9 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/group-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/group-dialog.ts @@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t } from "../styles"; +import { t, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import "./ms-textfield"; import type { @@ -26,7 +26,7 @@ export class MaintenanceGroupDialog extends LitElement { @state() private _selected: Set = new Set(); // "entry_id:task_id" private get _lang(): string { - return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; + return langOf(this.hass); } public openCreate(): void { diff --git a/custom_components/maintenance_supporter/frontend-src/components/groups-section-card.ts b/custom_components/maintenance_supporter/frontend-src/components/groups-section-card.ts index 9b5cb604..00798cb5 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/groups-section-card.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/groups-section-card.ts @@ -8,7 +8,8 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; +import { registerCustomCard } from "../helpers/register-card"; import { describeWsError } from "../ws-errors"; import { sectionCardSharedStyles } from "./section-card-shared-styles"; import type { HomeAssistant } from "../types"; @@ -47,7 +48,7 @@ export class MaintenanceGroupsSectionCard extends LitElement { } private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } private get _isAdmin(): boolean { @@ -330,9 +331,7 @@ if (!customElements.get("maintenance-groups-section-card")) { ); } -(window as { customCards?: unknown[] }).customCards = - (window as { customCards?: unknown[] }).customCards || []; -((window as { customCards?: unknown[] }).customCards!).push({ +registerCustomCard({ type: "maintenance-groups-section-card", name: "Maintenance Supporter — Groups", description: "Inline group CRUD", diff --git a/custom_components/maintenance_supporter/frontend-src/components/history-edit-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/history-edit-dialog.ts index b0ec1ab8..57bb5dd8 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/history-edit-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/history-edit-dialog.ts @@ -9,7 +9,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t } from "../styles"; +import { t, langOf } from "../styles"; import type { HomeAssistant } from "../types"; import { describeWsError } from "../ws-errors"; @@ -50,7 +50,7 @@ export class MaintenanceHistoryEditDialog extends LitElement { private _originalSnapshot: HistoryEntryDraft | null = null; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } // #130: selectable parts + the edited selection (part key -> quantity; diff --git a/custom_components/maintenance_supporter/frontend-src/components/history-photo.ts b/custom_components/maintenance_supporter/frontend-src/components/history-photo.ts index ea54911b..5ba36ca0 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/history-photo.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/history-photo.ts @@ -10,6 +10,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { HomeAssistant } from "../types"; +import { signDocumentPath } from "../helpers/document-url"; export class MaintenanceHistoryPhoto extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -29,12 +30,7 @@ export class MaintenanceHistoryPhoto extends LitElement { private async _sign(): Promise { try { - const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${this.docId}`, - expires: 300, - }); - this._url = signed.path; + this._url = await signDocumentPath(this.hass, this.docId); } catch { this._failed = true; } diff --git a/custom_components/maintenance_supporter/frontend-src/components/object-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/object-dialog.ts index c5daab9e..910a85a0 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/object-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/object-dialog.ts @@ -3,7 +3,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types"; -import { t } from "../styles"; +import { t, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import "./ms-textfield"; @@ -33,7 +33,7 @@ export class MaintenanceObjectDialog extends LitElement { @state() private _entryId: string | null = null; // null = create, string = update private get _lang(): string { - return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; + return langOf(this.hass); } public openCreate(): void { diff --git a/custom_components/maintenance_supporter/frontend-src/components/object-quick-actions-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/object-quick-actions-dialog.ts index ba7e532f..3cc172be 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/object-quick-actions-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/object-quick-actions-dialog.ts @@ -11,7 +11,7 @@ import { LitElement, html, css, nothing } from "lit"; import { isSafeHttpUrl } from "../helpers/url"; import { property, state } from "lit/decorators.js"; -import { t, STATUS_COLORS } from "../styles"; +import { t, STATUS_COLORS, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types"; @@ -31,7 +31,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement { @state() private _error = ""; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } public async openFor(entryId: string): Promise { diff --git a/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts b/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts index 91023242..96bf47b5 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts @@ -15,7 +15,7 @@ import { LitElement, html, css, nothing } from "lit"; import { isSafeHttpUrl } from "../helpers/url"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import type { HomeAssistant, MaintenancePart } from "../types"; // Per-part document links (v2.26) — the task-documents component in part mode. @@ -70,7 +70,7 @@ export class MaintenancePartsSection extends LitElement { @state() private _docsFor: string | null = null; private get _lang(): string { - return this.hass?.locale?.language || this.hass?.language || "en"; + return langOf(this.hass); } public connectedCallback(): void { diff --git a/custom_components/maintenance_supporter/frontend-src/components/saved-views-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/saved-views-dialog.ts index 7d21def5..4cd6ad05 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/saved-views-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/saved-views-dialog.ts @@ -12,7 +12,7 @@ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import type { HomeAssistant, SavedView, SavedViewFilters } from "../types"; @@ -33,7 +33,7 @@ export class MaintenanceSavedViewsDialog extends LitElement { private _localeReady = false; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } updated(changed: Map): void { @@ -171,7 +171,7 @@ export class MaintenanceSavedViewsDialog extends LitElement { display: flex; flex-direction: column; gap: 12px; - min-width: 340px; + min-width: min(360px, calc(100vw - 24px)); max-width: 480px; width: 90vw; max-height: 80vh; diff --git a/custom_components/maintenance_supporter/frontend-src/components/seasonal-overrides-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/seasonal-overrides-dialog.ts index caa88926..7e217628 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/seasonal-overrides-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/seasonal-overrides-dialog.ts @@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t } from "../styles"; +import { t, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import type { HomeAssistant } from "../types"; @@ -23,7 +23,7 @@ export class SeasonalOverridesDialog extends LitElement { @state() private _values: string[] = new Array(12).fill(""); private get _lang(): string { - return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; + return langOf(this.hass); } public open(entryId: string, taskId: string, currentOverrides: Record | null | undefined): void { diff --git a/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts b/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts index 9201b967..9f9625c6 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts @@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types"; -import { t } from "../styles"; +import { t, langOf } from "../styles"; +import { signApiPath } from "../helpers/document-url"; +import { downloadUrl } from "../helpers/download"; import { UserService } from "../user-service"; import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns"; import { downloadTextFile } from "../helpers/download"; @@ -160,7 +162,7 @@ export class MaintenanceSettingsView extends LitElement { private _userService: UserService | null = null; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } updated(changedProps: Map): void { @@ -1430,6 +1432,7 @@ export class MaintenanceSettingsView extends LitElement { +

${t("settings_docs_archive", L)}

@@ -1510,6 +1513,22 @@ export class MaintenanceSettingsView extends LitElement { } } + /** The SECOND export: the global entry's settings (groups, saved views, + * vacation, notification/budget settings, feature toggles) — the objects + * export deliberately excludes them. Re-import via the regular import. */ + private async _exportSettings(): Promise { + try { + const result = await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/settings/export", + }) as { data: string }; + const ts = new Date().toISOString().slice(0, 10); + this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json"); + this._showToast(t("settings_export_success", this._lang)); + } catch { + this._showToast(t("action_error", this._lang)); + } + } + private async _exportYaml(): Promise { try { const ids = this._selectedEntryIds; @@ -1573,17 +1592,8 @@ export class MaintenanceSettingsView extends LitElement { try { const raw = this._selectedEntryIds; const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : ""; - const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/documents/archive${q}`, - expires: 300, - }); - const a = document.createElement("a"); - a.href = signed.path; - a.download = "maintenance-documents.zip"; - document.body.appendChild(a); - a.click(); - a.remove(); + const signed = await signApiPath(this.hass, `/api/maintenance_supporter/documents/archive${q}`); + downloadUrl(signed, "maintenance-documents.zip"); } catch { this._showToast(t("action_error", this._lang)); } diff --git a/custom_components/maintenance_supporter/frontend-src/components/storage-section-card.ts b/custom_components/maintenance_supporter/frontend-src/components/storage-section-card.ts index f0828a30..f4cb83ed 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/storage-section-card.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/storage-section-card.ts @@ -8,8 +8,9 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; +import { openSignedDocument } from "../helpers/document-url"; import { formatBytes } from "../helpers/format-bytes"; import type { HomeAssistant } from "../types"; @@ -55,7 +56,7 @@ export class MaintenanceStorageSectionCard extends LitElement { private _searchTimer = 0; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } updated(changed: Map): void { @@ -137,17 +138,9 @@ export class MaintenanceStorageSectionCard extends LitElement { window.open(doc.url, "_blank", "noopener"); return; } - const win = window.open("about:blank", "_blank"); try { - const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${doc.id}`, - expires: 300, - }); - // Absolute URL so it navigates the blank popup reliably (about:blank base). - if (win) win.location.href = new URL(s.path, window.location.origin).href; + await openSignedDocument(this.hass, doc.id); } catch (e) { - if (win) win.close(); this._error = describeWsError(e, this._lang); } } diff --git a/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts index bf27a20f..2d8ce28e 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts @@ -11,7 +11,7 @@ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import type { HomeAssistant } from "../types"; @@ -61,7 +61,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement { private _localeReady = false; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } updated(changed: Map): void { diff --git a/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts index 504d40cd..f66b4dfc 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts @@ -3,7 +3,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types"; -import { formatDate, t, weekdayName } from "../styles"; +import { formatDate, t, weekdayName, langOf } from "../styles"; import { UserService } from "../user-service"; import { partLinkKey } from "../helpers/shared-parts"; import { @@ -24,6 +24,8 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"]; // The type selector additionally offers "compound" (a group of conditions // joined by AND/OR); its per-condition sub-type is limited to the flat kinds. const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"]; +// Backend defaults for the adaptive tuning fields (ewa_alpha / min / max days). +const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const; /** One condition of a compound trigger — a flat trigger the user edits inline. * String-typed like the top-level fields (form inputs); coerced on save. */ @@ -279,9 +281,9 @@ export class MaintenanceTaskDialog extends LitElement { // Adaptive tuning (parity with the options flow's adaptive step) — Store- // managed like the environmental binding, saved through task/set_adaptive. @state() private _adaptiveEnabled = false; - @state() private _adaptiveAlpha = "0.3"; - @state() private _adaptiveMin = "7"; - @state() private _adaptiveMax = "365"; + @state() private _adaptiveAlpha: string = ADAPTIVE_DEFAULTS.alpha; + @state() private _adaptiveMin: string = ADAPTIVE_DEFAULTS.min; + @state() private _adaptiveMax: string = ADAPTIVE_DEFAULTS.max; @state() private _adaptiveSeasonal = true; @state() private _adaptivePrediction = true; private _adaptiveInitial = ""; @@ -295,7 +297,7 @@ export class MaintenanceTaskDialog extends LitElement { private _userService: UserService | null = null; private get _lang(): string { - return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; + return langOf(this.hass); } public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise { @@ -404,9 +406,9 @@ export class MaintenanceTaskDialog extends LitElement { this._environmentalInitial = this._environmentalEntity; this._environmentalAttributeInitial = this._environmentalAttribute; this._adaptiveEnabled = !!ac.enabled; - this._adaptiveAlpha = (ac.ewa_alpha ?? 0.3).toString(); - this._adaptiveMin = (ac.min_interval_days ?? 7).toString(); - this._adaptiveMax = (ac.max_interval_days ?? 365).toString(); + this._adaptiveAlpha = ac.ewa_alpha?.toString() ?? ADAPTIVE_DEFAULTS.alpha; + this._adaptiveMin = ac.min_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.min; + this._adaptiveMax = ac.max_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.max; this._adaptiveSeasonal = ac.seasonal_enabled !== false; this._adaptivePrediction = ac.sensor_prediction_enabled !== false; this._adaptiveInitial = this._adaptiveSnapshot(); @@ -497,9 +499,9 @@ export class MaintenanceTaskDialog extends LitElement { this._environmentalInitial = ""; this._environmentalAttributeInitial = ""; this._adaptiveEnabled = false; - this._adaptiveAlpha = "0.3"; - this._adaptiveMin = "7"; - this._adaptiveMax = "365"; + this._adaptiveAlpha = ADAPTIVE_DEFAULTS.alpha; + this._adaptiveMin = ADAPTIVE_DEFAULTS.min; + this._adaptiveMax = ADAPTIVE_DEFAULTS.max; this._adaptiveSeasonal = true; this._adaptivePrediction = true; this._adaptiveInitial = this._adaptiveSnapshot(); @@ -1314,34 +1316,13 @@ export class MaintenanceTaskDialog extends LitElement {
` : nothing} - ${this._availableAttributes.length > 0 - ? html` -
- - -
- ` - : html` - (this._triggerAttribute = (e.target as HTMLInputElement).value)} - > - ` - } + ${this._renderAttributeSelect({ + label: t("attribute_optional", L), + value: this._triggerAttribute, + suggested: this._suggestedAttributes, + available: this._availableAttributes, + onSelect: (v) => (this._triggerAttribute = v), + })} ${this._renderTriggerTypeFields()} ${this._renderTriggerLiveHint()} `} @@ -1576,76 +1557,73 @@ export class MaintenanceTaskDialog extends LitElement { `; } + /** Shared attribute selector: "use entity state" + suggested★ + remaining + * attributes (non-numeric flagged), textfield fallback when none known. */ + private _renderAttributeSelect(cfg: { + label: string; + value: string; + suggested: string[]; + available: { name: string; numeric: boolean }[]; + onSelect: (value: string) => void; + }) { + const L = this._lang; + if (cfg.available.length > 0) { + return html` +
+ + +
+ `; + } + return html` + cfg.onSelect((e.target as HTMLInputElement).value.trim())} + > + `; + } + /** Environmental attribute — the same live-fetched dropdown the flat and * compound attribute fields use, keyed by the environmental entity. */ private _renderEnvironmentalAttribute(L: string) { this._fetchConditionAttributes(this._environmentalEntity); const opts = this._conditionAttrOptions[this._environmentalEntity]; - if (opts && opts.available.length > 0) { - return html` -
- - -
- `; - } - return html` - (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())} - > - `; + return this._renderAttributeSelect({ + label: t("environmental_attribute_optional", L), + value: this._environmentalAttribute, + suggested: opts?.suggested ?? [], + available: opts?.available ?? [], + onSelect: (v) => (this._environmentalAttribute = v), + }); } /** Attribute selector for one compound condition — the same live-fetched * dropdown the flat editor has, keyed by the condition's first entity. */ private _renderConditionAttribute(c: CompoundConditionDraft, i: number) { - const L = this._lang; const firstId = c.entityIds.split(",")[0]?.trim() || ""; if (firstId) this._fetchConditionAttributes(firstId); const opts = firstId ? this._conditionAttrOptions[firstId] : undefined; - if (opts && opts.available.length > 0) { - return html` -
- - -
- `; - } - return html` - this._patchCondition(i, { attribute: (e.target as HTMLInputElement).value.trim() })} - > - `; + return this._renderAttributeSelect({ + label: t("attribute_optional", this._lang), + value: c.attribute, + suggested: opts?.suggested ?? [], + available: opts?.available ?? [], + onSelect: (v) => this._patchCondition(i, { attribute: v }), + }); } /** Type-specific inputs for a single compound condition (mirrors the flat diff --git a/custom_components/maintenance_supporter/frontend-src/components/task-documents.ts b/custom_components/maintenance_supporter/frontend-src/components/task-documents.ts index c6336a66..6b9efc5a 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/task-documents.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/task-documents.ts @@ -11,10 +11,12 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import { downloadUrl } from "../helpers/download"; +import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url"; import { formatBytes } from "../helpers/format-bytes"; +import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories"; import type { HomeAssistant } from "../types"; interface Doc { @@ -31,16 +33,6 @@ interface Doc { part_ids?: string[]; } -const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const; -const CATEGORY_ICONS: Record = { - manual: "mdi:book-open-variant", - warranty: "mdi:shield-check", - invoice: "mdi:receipt-text-outline", - spare_parts: "mdi:cog-outline", - photo: "mdi:image-outline", - other: "mdi:file-document-outline", -}; - export class MaintenanceTaskDocuments extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public entryId!: string; @@ -59,7 +51,7 @@ export class MaintenanceTaskDocuments extends LitElement { private _localeReady = false; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } /** The id linked docs reference — a task id or (part mode) a part id. */ @@ -158,19 +150,9 @@ export class MaintenanceTaskDocuments extends LitElement { // A per-task page hint jumps straight to the relevant page via the PDF // viewer's #page=N fragment (client-side, so it never breaks the signature). const page = this._pageFor(doc); - const frag = page ? `#page=${page}` : ""; - const win = window.open("about:blank", "_blank"); try { - const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${doc.id}`, - expires: 300, - }); - // Absolute URL: a fragment on a *root-relative* path won't resolve against - // the blank popup's about:blank base, so it would silently stay blank. - if (win) win.location.href = new URL(s.path + frag, window.location.origin).href; + await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : ""); } catch (e) { - if (win) win.close(); this._error = describeWsError(e, this._lang); } } @@ -196,12 +178,7 @@ export class MaintenanceTaskDocuments extends LitElement { private async _download(doc: Doc): Promise { try { - const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${doc.id}`, - expires: 30, - }); - downloadUrl(s.path, doc.filename || doc.title || "document"); + await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"); } catch (e) { this._error = describeWsError(e, this._lang); } diff --git a/custom_components/maintenance_supporter/frontend-src/components/task-quick-actions-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/task-quick-actions-dialog.ts index e41ec337..eeff139d 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/task-quick-actions-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/task-quick-actions-dialog.ts @@ -14,7 +14,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles"; +import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles"; import { describeWsError } from "../ws-errors"; import { renderWeibullSection } from "../renderers/weibull"; import { renderPredictionSection } from "../renderers/prediction"; @@ -62,7 +62,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement { private _featuresLoaded = false; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } /** Open the dialog. Loads fresh data from /object via WS so dialog stays in diff --git a/custom_components/maintenance_supporter/frontend-src/components/vacation-section-card.ts b/custom_components/maintenance_supporter/frontend-src/components/vacation-section-card.ts index 363fbca4..d739ce53 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/vacation-section-card.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/vacation-section-card.ts @@ -13,7 +13,8 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t, ensureLocale } from "../styles"; +import { t, ensureLocale, langOf } from "../styles"; +import { registerCustomCard } from "../helpers/register-card"; import { describeWsError } from "../ws-errors"; import { sectionCardSharedStyles } from "./section-card-shared-styles"; import type { HomeAssistant } from "../types"; @@ -55,7 +56,7 @@ export class MaintenanceVacationSectionCard extends LitElement { } private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } private get _isAdmin(): boolean { @@ -317,9 +318,7 @@ if (!customElements.get("maintenance-vacation-section-card")) { ); } -(window as { customCards?: unknown[] }).customCards = - (window as { customCards?: unknown[] }).customCards || []; -((window as { customCards?: unknown[] }).customCards!).push({ +registerCustomCard({ type: "maintenance-vacation-section-card", name: "Maintenance Supporter — Vacation", description: "Inline vacation mode toggle + dates", diff --git a/custom_components/maintenance_supporter/frontend-src/helpers/storage-keys.ts b/custom_components/maintenance_supporter/frontend-src/helpers/storage-keys.ts index 9b4458e8..45ea11e2 100644 --- a/custom_components/maintenance_supporter/frontend-src/helpers/storage-keys.ts +++ b/custom_components/maintenance_supporter/frontend-src/helpers/storage-keys.ts @@ -18,4 +18,29 @@ export const LS_KEYS = { objectView: "maintenance_supporter_object_view", objectsCache: "msp-objects-cache", gettingStartedDismissed: "msp-gs-dismissed", + batteryRosterSort: "ms_bf_roster_sort", } as const; + +/** + * Guarded storage access — localStorage can THROW (Safari private mode / + * locked-down policies raise instead of returning null), and an unguarded + * call aborts the surrounding handler. The 2026-08 DRY audit found two + * hand-written call sites that had missed the guard; every access now goes + * through these two (a source tripwire forbids direct localStorage use + * outside this module). + */ +export function lsGet(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +export function lsSet(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + /* private mode / storage blocked */ + } +} diff --git a/custom_components/maintenance_supporter/frontend-src/locales/cs.json b/custom_components/maintenance_supporter/frontend-src/locales/cs.json index 34914dfc..9c2038a0 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/cs.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/cs.json @@ -483,6 +483,7 @@ "settings_export_json": "Exportovat JSON", "settings_export_yaml": "Exportovat YAML", "settings_export_csv": "Exportovat CSV", + "settings_export_settings": "Exportovat nastavení (JSON)", "settings_import_csv": "Importovat CSV", "settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…", "settings_import_btn": "Importovat", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/da.json b/custom_components/maintenance_supporter/frontend-src/locales/da.json index f279b013..9449d4f7 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/da.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/da.json @@ -484,6 +484,7 @@ "settings_export_json": "Eksporter JSON", "settings_export_yaml": "Eksporter YAML", "settings_export_csv": "Eksporter CSV", + "settings_export_settings": "Eksportér indstillinger (JSON)", "settings_import_csv": "Importer CSV", "settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…", "settings_import_btn": "Importer", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/de.json b/custom_components/maintenance_supporter/frontend-src/locales/de.json index a8bc2fa0..900e84a9 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/de.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/de.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON exportieren", "settings_export_yaml": "YAML exportieren", "settings_export_csv": "CSV exportieren", + "settings_export_settings": "Einstellungen exportieren (JSON)", "settings_import_csv": "CSV importieren", "settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…", "settings_import_btn": "Importieren", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/en.json b/custom_components/maintenance_supporter/frontend-src/locales/en.json index de1c22c4..37f9159e 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/en.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/en.json @@ -484,6 +484,7 @@ "settings_export_json": "Export JSON", "settings_export_yaml": "Export YAML", "settings_export_csv": "Export CSV", + "settings_export_settings": "Export settings (JSON)", "settings_import_csv": "Import CSV", "settings_import_placeholder": "Paste JSON or CSV content here…", "settings_import_btn": "Import", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/es.json b/custom_components/maintenance_supporter/frontend-src/locales/es.json index f23a81b8..7701e67e 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/es.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/es.json @@ -483,6 +483,7 @@ "settings_export_json": "Exportar JSON", "settings_export_yaml": "Exportar YAML", "settings_export_csv": "Exportar CSV", + "settings_export_settings": "Exportar ajustes (JSON)", "settings_import_csv": "Importar CSV", "settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…", "settings_import_btn": "Importar", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/fi.json b/custom_components/maintenance_supporter/frontend-src/locales/fi.json index 2cfb6588..19ca1a84 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/fi.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/fi.json @@ -484,6 +484,7 @@ "settings_export_json": "Vie JSON", "settings_export_yaml": "Vie YAML", "settings_export_csv": "Vie CSV", + "settings_export_settings": "Vie asetukset (JSON)", "settings_import_csv": "Tuo CSV", "settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…", "settings_import_btn": "Tuo", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/fr.json b/custom_components/maintenance_supporter/frontend-src/locales/fr.json index bf17f41a..5dba5716 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/fr.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/fr.json @@ -483,6 +483,7 @@ "settings_export_json": "Exporter JSON", "settings_export_yaml": "Exporter YAML", "settings_export_csv": "Exporter CSV", + "settings_export_settings": "Exporter les réglages (JSON)", "settings_import_csv": "Importer CSV", "settings_import_placeholder": "Collez le contenu JSON ou CSV ici…", "settings_import_btn": "Importer", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/hi.json b/custom_components/maintenance_supporter/frontend-src/locales/hi.json index da305c83..8de72e44 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/hi.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/hi.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON निर्यात करें", "settings_export_yaml": "YAML निर्यात करें", "settings_export_csv": "CSV निर्यात करें", + "settings_export_settings": "सेटिंग्स निर्यात करें (JSON)", "settings_import_csv": "CSV आयात करें", "settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…", "settings_import_btn": "आयात करें", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/hu.json b/custom_components/maintenance_supporter/frontend-src/locales/hu.json index 600b149e..53ecbdaf 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/hu.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/hu.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON exportálása", "settings_export_yaml": "YAML exportálása", "settings_export_csv": "CSV exportálása", + "settings_export_settings": "Beállítások exportálása (JSON)", "settings_import_csv": "CSV importálása", "settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…", "settings_import_btn": "Importálás", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/it.json b/custom_components/maintenance_supporter/frontend-src/locales/it.json index 29a6fbf9..3fd26b70 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/it.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/it.json @@ -483,6 +483,7 @@ "settings_export_json": "Esporta JSON", "settings_export_yaml": "Esporta YAML", "settings_export_csv": "Esporta CSV", + "settings_export_settings": "Esporta impostazioni (JSON)", "settings_import_csv": "Importa CSV", "settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…", "settings_import_btn": "Importa", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/ja.json b/custom_components/maintenance_supporter/frontend-src/locales/ja.json index 8d591226..d80403b8 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/ja.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/ja.json @@ -484,6 +484,7 @@ "settings_export_json": "JSONをエクスポート", "settings_export_yaml": "YAMLをエクスポート", "settings_export_csv": "CSVをエクスポート", + "settings_export_settings": "設定をエクスポート(JSON)", "settings_import_csv": "CSVをインポート", "settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…", "settings_import_btn": "インポート", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/ko.json b/custom_components/maintenance_supporter/frontend-src/locales/ko.json index c788c2d0..c53c60fc 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/ko.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/ko.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON 내보내기", "settings_export_yaml": "YAML 내보내기", "settings_export_csv": "CSV 내보내기", + "settings_export_settings": "설정 내보내기 (JSON)", "settings_import_csv": "CSV 가져오기", "settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…", "settings_import_btn": "가져오기", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/nb.json b/custom_components/maintenance_supporter/frontend-src/locales/nb.json index b7afe48d..0fa109d9 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/nb.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/nb.json @@ -484,6 +484,7 @@ "settings_export_json": "Eksporter JSON", "settings_export_yaml": "Eksporter YAML", "settings_export_csv": "Eksporter CSV", + "settings_export_settings": "Eksporter innstillinger (JSON)", "settings_import_csv": "Importer CSV", "settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…", "settings_import_btn": "Importer", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/nl.json b/custom_components/maintenance_supporter/frontend-src/locales/nl.json index 2f1d92b6..305a34a4 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/nl.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/nl.json @@ -483,6 +483,7 @@ "settings_export_json": "JSON exporteren", "settings_export_yaml": "YAML exporteren", "settings_export_csv": "CSV exporteren", + "settings_export_settings": "Instellingen exporteren (JSON)", "settings_import_csv": "CSV importeren", "settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…", "settings_import_btn": "Importeren", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/pl.json b/custom_components/maintenance_supporter/frontend-src/locales/pl.json index b00666b9..9c2c10db 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/pl.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/pl.json @@ -483,6 +483,7 @@ "settings_export_json": "Eksportuj JSON", "settings_export_yaml": "Eksportuj YAML", "settings_export_csv": "Eksportuj CSV", + "settings_export_settings": "Eksportuj ustawienia (JSON)", "settings_import_csv": "Importuj CSV", "settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…", "settings_import_btn": "Importuj", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/pt-br.json b/custom_components/maintenance_supporter/frontend-src/locales/pt-br.json index d1199343..33010069 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/pt-br.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/pt-br.json @@ -484,6 +484,7 @@ "settings_export_json": "Exportar JSON", "settings_export_yaml": "Exportar YAML", "settings_export_csv": "Exportar CSV", + "settings_export_settings": "Exportar configurações (JSON)", "settings_import_csv": "Importar CSV", "settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…", "settings_import_btn": "Importar", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/pt.json b/custom_components/maintenance_supporter/frontend-src/locales/pt.json index 92286162..0ce44bca 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/pt.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/pt.json @@ -483,6 +483,7 @@ "settings_export_json": "Exportar JSON", "settings_export_yaml": "Exportar YAML", "settings_export_csv": "Exportar CSV", + "settings_export_settings": "Exportar definições (JSON)", "settings_import_csv": "Importar CSV", "settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…", "settings_import_btn": "Importar", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/ru.json b/custom_components/maintenance_supporter/frontend-src/locales/ru.json index 942a754c..b9e78ea2 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/ru.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/ru.json @@ -483,6 +483,7 @@ "settings_export_json": "Экспорт JSON", "settings_export_yaml": "Экспорт YAML", "settings_export_csv": "Экспорт CSV", + "settings_export_settings": "Экспорт настроек (JSON)", "settings_import_csv": "Импорт CSV", "settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…", "settings_import_btn": "Импортировать", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/sv.json b/custom_components/maintenance_supporter/frontend-src/locales/sv.json index 5461c02a..33b3a71e 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/sv.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/sv.json @@ -483,6 +483,7 @@ "settings_export_json": "Exportera JSON", "settings_export_yaml": "Exportera YAML", "settings_export_csv": "Exportera CSV", + "settings_export_settings": "Exportera inställningar (JSON)", "settings_import_csv": "Importera CSV", "settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…", "settings_import_btn": "Importera", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/tr.json b/custom_components/maintenance_supporter/frontend-src/locales/tr.json index 95404051..6a89ce27 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/tr.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/tr.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON dışa aktar", "settings_export_yaml": "YAML dışa aktar", "settings_export_csv": "CSV dışa aktar", + "settings_export_settings": "Ayarları dışa aktar (JSON)", "settings_import_csv": "CSV içe aktar", "settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…", "settings_import_btn": "İçe aktar", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/uk.json b/custom_components/maintenance_supporter/frontend-src/locales/uk.json index ebceca7b..26a6f96b 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/uk.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/uk.json @@ -483,6 +483,7 @@ "settings_export_json": "Експортувати JSON", "settings_export_yaml": "Експортувати YAML", "settings_export_csv": "Експортувати CSV", + "settings_export_settings": "Експорт налаштувань (JSON)", "settings_import_csv": "Імпортувати CSV", "settings_import_placeholder": "Вставте вміст JSON або CSV сюди…", "settings_import_btn": "Імпортувати", diff --git a/custom_components/maintenance_supporter/frontend-src/locales/zh.json b/custom_components/maintenance_supporter/frontend-src/locales/zh.json index 6a4e2a75..885de6c7 100644 --- a/custom_components/maintenance_supporter/frontend-src/locales/zh.json +++ b/custom_components/maintenance_supporter/frontend-src/locales/zh.json @@ -484,6 +484,7 @@ "settings_export_json": "导出 JSON", "settings_export_yaml": "导出 YAML", "settings_export_csv": "导出 CSV", + "settings_export_settings": "导出设置(JSON)", "settings_import_csv": "导入 CSV", "settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…", "settings_import_btn": "导入", diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-calendar-card.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-calendar-card.ts index 6a9e23ca..80453e2b 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-calendar-card.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-calendar-card.ts @@ -34,7 +34,8 @@ import { type CalendarEvent, } from "./helpers/calendar-bucket"; import { calendarStyles } from "./calendar-styles"; -import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles"; +import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles"; +import { registerCustomCard } from "./helpers/register-card"; import type { HomeAssistant, MaintenanceObjectResponse, @@ -121,7 +122,7 @@ export class MaintenanceCalendarCard extends LitElement { } private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } disconnectedCallback(): void { @@ -642,31 +643,15 @@ if (!customElements.get("maintenance-supporter-calendar-card-editor")) { } // Register with HACS / customCards so the picker shows it -const w = window as unknown as { - customCards?: Array<{ - type: string; - name: string; - description: string; - preview?: boolean; - }>; -}; -w.customCards = w.customCards || []; -// HA's custom-card resolver maps ``custom:X`` → element tag ``X``. Our element -// is registered as ``maintenance-supporter-calendar-card``, so the customCards -// type MUST match that exact suffix or the card-picker entry resolves to a -// non-existent element and the strategy's calendar mode throws a config error. -const CALENDAR_CARD_TYPE = "maintenance-supporter-calendar-card"; -const alreadyRegistered = w.customCards.some( - (c) => c.type === CALENDAR_CARD_TYPE, -); -if (!alreadyRegistered) { - w.customCards.push({ - type: CALENDAR_CARD_TYPE, - name: "Maintenance Supporter — Calendar", - description: - "Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.", - preview: true, - }); -} +// The type MUST match the registered element tag exactly (custom:X → tag X), +// or the picker entry resolves to a non-existent element and the strategy's +// calendar mode throws a config error. +registerCustomCard({ + type: "maintenance-supporter-calendar-card", + name: "Maintenance Supporter — Calendar", + description: + "Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.", + preview: true, +}); export {}; diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-card-editor.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-card-editor.ts index 84428cd8..813df00c 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-card-editor.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-card-editor.ts @@ -2,7 +2,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { t } from "./styles"; +import { t, langOf } from "./styles"; import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types"; const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const; @@ -18,7 +18,7 @@ export class MaintenanceSupporterCardEditor extends LitElement { private _objectsLoaded = false; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } setConfig(config: CardConfig): void { diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-card.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-card.ts index d35d933d..75cc0886 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-card.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-card.ts @@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit"; import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge"; import { hydrateObjects } from "./helpers/hydrate-objects"; import { property, state } from "lit/decorators.js"; -import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles"; +import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles"; +import { openSignedDocument } from "./helpers/document-url"; +import { registerCustomCard } from "./helpers/register-card"; import type { HomeAssistant, MaintenanceObjectResponse, @@ -54,7 +56,7 @@ export class MaintenanceSupporterCard extends LitElement { private _docsLoadedFor = new Set(); private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } static getConfigElement() { @@ -240,16 +242,10 @@ export class MaintenanceSupporterCard extends LitElement { window.open(doc.url, "_blank", "noopener"); return; } - const win = window.open("about:blank", "_blank"); try { - const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${doc.id}`, - expires: 300, - }); - if (win) win.location.href = new URL(signed.path, window.location.origin).href; + await openSignedDocument(this.hass, doc.id); } catch { - if (win) win.close(); + /* silent — the card has no error surface */ } } @@ -696,8 +692,7 @@ if (!customElements.get("maintenance-supporter-card")) { } // Register as custom card so the Lovelace card picker lists it. -(window as any).customCards = (window as any).customCards || []; -(window as any).customCards.push({ +registerCustomCard({ type: "maintenance-supporter-card", name: "Maintenance Supporter", description: "Overview of your maintenance tasks with quick actions.", diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts index 96fcace3..c8cb1304 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts @@ -5,8 +5,9 @@ import { isSafeHttpUrl } from "./helpers/url"; import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge"; import { isStaleBundle } from "./helpers/bundle-version"; import { customElement, property, state } from "lit/decorators.js"; -import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles"; -import { LS_KEYS } from "./helpers/storage-keys"; +import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs, langOf } from "./styles"; +import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys"; +import { openSignedDocument, signApiPath } from "./helpers/document-url"; import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache"; import { hydrateObjects } from "./helpers/hydrate-objects"; import { daysProgress } from "./helpers/interval"; @@ -134,17 +135,22 @@ export class MaintenanceSupporterPanel extends LitElement { @state() private _unsub: (() => void) | null = null; @state() private _chartRangeDays = (() => { try { - const v = parseInt(localStorage.getItem(LS_KEYS.chartRange) || "", 10); + const v = parseInt(lsGet(LS_KEYS.chartRange) || "", 10); return [7, 30, 90, 365].includes(v) ? v : 30; } catch { return 30; } })(); @state() private _hideOutliers = (() => { - try { return localStorage.getItem(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; } + try { return lsGet(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; } })(); @state() private _historyFilter: string | null = null; @state() private _budget: BudgetStatus | null = null; + + private get _currencySymbol(): string { + return this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL; + } + @state() private _groups: Record = {}; @state() private _detailStatsData: Map = new Map(); @state() private _miniStatsData: Map = new Map(); @@ -185,7 +191,7 @@ export class MaintenanceSupporterPanel extends LitElement { // Dashboard redesign state @state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => { try { - const v = localStorage.getItem(LS_KEYS.overviewTab); + const v = lsGet(LS_KEYS.overviewTab); return v === "today" || v === "calendar" ? v : "dashboard"; } catch { return "dashboard"; } })(); @@ -217,7 +223,7 @@ export class MaintenanceSupporterPanel extends LitElement { // v2.15.0: collapsed analysis sections on the task-detail overview tab, // remembered per section across visits. @state() private _collapsedSections: Set = (() => { - try { return new Set(JSON.parse(localStorage.getItem(LS_KEYS.collapsedSections) || "[]")); } + try { return new Set(JSON.parse(lsGet(LS_KEYS.collapsedSections) || "[]")); } catch { return new Set(); } })(); // v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own @@ -241,7 +247,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _lastConnection: unknown = null; private get _lang(): string { - return this.hass?.language || "en"; + return langOf(this.hass); } /** @@ -314,19 +320,19 @@ export class MaintenanceSupporterPanel extends LitElement { // read here would abort connectedCallback and leave the panel blank. Every // other storage access in this file is wrapped; wrap these too. try { - const saved = localStorage.getItem(LS_KEYS.taskSort); + const saved = lsGet(LS_KEYS.taskSort); if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) { this._sortMode = saved as SortMode; } - const savedObj = localStorage.getItem(LS_KEYS.objectSort); + const savedObj = lsGet(LS_KEYS.objectSort); if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) { this._objectSortMode = savedObj as ObjectSortMode; } - const savedGroup = localStorage.getItem(LS_KEYS.groupBy); + const savedGroup = lsGet(LS_KEYS.groupBy); if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) { this._groupByMode = savedGroup as GroupByMode; } - const savedView = localStorage.getItem(LS_KEYS.objectView); + const savedView = lsGet(LS_KEYS.objectView); if (savedView === "cards" || savedView === "table") { this._objectViewMode = savedView; } @@ -640,7 +646,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _setChartRange(days: number): void { if (days === this._chartRangeDays) return; this._chartRangeDays = days; - try { localStorage.setItem(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ } + try { lsSet(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ } const task = this._selectedEntryId && this._selectedTaskId ? this._getTask(this._selectedEntryId, this._selectedTaskId) : null; @@ -659,7 +665,7 @@ export class MaintenanceSupporterPanel extends LitElement { // Outlier filtering is client-side on the already-fetched series, so just // flip the flag and let renderChart re-filter — no re-fetch needed. this._hideOutliers = hide; - try { localStorage.setItem(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ } + try { lsSet(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ } } private async _fetchMiniStatsForOverview(): Promise { @@ -948,8 +954,8 @@ export class MaintenanceSupporterPanel extends LitElement { } // Persist sort/group like the manual controls do, so they stick after reload. try { - localStorage.setItem(LS_KEYS.taskSort, this._sortMode); - localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); + lsSet(LS_KEYS.taskSort, this._sortMode); + lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { // ignore private-mode storage errors } @@ -1387,6 +1393,29 @@ export class MaintenanceSupporterPanel extends LitElement { // --- Actions --- + /** Run a mutating WS action: loading state, data reload, and — on failure — + * the SERVER's error message as the toast. The ~16 hand-written action + * methods had drifted (most swallowed the server message behind a generic + * "Action failed", three never set _actionLoading, one skipped the reload). + * Returns the result payload, or null when the call failed (toast shown). */ + private async _runAction>( + msg: Record, + opts?: { successToast?: string }, + ): Promise { + this._actionLoading = true; + try { + const res = await this.hass.connection.sendMessagePromise(msg); + await this._loadData(); + if (opts?.successToast) this._showToast(opts.successToast); + return (res ?? {}) as T; + } catch (e) { + this._showToast(describeWsError(e, this._lang)); + return null; + } finally { + this._actionLoading = false; + } + } + private async _deleteObject(entryId: string): Promise { const dlg = this.shadowRoot!.querySelector("maintenance-confirm-dialog"); const ok = await dlg?.confirm({ @@ -1396,16 +1425,11 @@ export class MaintenanceSupporterPanel extends LitElement { danger: true, }); if (!ok) return; - try { - await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/object/delete", - entry_id: entryId, - }); - this._showOverview(); - await this._loadData(); - } catch { - this._showToast(t("action_error", this._lang)); - } + const res = await this._runAction({ + type: "maintenance_supporter/object/delete", + entry_id: entryId, + }); + if (res) this._showOverview(); } /** Open a printable maintenance report for the object in a new tab (the user @@ -1442,7 +1466,7 @@ export class MaintenanceSupporterPanel extends LitElement { const html = buildObjectReportHtml( resp.object, resp.tasks, labels, (iso) => (iso ? formatDate(iso, L) : ""), - this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL, + this._currencySymbol, new Date().toISOString(), ); const url = URL.createObjectURL(new Blob([html], { type: "text/html" })); @@ -1451,20 +1475,11 @@ export class MaintenanceSupporterPanel extends LitElement { } private async _duplicateObject(entryId: string): Promise { - this._actionLoading = true; - try { - const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({ - type: "maintenance_supporter/object/duplicate", - entry_id: entryId, - }); - await this._loadData(); - this._showToast(t("object_duplicated", this._lang)); - if (res?.entry_id) this._showObject(res.entry_id); - } catch { - this._showToast(t("action_error", this._lang)); - } finally { - this._actionLoading = false; - } + const res = await this._runAction<{ entry_id?: string }>( + { type: "maintenance_supporter/object/duplicate", entry_id: entryId }, + { successToast: t("object_duplicated", this._lang) }, + ); + if (res?.entry_id) this._showObject(res.entry_id); } private async _deleteTask(entryId: string, taskId: string): Promise { @@ -1476,60 +1491,37 @@ export class MaintenanceSupporterPanel extends LitElement { danger: true, }); if (!ok) return; - try { - await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/task/delete", - entry_id: entryId, - task_id: taskId, - }); - this._showObject(entryId); - await this._loadData(); - } catch { - this._showToast(t("action_error", this._lang)); - } + const res = await this._runAction({ + type: "maintenance_supporter/task/delete", + entry_id: entryId, + task_id: taskId, + }); + if (res) this._showObject(entryId); } // v2.10.0: archive / unarchive a single task (reversible — no confirm). private async _duplicateTask(entryId: string, taskId: string): Promise { this._moreMenuOpen = false; - this._actionLoading = true; - try { - const res = await this.hass.connection.sendMessagePromise<{ task_id?: string }>({ - type: "maintenance_supporter/task/duplicate", - entry_id: entryId, - task_id: taskId, - }); - await this._loadData(); - this._showToast(t("task_duplicated", this._lang)); - // Jump straight to the copy so the user can rename/adjust it. - if (res?.task_id) this._showTask(entryId, res.task_id); - } catch { - this._showToast(t("action_error", this._lang)); - } finally { - this._actionLoading = false; - } + const res = await this._runAction<{ task_id?: string }>( + { type: "maintenance_supporter/task/duplicate", entry_id: entryId, task_id: taskId }, + { successToast: t("task_duplicated", this._lang) }, + ); + // Jump straight to the copy so the user can rename/adjust it. + if (res?.task_id) this._showTask(entryId, res.task_id); } private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise { - this._actionLoading = true; - try { - await this.hass.connection.sendMessagePromise({ - type: archived - ? "maintenance_supporter/task/unarchive" - : "maintenance_supporter/task/archive", - entry_id: entryId, - task_id: taskId, - }); - await this._loadData(); - // Just archived → offer a one-tap undo (unarchive) instead of a confirm. - if (!archived) { - this._showUndoToast(t("task_archived", this._lang), - () => this._toggleArchiveTask(entryId, taskId, true)); - } - } catch { - this._showToast(t("action_error", this._lang)); - } finally { - this._actionLoading = false; + const res = await this._runAction({ + type: archived + ? "maintenance_supporter/task/unarchive" + : "maintenance_supporter/task/archive", + entry_id: entryId, + task_id: taskId, + }); + // Just archived → offer a one-tap undo (unarchive) instead of a confirm. + if (res && !archived) { + this._showUndoToast(t("task_archived", this._lang), + () => this._toggleArchiveTask(entryId, taskId, true)); } } @@ -1537,20 +1529,15 @@ export class MaintenanceSupporterPanel extends LitElement { // is fully reversible, so instead of a blocking confirm we run it immediately // and offer an Undo toast (v2.14.0). private async _toggleArchiveObject(entryId: string, archived: boolean): Promise { - try { - await this.hass.connection.sendMessagePromise({ - type: archived - ? "maintenance_supporter/object/unarchive" - : "maintenance_supporter/object/archive", - entry_id: entryId, - }); - await this._loadData(); - if (!archived) { - this._showUndoToast(t("object_archived", this._lang), - () => this._toggleArchiveObject(entryId, true)); - } - } catch { - this._showToast(t("action_error", this._lang)); + const res = await this._runAction({ + type: archived + ? "maintenance_supporter/object/unarchive" + : "maintenance_supporter/object/archive", + entry_id: entryId, + }); + if (res && !archived) { + this._showUndoToast(t("object_archived", this._lang), + () => this._toggleArchiveObject(entryId, true)); } } @@ -1567,31 +1554,21 @@ export class MaintenanceSupporterPanel extends LitElement { inputType: "date", }); if (!result?.confirmed) return; - try { - const msg: Record = { - type: "maintenance_supporter/object/pause", - entry_id: entryId, - }; - if (result.value) msg.until = result.value; - await this.hass.connection.sendMessagePromise(msg); - await this._loadData(); + const msg: Record = { + type: "maintenance_supporter/object/pause", + entry_id: entryId, + }; + if (result.value) msg.until = result.value; + if (await this._runAction(msg)) { this._showUndoToast(t("object_paused", this._lang), () => this._togglePauseObject(entryId, true)); - } catch (e) { - this._showToast(describeWsError(e, this._lang)); } return; } - try { - await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/object/resume", - entry_id: entryId, - }); - await this._loadData(); - this._showToast(t("object_resumed", this._lang)); - } catch (e) { - this._showToast(describeWsError(e, this._lang)); - } + await this._runAction( + { type: "maintenance_supporter/object/resume", entry_id: entryId }, + { successToast: t("object_resumed", this._lang) }, + ); } // v2.20 (N1): replace a worn-out object with a successor — the old one is @@ -1608,71 +1585,44 @@ export class MaintenanceSupporterPanel extends LitElement { inputValue: currentName, }); if (!result?.confirmed) return; - this._actionLoading = true; - try { - const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({ + const res = await this._runAction<{ entry_id?: string }>( + { type: "maintenance_supporter/object/replace", entry_id: entryId, name: result.value || currentName, - }); - await this._loadData(); - this._showToast(t("object_replaced", this._lang)); - if (res?.entry_id) this._showObject(res.entry_id); - } catch (e) { - this._showToast(describeWsError(e, this._lang)); - } finally { - this._actionLoading = false; - } + }, + { successToast: t("object_replaced", this._lang) }, + ); + if (res?.entry_id) this._showObject(res.entry_id); } private async _skipTask(entryId: string, taskId: string, reason?: string): Promise { - this._actionLoading = true; - try { - const msg: Record = { - type: "maintenance_supporter/task/skip", - entry_id: entryId, - task_id: taskId, - }; - if (reason) msg.reason = reason; - await this.hass.connection.sendMessagePromise(msg); - await this._loadData(); - } catch { - this._showToast(t("action_error", this._lang)); - } finally { - this._actionLoading = false; - } + const msg: Record = { + type: "maintenance_supporter/task/skip", + entry_id: entryId, + task_id: taskId, + }; + if (reason) msg.reason = reason; + await this._runAction(msg); } private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise { - this._actionLoading = true; - try { - const msg: Record = { - type: "maintenance_supporter/task/reset", - entry_id: entryId, - task_id: taskId, - }; - if (resetDate) msg.date = resetDate; - await this.hass.connection.sendMessagePromise(msg); - await this._loadData(); - } catch { - this._showToast(t("action_error", this._lang)); - } finally { - this._actionLoading = false; - } + const msg: Record = { + type: "maintenance_supporter/task/reset", + entry_id: entryId, + task_id: taskId, + }; + if (resetDate) msg.date = resetDate; + await this._runAction(msg); } private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise { - try { - await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/task/apply_suggestion", - entry_id: entryId, - task_id: taskId, - interval: interval, - }); - await this._loadData(); - } catch { - this._showToast(t("action_error", this._lang)); - } + await this._runAction({ + type: "maintenance_supporter/task/apply_suggestion", + entry_id: entryId, + task_id: taskId, + interval: interval, + }); } private _openSeasonalOverrides(task: MaintenanceTask): void { @@ -1683,28 +1633,24 @@ export class MaintenanceSupporterPanel extends LitElement { } private async _reanalyzeInterval(entryId: string, taskId: string): Promise { - try { - const res = await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/task/analyze_interval", - entry_id: entryId, - task_id: taskId, - }) as { - recommended_interval: number | null; - confidence: string; - data_points: number; - recommendation_reason: string | null; - }; - if (res.recommended_interval) { - this._showToast( - `${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` + - `(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`, - ); - } else { - this._showToast(t("reanalyze_insufficient_data", this._lang)); - } - await this._loadData(); - } catch { - this._showToast(t("action_error", this._lang)); + const res = await this._runAction<{ + recommended_interval: number | null; + confidence: string; + data_points: number; + recommendation_reason: string | null; + }>({ + type: "maintenance_supporter/task/analyze_interval", + entry_id: entryId, + task_id: taskId, + }); + if (!res) return; + if (res.recommended_interval) { + this._showToast( + `${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` + + `(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`, + ); + } else { + this._showToast(t("reanalyze_insufficient_data", this._lang)); } } @@ -1737,21 +1683,10 @@ export class MaintenanceSupporterPanel extends LitElement { } private async _postponeTask(entryId: string, taskId: string, until: string): Promise { - this._actionLoading = true; - try { - await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/task/postpone", - entry_id: entryId, - task_id: taskId, - until, - }); - this._showToast(t("postponed", this._lang)); - await this._loadData(); - } catch { - this._showToast(t("action_error", this._lang)); - } finally { - this._actionLoading = false; - } + await this._runAction( + { type: "maintenance_supporter/task/postpone", entry_id: entryId, task_id: taskId, until }, + { successToast: t("postponed", this._lang) }, + ); } private async _promptPostponeTask(entryId: string, taskId: string): Promise { @@ -1769,19 +1704,13 @@ export class MaintenanceSupporterPanel extends LitElement { } private async _snoozeTask(entryId: string, taskId: string): Promise { - this._actionLoading = true; - try { - await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/task/snooze", - entry_id: entryId, - task_id: taskId, - }); - this._showToast(t("snoozed", this._lang)); - } catch { - this._showToast(t("action_error", this._lang)); - } finally { - this._actionLoading = false; - } + // Now reloads like every sibling action — this was the one mutation that + // skipped the refresh, leaving the snoozed due date stale until the next + // poll. + await this._runAction( + { type: "maintenance_supporter/task/snooze", entry_id: entryId, task_id: taskId }, + { successToast: t("snoozed", this._lang) }, + ); } private _dismissSuggestion(entryId?: string, taskId?: string): void { @@ -1850,11 +1779,13 @@ export class MaintenanceSupporterPanel extends LitElement { if (manual) { const start = manual.task_pages![taskId]; const count = 4; - const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`, - expires: 3600, - }); + const signed = { + path: await signApiPath( + this.hass, + `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`, + 3600, + ), + }; excerpt = { title: manual.title || manual.filename || "Manual", startPage: start, endPage: start + count - 1, @@ -1920,19 +1851,9 @@ export class MaintenanceSupporterPanel extends LitElement { if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener"); return; } - // Open the tab synchronously (inside the click gesture) so it isn't - // popup-blocked, then point it at the freshly signed URL. - const win = window.open("about:blank", "_blank"); - void this.hass.connection - .sendMessagePromise<{ path: string }>({ - type: "auth/sign_path", - path: `/api/maintenance_supporter/document/${doc.id}`, - expires: 300, - }) - .then((signed) => { - if (win) win.location.href = new URL(signed.path, window.location.origin).href; - }) - .catch(() => win?.close()); + void openSignedDocument(this.hass, doc.id).catch(() => { + /* tab already closed by the helper; the panel toast adds no value here */ + }); } /** #73: persist one checklist tick. Sends the FULL current state (the @@ -1947,15 +1868,10 @@ export class MaintenanceSupporterPanel extends LitElement { const current = task.checklist_progress?.[step] ?? false; state[step] = step === item ? done : current; } - try { - await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/task/checklist_progress", - entry_id: entryId, task_id: taskId, checklist_state: state, - }); - await this._loadData(); - } catch { - this._showToast(t("action_error", this._lang)); - } + await this._runAction({ + type: "maintenance_supporter/task/checklist_progress", + entry_id: entryId, task_id: taskId, checklist_state: state, + }); } private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void { @@ -1988,7 +1904,7 @@ export class MaintenanceSupporterPanel extends LitElement { // #104 follow-up: parts carry unit costs — the dialog offers their sum // as a one-click cost suggestion (buy task: restock qty × unit cost). dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null; - dlg.currencySymbol = this._budget?.currency_symbol || ""; + dlg.currencySymbol = this._currencySymbol; // #111: a link may point at another object's pool — name that object, and // never drop a line that fails to resolve (the old .filter(Boolean) hid it). dlg.consumesInfo = (tk?.consumes_parts || []).map((link) => @@ -2275,7 +2191,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void { this._overviewTab = tab; - try { localStorage.setItem(LS_KEYS.overviewTab, tab); } catch { /* private mode */ } + try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ } this._scrollContentToTop(); } @@ -2441,7 +2357,7 @@ export class MaintenanceSupporterPanel extends LitElement { @change=${(e: Event) => { this._sortMode = (e.target as HTMLSelectElement).value as SortMode; this._activeViewId = ""; - try { localStorage.setItem(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ } + try { lsSet(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ } }} > @@ -2460,7 +2376,7 @@ export class MaintenanceSupporterPanel extends LitElement { @change=${(e: Event) => { this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode; this._activeViewId = ""; - try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ } + try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ } }} > @@ -2765,7 +2681,7 @@ export class MaintenanceSupporterPanel extends LitElement { .value=${this._objectSortMode} @change=${(e: Event) => { this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode; - localStorage.setItem(LS_KEYS.objectSort, this._objectSortMode); + try { lsSet(LS_KEYS.objectSort, this._objectSortMode); } catch { /* private mode */ } }} > @@ -2794,7 +2710,7 @@ export class MaintenanceSupporterPanel extends LitElement { .value=${this._groupByMode} @change=${(e: Event) => { this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode; - try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ } + try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ } }} > @@ -2843,7 +2759,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _setObjectViewMode(mode: "cards" | "table"): void { this._objectViewMode = mode; - localStorage.setItem(LS_KEYS.objectView, mode); + try { lsSet(LS_KEYS.objectView, mode); } catch { /* private mode */ } } // ── #130: instance-wide parts overview ──────────────────────────────────── @@ -2851,7 +2767,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _renderAllParts() { const L = this._lang; const rows = this._allParts; - const currency = this._budget?.currency_symbol || ""; + const currency = this._currencySymbol; return html` @@ -3506,7 +3417,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _gsDismissed(): Set { try { - return new Set(JSON.parse(localStorage.getItem(LS_KEYS.gettingStartedDismissed) || "[]")); + return new Set(JSON.parse(lsGet(LS_KEYS.gettingStartedDismissed) || "[]")); } catch { return new Set(); } @@ -3515,7 +3426,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _dismissGettingStarted(id: string): void { const next = this._gsDismissed(); next.add(id); - try { localStorage.setItem(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ } + try { lsSet(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ } this.requestUpdate(); } @@ -3612,7 +3523,7 @@ export class MaintenanceSupporterPanel extends LitElement { const next = new Set(this._collapsedSections); if (next.has(key)) next.delete(key); else next.add(key); this._collapsedSections = next; - try { localStorage.setItem(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ } + try { lsSet(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ } } /** Build the context the history renderers need from panel state. */ @@ -3639,7 +3550,7 @@ export class MaintenanceSupporterPanel extends LitElement { hass: this.hass, filter: this._historyFilter, search: this._historySearch, - currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL, + currencySymbol: this._currencySymbol, setFilter: (f) => { this._historyFilter = f; }, setSearch: (s) => { this._historySearch = s; }, openEdit: (entry) => this._openHistoryEdit(entry), @@ -3674,7 +3585,7 @@ export class MaintenanceSupporterPanel extends LitElement { moreMenuOpen: this._moreMenuOpen, activeTab: this._activeTab, features: this._features, - currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL, + currencySymbol: this._currencySymbol, collapsedSections: this._collapsedSections, costDurationToggle: this._costDurationToggle, suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`), diff --git a/custom_components/maintenance_supporter/frontend-src/styles.ts b/custom_components/maintenance_supporter/frontend-src/styles.ts index e9fc8427..3e324a8e 100644 --- a/custom_components/maintenance_supporter/frontend-src/styles.ts +++ b/custom_components/maintenance_supporter/frontend-src/styles.ts @@ -79,6 +79,17 @@ export function t(key: string, lang?: string): string { return STORE[l]?.[key] ?? STORE.en[key] ?? key; } +/** + * The UI language for a hass object — the ONE rule every component's `_lang` + * getter delegates to. Before this helper the 23 hand-copied getters had + * drifted into three variants (`|| "en"`, `?? navigator.language...`, and a + * `locale.language` probe), so sibling components could resolve different + * languages from the same hass. + */ +export function langOf(hass?: { language?: string }): string { + return hass?.language || "en"; +} + /** True when *lang*'s table is in memory (English is always bundled). */ export function isLocaleLoaded(lang?: string): boolean { const l = normLang(lang); diff --git a/custom_components/maintenance_supporter/frontend/locales/cs.json b/custom_components/maintenance_supporter/frontend/locales/cs.json index 34914dfc..9c2038a0 100644 --- a/custom_components/maintenance_supporter/frontend/locales/cs.json +++ b/custom_components/maintenance_supporter/frontend/locales/cs.json @@ -483,6 +483,7 @@ "settings_export_json": "Exportovat JSON", "settings_export_yaml": "Exportovat YAML", "settings_export_csv": "Exportovat CSV", + "settings_export_settings": "Exportovat nastavení (JSON)", "settings_import_csv": "Importovat CSV", "settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…", "settings_import_btn": "Importovat", diff --git a/custom_components/maintenance_supporter/frontend/locales/da.json b/custom_components/maintenance_supporter/frontend/locales/da.json index f279b013..9449d4f7 100644 --- a/custom_components/maintenance_supporter/frontend/locales/da.json +++ b/custom_components/maintenance_supporter/frontend/locales/da.json @@ -484,6 +484,7 @@ "settings_export_json": "Eksporter JSON", "settings_export_yaml": "Eksporter YAML", "settings_export_csv": "Eksporter CSV", + "settings_export_settings": "Eksportér indstillinger (JSON)", "settings_import_csv": "Importer CSV", "settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…", "settings_import_btn": "Importer", diff --git a/custom_components/maintenance_supporter/frontend/locales/de.json b/custom_components/maintenance_supporter/frontend/locales/de.json index a8bc2fa0..900e84a9 100644 --- a/custom_components/maintenance_supporter/frontend/locales/de.json +++ b/custom_components/maintenance_supporter/frontend/locales/de.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON exportieren", "settings_export_yaml": "YAML exportieren", "settings_export_csv": "CSV exportieren", + "settings_export_settings": "Einstellungen exportieren (JSON)", "settings_import_csv": "CSV importieren", "settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…", "settings_import_btn": "Importieren", diff --git a/custom_components/maintenance_supporter/frontend/locales/en.json b/custom_components/maintenance_supporter/frontend/locales/en.json index de1c22c4..37f9159e 100644 --- a/custom_components/maintenance_supporter/frontend/locales/en.json +++ b/custom_components/maintenance_supporter/frontend/locales/en.json @@ -484,6 +484,7 @@ "settings_export_json": "Export JSON", "settings_export_yaml": "Export YAML", "settings_export_csv": "Export CSV", + "settings_export_settings": "Export settings (JSON)", "settings_import_csv": "Import CSV", "settings_import_placeholder": "Paste JSON or CSV content here…", "settings_import_btn": "Import", diff --git a/custom_components/maintenance_supporter/frontend/locales/es.json b/custom_components/maintenance_supporter/frontend/locales/es.json index f23a81b8..7701e67e 100644 --- a/custom_components/maintenance_supporter/frontend/locales/es.json +++ b/custom_components/maintenance_supporter/frontend/locales/es.json @@ -483,6 +483,7 @@ "settings_export_json": "Exportar JSON", "settings_export_yaml": "Exportar YAML", "settings_export_csv": "Exportar CSV", + "settings_export_settings": "Exportar ajustes (JSON)", "settings_import_csv": "Importar CSV", "settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…", "settings_import_btn": "Importar", diff --git a/custom_components/maintenance_supporter/frontend/locales/fi.json b/custom_components/maintenance_supporter/frontend/locales/fi.json index 2cfb6588..19ca1a84 100644 --- a/custom_components/maintenance_supporter/frontend/locales/fi.json +++ b/custom_components/maintenance_supporter/frontend/locales/fi.json @@ -484,6 +484,7 @@ "settings_export_json": "Vie JSON", "settings_export_yaml": "Vie YAML", "settings_export_csv": "Vie CSV", + "settings_export_settings": "Vie asetukset (JSON)", "settings_import_csv": "Tuo CSV", "settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…", "settings_import_btn": "Tuo", diff --git a/custom_components/maintenance_supporter/frontend/locales/fr.json b/custom_components/maintenance_supporter/frontend/locales/fr.json index bf17f41a..5dba5716 100644 --- a/custom_components/maintenance_supporter/frontend/locales/fr.json +++ b/custom_components/maintenance_supporter/frontend/locales/fr.json @@ -483,6 +483,7 @@ "settings_export_json": "Exporter JSON", "settings_export_yaml": "Exporter YAML", "settings_export_csv": "Exporter CSV", + "settings_export_settings": "Exporter les réglages (JSON)", "settings_import_csv": "Importer CSV", "settings_import_placeholder": "Collez le contenu JSON ou CSV ici…", "settings_import_btn": "Importer", diff --git a/custom_components/maintenance_supporter/frontend/locales/hi.json b/custom_components/maintenance_supporter/frontend/locales/hi.json index da305c83..8de72e44 100644 --- a/custom_components/maintenance_supporter/frontend/locales/hi.json +++ b/custom_components/maintenance_supporter/frontend/locales/hi.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON निर्यात करें", "settings_export_yaml": "YAML निर्यात करें", "settings_export_csv": "CSV निर्यात करें", + "settings_export_settings": "सेटिंग्स निर्यात करें (JSON)", "settings_import_csv": "CSV आयात करें", "settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…", "settings_import_btn": "आयात करें", diff --git a/custom_components/maintenance_supporter/frontend/locales/hu.json b/custom_components/maintenance_supporter/frontend/locales/hu.json index 600b149e..53ecbdaf 100644 --- a/custom_components/maintenance_supporter/frontend/locales/hu.json +++ b/custom_components/maintenance_supporter/frontend/locales/hu.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON exportálása", "settings_export_yaml": "YAML exportálása", "settings_export_csv": "CSV exportálása", + "settings_export_settings": "Beállítások exportálása (JSON)", "settings_import_csv": "CSV importálása", "settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…", "settings_import_btn": "Importálás", diff --git a/custom_components/maintenance_supporter/frontend/locales/it.json b/custom_components/maintenance_supporter/frontend/locales/it.json index 29a6fbf9..3fd26b70 100644 --- a/custom_components/maintenance_supporter/frontend/locales/it.json +++ b/custom_components/maintenance_supporter/frontend/locales/it.json @@ -483,6 +483,7 @@ "settings_export_json": "Esporta JSON", "settings_export_yaml": "Esporta YAML", "settings_export_csv": "Esporta CSV", + "settings_export_settings": "Esporta impostazioni (JSON)", "settings_import_csv": "Importa CSV", "settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…", "settings_import_btn": "Importa", diff --git a/custom_components/maintenance_supporter/frontend/locales/ja.json b/custom_components/maintenance_supporter/frontend/locales/ja.json index 8d591226..d80403b8 100644 --- a/custom_components/maintenance_supporter/frontend/locales/ja.json +++ b/custom_components/maintenance_supporter/frontend/locales/ja.json @@ -484,6 +484,7 @@ "settings_export_json": "JSONをエクスポート", "settings_export_yaml": "YAMLをエクスポート", "settings_export_csv": "CSVをエクスポート", + "settings_export_settings": "設定をエクスポート(JSON)", "settings_import_csv": "CSVをインポート", "settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…", "settings_import_btn": "インポート", diff --git a/custom_components/maintenance_supporter/frontend/locales/ko.json b/custom_components/maintenance_supporter/frontend/locales/ko.json index c788c2d0..c53c60fc 100644 --- a/custom_components/maintenance_supporter/frontend/locales/ko.json +++ b/custom_components/maintenance_supporter/frontend/locales/ko.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON 내보내기", "settings_export_yaml": "YAML 내보내기", "settings_export_csv": "CSV 내보내기", + "settings_export_settings": "설정 내보내기 (JSON)", "settings_import_csv": "CSV 가져오기", "settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…", "settings_import_btn": "가져오기", diff --git a/custom_components/maintenance_supporter/frontend/locales/nb.json b/custom_components/maintenance_supporter/frontend/locales/nb.json index b7afe48d..0fa109d9 100644 --- a/custom_components/maintenance_supporter/frontend/locales/nb.json +++ b/custom_components/maintenance_supporter/frontend/locales/nb.json @@ -484,6 +484,7 @@ "settings_export_json": "Eksporter JSON", "settings_export_yaml": "Eksporter YAML", "settings_export_csv": "Eksporter CSV", + "settings_export_settings": "Eksporter innstillinger (JSON)", "settings_import_csv": "Importer CSV", "settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…", "settings_import_btn": "Importer", diff --git a/custom_components/maintenance_supporter/frontend/locales/nl.json b/custom_components/maintenance_supporter/frontend/locales/nl.json index 2f1d92b6..305a34a4 100644 --- a/custom_components/maintenance_supporter/frontend/locales/nl.json +++ b/custom_components/maintenance_supporter/frontend/locales/nl.json @@ -483,6 +483,7 @@ "settings_export_json": "JSON exporteren", "settings_export_yaml": "YAML exporteren", "settings_export_csv": "CSV exporteren", + "settings_export_settings": "Instellingen exporteren (JSON)", "settings_import_csv": "CSV importeren", "settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…", "settings_import_btn": "Importeren", diff --git a/custom_components/maintenance_supporter/frontend/locales/pl.json b/custom_components/maintenance_supporter/frontend/locales/pl.json index b00666b9..9c2c10db 100644 --- a/custom_components/maintenance_supporter/frontend/locales/pl.json +++ b/custom_components/maintenance_supporter/frontend/locales/pl.json @@ -483,6 +483,7 @@ "settings_export_json": "Eksportuj JSON", "settings_export_yaml": "Eksportuj YAML", "settings_export_csv": "Eksportuj CSV", + "settings_export_settings": "Eksportuj ustawienia (JSON)", "settings_import_csv": "Importuj CSV", "settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…", "settings_import_btn": "Importuj", diff --git a/custom_components/maintenance_supporter/frontend/locales/pt-br.json b/custom_components/maintenance_supporter/frontend/locales/pt-br.json index d1199343..33010069 100644 --- a/custom_components/maintenance_supporter/frontend/locales/pt-br.json +++ b/custom_components/maintenance_supporter/frontend/locales/pt-br.json @@ -484,6 +484,7 @@ "settings_export_json": "Exportar JSON", "settings_export_yaml": "Exportar YAML", "settings_export_csv": "Exportar CSV", + "settings_export_settings": "Exportar configurações (JSON)", "settings_import_csv": "Importar CSV", "settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…", "settings_import_btn": "Importar", diff --git a/custom_components/maintenance_supporter/frontend/locales/pt.json b/custom_components/maintenance_supporter/frontend/locales/pt.json index 92286162..0ce44bca 100644 --- a/custom_components/maintenance_supporter/frontend/locales/pt.json +++ b/custom_components/maintenance_supporter/frontend/locales/pt.json @@ -483,6 +483,7 @@ "settings_export_json": "Exportar JSON", "settings_export_yaml": "Exportar YAML", "settings_export_csv": "Exportar CSV", + "settings_export_settings": "Exportar definições (JSON)", "settings_import_csv": "Importar CSV", "settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…", "settings_import_btn": "Importar", diff --git a/custom_components/maintenance_supporter/frontend/locales/ru.json b/custom_components/maintenance_supporter/frontend/locales/ru.json index 942a754c..b9e78ea2 100644 --- a/custom_components/maintenance_supporter/frontend/locales/ru.json +++ b/custom_components/maintenance_supporter/frontend/locales/ru.json @@ -483,6 +483,7 @@ "settings_export_json": "Экспорт JSON", "settings_export_yaml": "Экспорт YAML", "settings_export_csv": "Экспорт CSV", + "settings_export_settings": "Экспорт настроек (JSON)", "settings_import_csv": "Импорт CSV", "settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…", "settings_import_btn": "Импортировать", diff --git a/custom_components/maintenance_supporter/frontend/locales/sv.json b/custom_components/maintenance_supporter/frontend/locales/sv.json index 5461c02a..33b3a71e 100644 --- a/custom_components/maintenance_supporter/frontend/locales/sv.json +++ b/custom_components/maintenance_supporter/frontend/locales/sv.json @@ -483,6 +483,7 @@ "settings_export_json": "Exportera JSON", "settings_export_yaml": "Exportera YAML", "settings_export_csv": "Exportera CSV", + "settings_export_settings": "Exportera inställningar (JSON)", "settings_import_csv": "Importera CSV", "settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…", "settings_import_btn": "Importera", diff --git a/custom_components/maintenance_supporter/frontend/locales/tr.json b/custom_components/maintenance_supporter/frontend/locales/tr.json index 95404051..6a89ce27 100644 --- a/custom_components/maintenance_supporter/frontend/locales/tr.json +++ b/custom_components/maintenance_supporter/frontend/locales/tr.json @@ -484,6 +484,7 @@ "settings_export_json": "JSON dışa aktar", "settings_export_yaml": "YAML dışa aktar", "settings_export_csv": "CSV dışa aktar", + "settings_export_settings": "Ayarları dışa aktar (JSON)", "settings_import_csv": "CSV içe aktar", "settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…", "settings_import_btn": "İçe aktar", diff --git a/custom_components/maintenance_supporter/frontend/locales/uk.json b/custom_components/maintenance_supporter/frontend/locales/uk.json index ebceca7b..26a6f96b 100644 --- a/custom_components/maintenance_supporter/frontend/locales/uk.json +++ b/custom_components/maintenance_supporter/frontend/locales/uk.json @@ -483,6 +483,7 @@ "settings_export_json": "Експортувати JSON", "settings_export_yaml": "Експортувати YAML", "settings_export_csv": "Експортувати CSV", + "settings_export_settings": "Експорт налаштувань (JSON)", "settings_import_csv": "Імпортувати CSV", "settings_import_placeholder": "Вставте вміст JSON або CSV сюди…", "settings_import_btn": "Імпортувати", diff --git a/custom_components/maintenance_supporter/frontend/locales/zh.json b/custom_components/maintenance_supporter/frontend/locales/zh.json index 6a4e2a75..885de6c7 100644 --- a/custom_components/maintenance_supporter/frontend/locales/zh.json +++ b/custom_components/maintenance_supporter/frontend/locales/zh.json @@ -484,6 +484,7 @@ "settings_export_json": "导出 JSON", "settings_export_yaml": "导出 YAML", "settings_export_csv": "导出 CSV", + "settings_export_settings": "导出设置(JSON)", "settings_import_csv": "导入 CSV", "settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…", "settings_import_btn": "导入", diff --git a/custom_components/maintenance_supporter/frontend/maintenance-calendar-card.js b/custom_components/maintenance_supporter/frontend/maintenance-calendar-card.js index 637cf9ec..63549707 100644 --- a/custom_components/maintenance_supporter/frontend/maintenance-calendar-card.js +++ b/custom_components/maintenance_supporter/frontend/maintenance-calendar-card.js @@ -1,7 +1,7 @@ -/*! maintenance_supporter frontend 2.57.0 */ -var it=Object.defineProperty;var lt=Object.getOwnPropertyDescriptor;var x=(a,e,t,o)=>{for(var r=o>1?void 0:o?lt(e,t):e,n=a.length-1,s;n>=0;n--)(s=a[n])&&(r=(o?s(e,t,r):s(r))||r);return o&&r&&it(e,t,r),r};var ee=globalThis,te=ee.ShadowRoot&&(ee.ShadyCSS===void 0||ee.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,de=Symbol(),Se=new WeakMap,q=class{constructor(e,t,o){if(this._$cssResult$=!0,o!==de)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(te&&e===void 0){let o=t!==void 0&&t.length===1;o&&(e=Se.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),o&&Se.set(t,e))}return e}toString(){return this.cssText}},$e=a=>new q(typeof a=="string"?a:a+"",void 0,de),k=(a,...e)=>{let t=a.length===1?a[0]:e.reduce((o,r,n)=>o+(s=>{if(s._$cssResult$===!0)return s.cssText;if(typeof s=="number")return s;throw Error("Value passed to 'css' function must be a 'css' function result: "+s+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(r)+a[n+1],a[0]);return new q(t,a,de)},Ae=(a,e)=>{if(te)a.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(let t of e){let o=document.createElement("style"),r=ee.litNonce;r!==void 0&&o.setAttribute("nonce",r),o.textContent=t.cssText,a.appendChild(o)}},pe=te?a=>a:a=>a instanceof CSSStyleSheet?(e=>{let t="";for(let o of e.cssRules)t+=o.cssText;return $e(t)})(a):a;var{is:ct,defineProperty:dt,getOwnPropertyDescriptor:pt,getOwnPropertyNames:ut,getOwnPropertySymbols:_t,getPrototypeOf:ht}=Object,oe=globalThis,je=oe.trustedTypes,gt=je?je.emptyScript:"",ft=oe.reactiveElementPolyfillSupport,F=(a,e)=>a,B={toAttribute(a,e){switch(e){case Boolean:a=a?gt:null;break;case Object:case Array:a=a==null?a:JSON.stringify(a)}return a},fromAttribute(a,e){let t=a;switch(e){case Boolean:t=a!==null;break;case Number:t=a===null?null:Number(a);break;case Object:case Array:try{t=JSON.parse(a)}catch{t=null}}return t}},re=(a,e)=>!ct(a,e),Ee={attribute:!0,type:String,converter:B,reflect:!1,useDefault:!1,hasChanged:re};Symbol.metadata??=Symbol("metadata"),oe.litPropertyMetadata??=new WeakMap;var S=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=Ee){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let o=Symbol(),r=this.getPropertyDescriptor(e,o,t);r!==void 0&&dt(this.prototype,e,r)}}static getPropertyDescriptor(e,t,o){let{get:r,set:n}=pt(this.prototype,e)??{get(){return this[t]},set(s){this[t]=s}};return{get:r,set(s){let l=r?.call(this);n?.call(this,s),this.requestUpdate(e,l,o)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??Ee}static _$Ei(){if(this.hasOwnProperty(F("elementProperties")))return;let e=ht(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(F("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(F("properties"))){let t=this.properties,o=[...ut(t),..._t(t)];for(let r of o)this.createProperty(r,t[r])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[o,r]of t)this.elementProperties.set(o,r)}this._$Eh=new Map;for(let[t,o]of this.elementProperties){let r=this._$Eu(t,o);r!==void 0&&this._$Eh.set(r,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let o=new Set(e.flat(1/0).reverse());for(let r of o)t.unshift(pe(r))}else e!==void 0&&t.push(pe(e));return t}static _$Eu(e,t){let o=t.attribute;return o===!1?void 0:typeof o=="string"?o:typeof e=="string"?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let o of t.keys())this.hasOwnProperty(o)&&(e.set(o,this[o]),delete this[o]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return Ae(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,o){this._$AK(e,o)}_$ET(e,t){let o=this.constructor.elementProperties.get(e),r=this.constructor._$Eu(e,o);if(r!==void 0&&o.reflect===!0){let n=(o.converter?.toAttribute!==void 0?o.converter:B).toAttribute(t,o.type);this._$Em=e,n==null?this.removeAttribute(r):this.setAttribute(r,n),this._$Em=null}}_$AK(e,t){let o=this.constructor,r=o._$Eh.get(e);if(r!==void 0&&this._$Em!==r){let n=o.getPropertyOptions(r),s=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:B;this._$Em=r;let l=s.fromAttribute(t,n.type);this[r]=l??this._$Ej?.get(r)??l,this._$Em=null}}requestUpdate(e,t,o,r=!1,n){if(e!==void 0){let s=this.constructor;if(r===!1&&(n=this[e]),o??=s.getPropertyOptions(e),!((o.hasChanged??re)(n,t)||o.useDefault&&o.reflect&&n===this._$Ej?.get(e)&&!this.hasAttribute(s._$Eu(e,o))))return;this.C(e,t,o)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(e,t,{useDefault:o,reflect:r,wrapped:n},s){o&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,s??t??this[e]),n!==!0||s!==void 0)||(this._$AL.has(e)||(this.hasUpdated||o||(t=void 0),this._$AL.set(e,t)),r===!0&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[r,n]of this._$Ep)this[r]=n;this._$Ep=void 0}let o=this.constructor.elementProperties;if(o.size>0)for(let[r,n]of o){let{wrapped:s}=n,l=this[r];s!==!0||this._$AL.has(r)||l===void 0||this.C(r,void 0,n,l)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(o=>o.hostUpdate?.()),this.update(t)):this._$EM()}catch(o){throw e=!1,this._$EM(),o}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(e){}firstUpdated(e){}};S.elementStyles=[],S.shadowRootOptions={mode:"open"},S[F("elementProperties")]=new Map,S[F("finalized")]=new Map,ft?.({ReactiveElement:S}),(oe.reactiveElementVersions??=[]).push("2.1.2");var be=globalThis,Ce=a=>a,ae=be.trustedTypes,Te=ae?ae.createPolicy("lit-html",{createHTML:a=>a}):void 0,Oe="$lit$",A=`lit$${Math.random().toFixed(9).slice(2)}$`,Le="?"+A,mt=`<${Le}>`,T=document,Y=()=>T.createComment(""),G=a=>a===null||typeof a!="object"&&typeof a!="function",ye=Array.isArray,bt=a=>ye(a)||typeof a?.[Symbol.iterator]=="function",ue=`[ -\f\r]`,W=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,De=/-->/g,Ne=/>/g,E=RegExp(`>|${ue}(?:([^\\s"'>=/]+)(${ue}*=${ue}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),Re=/'/g,Pe=/"/g,Me=/^(?:script|style|textarea|title)$/i,ve=a=>(e,...t)=>({_$litType$:a,strings:e,values:t}),m=ve(1),It=ve(2),qt=ve(3),D=Symbol.for("lit-noChange"),g=Symbol.for("lit-nothing"),ze=new WeakMap,C=T.createTreeWalker(T,129);function Ue(a,e){if(!ye(a)||!a.hasOwnProperty("raw"))throw Error("invalid template strings array");return Te!==void 0?Te.createHTML(e):e}var yt=(a,e)=>{let t=a.length-1,o=[],r,n=e===2?"":e===3?"":"",s=W;for(let l=0;l"?(s=r??W,c=-1):u[1]===void 0?c=-2:(c=s.lastIndex-u[2].length,p=u[1],s=u[3]===void 0?E:u[3]==='"'?Pe:Re):s===Pe||s===Re?s=E:s===De||s===Ne?s=W:(s=E,r=void 0);let h=s===E&&a[l+1].startsWith("/>")?" ":"";n+=s===W?d+mt:c>=0?(o.push(p),d.slice(0,c)+Oe+d.slice(c)+A+h):d+A+(c===-2?l:h)}return[Ue(a,n+(a[t]||"")+(e===2?"":e===3?"":"")),o]},V=class a{constructor({strings:e,_$litType$:t},o){let r;this.parts=[];let n=0,s=0,l=e.length-1,d=this.parts,[p,u]=yt(e,t);if(this.el=a.createElement(p,o),C.currentNode=this.el.content,t===2||t===3){let c=this.el.content.firstChild;c.replaceWith(...c.childNodes)}for(;(r=C.nextNode())!==null&&d.length0){r.textContent=ae?ae.emptyScript:"";for(let h=0;h<_;h++)r.append(c[h],Y()),C.nextNode(),d.push({type:2,index:++n});r.append(c[_],Y())}}}else if(r.nodeType===8)if(r.data===Le)d.push({type:2,index:n});else{let c=-1;for(;(c=r.data.indexOf(A,c+1))!==-1;)d.push({type:7,index:n}),c+=A.length-1}n++}}static createElement(e,t){let o=T.createElement("template");return o.innerHTML=e,o}};function z(a,e,t=a,o){if(e===D)return e;let r=o!==void 0?t._$Co?.[o]:t._$Cl,n=G(e)?void 0:e._$litDirective$;return r?.constructor!==n&&(r?._$AO?.(!1),n===void 0?r=void 0:(r=new n(a),r._$AT(a,t,o)),o!==void 0?(t._$Co??=[])[o]=r:t._$Cl=r),r!==void 0&&(e=z(a,r._$AS(a,e.values),r,o)),e}var _e=class{constructor(e,t){this._$AV=[],this._$AN=void 0,this._$AD=e,this._$AM=t}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(e){let{el:{content:t},parts:o}=this._$AD,r=(e?.creationScope??T).importNode(t,!0);C.currentNode=r;let n=C.nextNode(),s=0,l=0,d=o[0];for(;d!==void 0;){if(s===d.index){let p;d.type===2?p=new K(n,n.nextSibling,this,e):d.type===1?p=new d.ctor(n,d.name,d.strings,this,e):d.type===6&&(p=new me(n,this,e)),this._$AV.push(p),d=o[++l]}s!==d?.index&&(n=C.nextNode(),s++)}return C.currentNode=T,r}p(e){let t=0;for(let o of this._$AV)o!==void 0&&(o.strings!==void 0?(o._$AI(e,o,t),t+=o.strings.length-2):o._$AI(e[t])),t++}},K=class a{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(e,t,o,r){this.type=2,this._$AH=g,this._$AN=void 0,this._$AA=e,this._$AB=t,this._$AM=o,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let e=this._$AA.parentNode,t=this._$AM;return t!==void 0&&e?.nodeType===11&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(e,t=this){e=z(this,e,t),G(e)?e===g||e==null||e===""?(this._$AH!==g&&this._$AR(),this._$AH=g):e!==this._$AH&&e!==D&&this._(e):e._$litType$!==void 0?this.$(e):e.nodeType!==void 0?this.T(e):bt(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==g&&G(this._$AH)?this._$AA.nextSibling.data=e:this.T(T.createTextNode(e)),this._$AH=e}$(e){let{values:t,_$litType$:o}=e,r=typeof o=="number"?this._$AC(e):(o.el===void 0&&(o.el=V.createElement(Ue(o.h,o.h[0]),this.options)),o);if(this._$AH?._$AD===r)this._$AH.p(t);else{let n=new _e(r,this),s=n.u(this.options);n.p(t),this.T(s),this._$AH=n}}_$AC(e){let t=ze.get(e.strings);return t===void 0&&ze.set(e.strings,t=new V(e)),t}k(e){ye(this._$AH)||(this._$AH=[],this._$AR());let t=this._$AH,o,r=0;for(let n of e)r===t.length?t.push(o=new a(this.O(Y()),this.O(Y()),this,this.options)):o=t[r],o._$AI(n),r++;r2||o[0]!==""||o[1]!==""?(this._$AH=Array(o.length-1).fill(new String),this.strings=o):this._$AH=g}_$AI(e,t=this,o,r){let n=this.strings,s=!1;if(n===void 0)e=z(this,e,t,0),s=!G(e)||e!==this._$AH&&e!==D,s&&(this._$AH=e);else{let l=e,d,p;for(e=n[0],d=0;d{let o=t?.renderBefore??e,r=o._$litPart$;if(r===void 0){let n=t?.renderBefore??null;o._$litPart$=r=new K(e.insertBefore(Y(),n),n,void 0,t??{})}return r._$AI(a),r};var xe=globalThis,$=class extends S{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=He(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return D}};$._$litElement$=!0,$.finalized=!0,xe.litElementHydrateSupport?.({LitElement:$});var xt=xe.litElementPolyfillSupport;xt?.({LitElement:$});(xe.litElementVersions??=[]).push("4.2.2");var wt={attribute:!0,type:String,converter:B,reflect:!1,hasChanged:re},kt=(a=wt,e,t)=>{let{kind:o,metadata:r}=t,n=globalThis.litPropertyMetadata.get(r);if(n===void 0&&globalThis.litPropertyMetadata.set(r,n=new Map),o==="setter"&&((a=Object.create(a)).wrapped=!0),n.set(t.name,a),o==="accessor"){let{name:s}=t;return{set(l){let d=e.get.call(this);e.set.call(this,l),this.requestUpdate(s,d,a,!0,l)},init(l){return l!==void 0&&this.C(s,void 0,a,l),l}}}if(o==="setter"){let{name:s}=t;return function(l){let d=this[s];e.call(this,l),this.requestUpdate(s,d,a,!0,l)}}throw Error("Unsupported decorator location: "+o)};function Q(a){return(e,t)=>typeof t=="object"?kt(a,e,t):((o,r,n)=>{let s=r.hasOwnProperty(n);return r.constructor.createProperty(n,o),s?Object.getOwnPropertyDescriptor(r,n):void 0})(a,e,t)}function w(a){return Q({...a,state:!0,attribute:!1})}var St={days:1,weeks:7,months:30.4368,years:365.25};function Ie(a,e){return!a||a<=0?0:a*(St[e||"days"]??1)}var qe=5;function J(a){let e=a.getFullYear(),t=String(a.getMonth()+1).padStart(2,"0"),o=String(a.getDate()).padStart(2,"0");return`${e}-${t}-${o}`}function $t(a,e){let t=[];for(let o=0;ot.cost).filter(t=>typeof t=="number");return e.length===0?null:e.reduce((t,o)=>t+o,0)/e.length}function jt(a){let{windowStart:e,windowEnd:t,task:o,entryId:r,objectName:n}=a,s=[],l=(c,_)=>({date:c,entry_id:r,task_id:o.id,task_name:o.name,object_name:n,status:_&&(o.status==="overdue"||o.status==="triggered")?"ok":o.status,days_until_due:_?null:o.days_until_due??null,projected:_,schedule_type:o.schedule_type,interval_days:o.interval_days??null,interval_unit:o.interval_unit??null,responsible_user_id:o.responsible_user_id??null,avg_cost:At(o.history),adaptive_enabled:!!o.adaptive_config?.enabled,prediction_confidence:o.threshold_prediction_confidence??null}),d=Math.max(1,Math.round(Ie(o.interval_days,o.interval_unit)));if(o.status==="overdue"||o.status==="triggered"){if(s.push(l(e,!1)),o.schedule_type==="time_based"&&o.interval_days&&o.interval_days>0){let c=se(e,d),_=1;for(;c<=t&&_=e&&u<=t)s.push(l(u,!1));else if(u>t)return s;if(o.schedule_type==="time_based"&&o.interval_days&&o.interval_days>0){let c=se(u,d),_=s.length;for(;c<=t&&_=e&&(s.push(l(c,!0)),_++),c=se(c,d)}return s}var Fe={overdue:0,triggered:1,due_soon:2,ok:3};function Be(a,e,t,o=null){let r=$t(e,t),n=r[0],s=r[r.length-1],l=[];for(let p of a){let u=p.object?.name||"",c=p.entry_id,_=p.tasks||[];for(let h of _){if(o&&h.responsible_user_id!==o||h.enabled===!1)continue;let b=jt({windowStart:n,windowEnd:s,task:h,entryId:c,objectName:u});l.push(...b)}}let d=new Map;for(let p of r)d.set(p,[]);for(let p of l){let u=d.get(p.date);u&&u.push(p)}for(let[,p]of d)p.sort((u,c)=>{let _=Fe[u.status]??99,h=Fe[c.status]??99;if(_!==h)return _-h;if(u.projected!==c.projected)return u.projected?1:-1;let b=u.object_name.localeCompare(c.object_name);return b!==0?b:u.task_name.localeCompare(c.task_name)});return r.map(p=>({date:p,events:d.get(p)??[]}))}var Et={completed:"ok",reset:"ok",skipped:"due_soon",triggered:"triggered",trigger_replaced:"triggered",trigger_removed:"ok"};function Ct(a,e){let t=[];for(let o=e-1;o>=0;o--){let r=new Date(a);r.setDate(r.getDate()-o),r.setHours(0,0,0,0),t.push(J(r))}return t}function We(a,e,t,o=null){let r=Ct(e,t),n=r[0],s=r[r.length-1],l=new Map;for(let p of r)l.set(p,[]);for(let p of a){let u=p.object?.name||"",c=p.entry_id,_=p.tasks||[];for(let h of _){if(o&&h.responsible_user_id!==o)continue;let b=h.history||[];for(let y of b){if(typeof y?.timestamp!="string")continue;let R=y.timestamp.slice(0,10);if(Rs)continue;let M=l.get(R);if(!M)continue;let U=y.type??"completed";M.push({date:R,entry_id:c,task_id:h.id,task_name:h.name,object_name:u,status:Et[U]??"ok",days_until_due:null,projected:!1,schedule_type:h.schedule_type,interval_days:h.interval_days??null,responsible_user_id:h.responsible_user_id??null,avg_cost:typeof y.cost=="number"?y.cost:null,adaptive_enabled:!!h.adaptive_config?.enabled,prediction_confidence:null,history_timestamp:y.timestamp,history_type:U,history_cost:typeof y.cost=="number"?y.cost:null,history_notes:typeof y.notes=="string"?y.notes:null,history_duration:typeof y.duration=="number"?y.duration:null})}}}let d={completed:0,reset:1,skipped:2,triggered:3,trigger_replaced:4};for(let[,p]of l)p.sort((u,c)=>{let _=d[u.history_type??""]??99,h=d[c.history_type??""]??99;if(_!==h)return _-h;let b=u.object_name.localeCompare(c.object_name);return b!==0?b:u.task_name.localeCompare(c.task_name)});return r.map(p=>({date:p,events:l.get(p)??[]}))}var Ye=k` +/*! maintenance_supporter frontend 2.58.0 */ +var it=Object.defineProperty;var lt=Object.getOwnPropertyDescriptor;var x=(a,e,t,o)=>{for(var r=o>1?void 0:o?lt(e,t):e,n=a.length-1,s;n>=0;n--)(s=a[n])&&(r=(o?s(e,t,r):s(r))||r);return o&&r&&it(e,t,r),r};var ee=globalThis,te=ee.ShadowRoot&&(ee.ShadyCSS===void 0||ee.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,ce=Symbol(),ke=new WeakMap,q=class{constructor(e,t,o){if(this._$cssResult$=!0,o!==ce)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(te&&e===void 0){let o=t!==void 0&&t.length===1;o&&(e=ke.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),o&&ke.set(t,e))}return e}toString(){return this.cssText}},Se=a=>new q(typeof a=="string"?a:a+"",void 0,ce),k=(a,...e)=>{let t=a.length===1?a[0]:e.reduce((o,r,n)=>o+(s=>{if(s._$cssResult$===!0)return s.cssText;if(typeof s=="number")return s;throw Error("Value passed to 'css' function must be a 'css' function result: "+s+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(r)+a[n+1],a[0]);return new q(t,a,ce)},$e=(a,e)=>{if(te)a.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(let t of e){let o=document.createElement("style"),r=ee.litNonce;r!==void 0&&o.setAttribute("nonce",r),o.textContent=t.cssText,a.appendChild(o)}},de=te?a=>a:a=>a instanceof CSSStyleSheet?(e=>{let t="";for(let o of e.cssRules)t+=o.cssText;return Se(t)})(a):a;var{is:ct,defineProperty:dt,getOwnPropertyDescriptor:pt,getOwnPropertyNames:ut,getOwnPropertySymbols:_t,getPrototypeOf:ht}=Object,oe=globalThis,Ae=oe.trustedTypes,gt=Ae?Ae.emptyScript:"",ft=oe.reactiveElementPolyfillSupport,F=(a,e)=>a,B={toAttribute(a,e){switch(e){case Boolean:a=a?gt:null;break;case Object:case Array:a=a==null?a:JSON.stringify(a)}return a},fromAttribute(a,e){let t=a;switch(e){case Boolean:t=a!==null;break;case Number:t=a===null?null:Number(a);break;case Object:case Array:try{t=JSON.parse(a)}catch{t=null}}return t}},re=(a,e)=>!ct(a,e),Ce={attribute:!0,type:String,converter:B,reflect:!1,useDefault:!1,hasChanged:re};Symbol.metadata??=Symbol("metadata"),oe.litPropertyMetadata??=new WeakMap;var S=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=Ce){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let o=Symbol(),r=this.getPropertyDescriptor(e,o,t);r!==void 0&&dt(this.prototype,e,r)}}static getPropertyDescriptor(e,t,o){let{get:r,set:n}=pt(this.prototype,e)??{get(){return this[t]},set(s){this[t]=s}};return{get:r,set(s){let l=r?.call(this);n?.call(this,s),this.requestUpdate(e,l,o)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??Ce}static _$Ei(){if(this.hasOwnProperty(F("elementProperties")))return;let e=ht(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(F("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(F("properties"))){let t=this.properties,o=[...ut(t),..._t(t)];for(let r of o)this.createProperty(r,t[r])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[o,r]of t)this.elementProperties.set(o,r)}this._$Eh=new Map;for(let[t,o]of this.elementProperties){let r=this._$Eu(t,o);r!==void 0&&this._$Eh.set(r,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let o=new Set(e.flat(1/0).reverse());for(let r of o)t.unshift(de(r))}else e!==void 0&&t.push(de(e));return t}static _$Eu(e,t){let o=t.attribute;return o===!1?void 0:typeof o=="string"?o:typeof e=="string"?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let o of t.keys())this.hasOwnProperty(o)&&(e.set(o,this[o]),delete this[o]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return $e(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,o){this._$AK(e,o)}_$ET(e,t){let o=this.constructor.elementProperties.get(e),r=this.constructor._$Eu(e,o);if(r!==void 0&&o.reflect===!0){let n=(o.converter?.toAttribute!==void 0?o.converter:B).toAttribute(t,o.type);this._$Em=e,n==null?this.removeAttribute(r):this.setAttribute(r,n),this._$Em=null}}_$AK(e,t){let o=this.constructor,r=o._$Eh.get(e);if(r!==void 0&&this._$Em!==r){let n=o.getPropertyOptions(r),s=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:B;this._$Em=r;let l=s.fromAttribute(t,n.type);this[r]=l??this._$Ej?.get(r)??l,this._$Em=null}}requestUpdate(e,t,o,r=!1,n){if(e!==void 0){let s=this.constructor;if(r===!1&&(n=this[e]),o??=s.getPropertyOptions(e),!((o.hasChanged??re)(n,t)||o.useDefault&&o.reflect&&n===this._$Ej?.get(e)&&!this.hasAttribute(s._$Eu(e,o))))return;this.C(e,t,o)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(e,t,{useDefault:o,reflect:r,wrapped:n},s){o&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,s??t??this[e]),n!==!0||s!==void 0)||(this._$AL.has(e)||(this.hasUpdated||o||(t=void 0),this._$AL.set(e,t)),r===!0&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[r,n]of this._$Ep)this[r]=n;this._$Ep=void 0}let o=this.constructor.elementProperties;if(o.size>0)for(let[r,n]of o){let{wrapped:s}=n,l=this[r];s!==!0||this._$AL.has(r)||l===void 0||this.C(r,void 0,n,l)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(o=>o.hostUpdate?.()),this.update(t)):this._$EM()}catch(o){throw e=!1,this._$EM(),o}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(e){}firstUpdated(e){}};S.elementStyles=[],S.shadowRootOptions={mode:"open"},S[F("elementProperties")]=new Map,S[F("finalized")]=new Map,ft?.({ReactiveElement:S}),(oe.reactiveElementVersions??=[]).push("2.1.2");var me=globalThis,je=a=>a,ae=me.trustedTypes,Ee=ae?ae.createPolicy("lit-html",{createHTML:a=>a}):void 0,Oe="$lit$",A=`lit$${Math.random().toFixed(9).slice(2)}$`,ze="?"+A,mt=`<${ze}>`,T=document,Y=()=>T.createComment(""),G=a=>a===null||typeof a!="object"&&typeof a!="function",be=Array.isArray,bt=a=>be(a)||typeof a?.[Symbol.iterator]=="function",pe=`[ +\f\r]`,W=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Te=/-->/g,De=/>/g,j=RegExp(`>|${pe}(?:([^\\s"'>=/]+)(${pe}*=${pe}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`,"g"),Ne=/'/g,Re=/"/g,Le=/^(?:script|style|textarea|title)$/i,ye=a=>(e,...t)=>({_$litType$:a,strings:e,values:t}),m=ye(1),Ht=ye(2),It=ye(3),D=Symbol.for("lit-noChange"),g=Symbol.for("lit-nothing"),Pe=new WeakMap,E=T.createTreeWalker(T,129);function Me(a,e){if(!be(a)||!a.hasOwnProperty("raw"))throw Error("invalid template strings array");return Ee!==void 0?Ee.createHTML(e):e}var yt=(a,e)=>{let t=a.length-1,o=[],r,n=e===2?"":e===3?"":"",s=W;for(let l=0;l"?(s=r??W,c=-1):u[1]===void 0?c=-2:(c=s.lastIndex-u[2].length,p=u[1],s=u[3]===void 0?j:u[3]==='"'?Re:Ne):s===Re||s===Ne?s=j:s===Te||s===De?s=W:(s=j,r=void 0);let h=s===j&&a[l+1].startsWith("/>")?" ":"";n+=s===W?d+mt:c>=0?(o.push(p),d.slice(0,c)+Oe+d.slice(c)+A+h):d+A+(c===-2?l:h)}return[Me(a,n+(a[t]||"")+(e===2?"":e===3?"":"")),o]},V=class a{constructor({strings:e,_$litType$:t},o){let r;this.parts=[];let n=0,s=0,l=e.length-1,d=this.parts,[p,u]=yt(e,t);if(this.el=a.createElement(p,o),E.currentNode=this.el.content,t===2||t===3){let c=this.el.content.firstChild;c.replaceWith(...c.childNodes)}for(;(r=E.nextNode())!==null&&d.length0){r.textContent=ae?ae.emptyScript:"";for(let h=0;h<_;h++)r.append(c[h],Y()),E.nextNode(),d.push({type:2,index:++n});r.append(c[_],Y())}}}else if(r.nodeType===8)if(r.data===ze)d.push({type:2,index:n});else{let c=-1;for(;(c=r.data.indexOf(A,c+1))!==-1;)d.push({type:7,index:n}),c+=A.length-1}n++}}static createElement(e,t){let o=T.createElement("template");return o.innerHTML=e,o}};function O(a,e,t=a,o){if(e===D)return e;let r=o!==void 0?t._$Co?.[o]:t._$Cl,n=G(e)?void 0:e._$litDirective$;return r?.constructor!==n&&(r?._$AO?.(!1),n===void 0?r=void 0:(r=new n(a),r._$AT(a,t,o)),o!==void 0?(t._$Co??=[])[o]=r:t._$Cl=r),r!==void 0&&(e=O(a,r._$AS(a,e.values),r,o)),e}var ue=class{constructor(e,t){this._$AV=[],this._$AN=void 0,this._$AD=e,this._$AM=t}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(e){let{el:{content:t},parts:o}=this._$AD,r=(e?.creationScope??T).importNode(t,!0);E.currentNode=r;let n=E.nextNode(),s=0,l=0,d=o[0];for(;d!==void 0;){if(s===d.index){let p;d.type===2?p=new K(n,n.nextSibling,this,e):d.type===1?p=new d.ctor(n,d.name,d.strings,this,e):d.type===6&&(p=new fe(n,this,e)),this._$AV.push(p),d=o[++l]}s!==d?.index&&(n=E.nextNode(),s++)}return E.currentNode=T,r}p(e){let t=0;for(let o of this._$AV)o!==void 0&&(o.strings!==void 0?(o._$AI(e,o,t),t+=o.strings.length-2):o._$AI(e[t])),t++}},K=class a{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(e,t,o,r){this.type=2,this._$AH=g,this._$AN=void 0,this._$AA=e,this._$AB=t,this._$AM=o,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let e=this._$AA.parentNode,t=this._$AM;return t!==void 0&&e?.nodeType===11&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(e,t=this){e=O(this,e,t),G(e)?e===g||e==null||e===""?(this._$AH!==g&&this._$AR(),this._$AH=g):e!==this._$AH&&e!==D&&this._(e):e._$litType$!==void 0?this.$(e):e.nodeType!==void 0?this.T(e):bt(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==g&&G(this._$AH)?this._$AA.nextSibling.data=e:this.T(T.createTextNode(e)),this._$AH=e}$(e){let{values:t,_$litType$:o}=e,r=typeof o=="number"?this._$AC(e):(o.el===void 0&&(o.el=V.createElement(Me(o.h,o.h[0]),this.options)),o);if(this._$AH?._$AD===r)this._$AH.p(t);else{let n=new ue(r,this),s=n.u(this.options);n.p(t),this.T(s),this._$AH=n}}_$AC(e){let t=Pe.get(e.strings);return t===void 0&&Pe.set(e.strings,t=new V(e)),t}k(e){be(this._$AH)||(this._$AH=[],this._$AR());let t=this._$AH,o,r=0;for(let n of e)r===t.length?t.push(o=new a(this.O(Y()),this.O(Y()),this,this.options)):o=t[r],o._$AI(n),r++;r2||o[0]!==""||o[1]!==""?(this._$AH=Array(o.length-1).fill(new String),this.strings=o):this._$AH=g}_$AI(e,t=this,o,r){let n=this.strings,s=!1;if(n===void 0)e=O(this,e,t,0),s=!G(e)||e!==this._$AH&&e!==D,s&&(this._$AH=e);else{let l=e,d,p;for(e=n[0],d=0;d{let o=t?.renderBefore??e,r=o._$litPart$;if(r===void 0){let n=t?.renderBefore??null;o._$litPart$=r=new K(e.insertBefore(Y(),n),n,void 0,t??{})}return r._$AI(a),r};var ve=globalThis,$=class extends S{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=Ue(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return D}};$._$litElement$=!0,$.finalized=!0,ve.litElementHydrateSupport?.({LitElement:$});var xt=ve.litElementPolyfillSupport;xt?.({LitElement:$});(ve.litElementVersions??=[]).push("4.2.2");var wt={attribute:!0,type:String,converter:B,reflect:!1,hasChanged:re},kt=(a=wt,e,t)=>{let{kind:o,metadata:r}=t,n=globalThis.litPropertyMetadata.get(r);if(n===void 0&&globalThis.litPropertyMetadata.set(r,n=new Map),o==="setter"&&((a=Object.create(a)).wrapped=!0),n.set(t.name,a),o==="accessor"){let{name:s}=t;return{set(l){let d=e.get.call(this);e.set.call(this,l),this.requestUpdate(s,d,a,!0,l)},init(l){return l!==void 0&&this.C(s,void 0,a,l),l}}}if(o==="setter"){let{name:s}=t;return function(l){let d=this[s];e.call(this,l),this.requestUpdate(s,d,a,!0,l)}}throw Error("Unsupported decorator location: "+o)};function Q(a){return(e,t)=>typeof t=="object"?kt(a,e,t):((o,r,n)=>{let s=r.hasOwnProperty(n);return r.constructor.createProperty(n,o),s?Object.getOwnPropertyDescriptor(r,n):void 0})(a,e,t)}function w(a){return Q({...a,state:!0,attribute:!1})}var St={days:1,weeks:7,months:30.4368,years:365.25};function He(a,e){return!a||a<=0?0:a*(St[e||"days"]??1)}var Ie=5;function J(a){let e=a.getFullYear(),t=String(a.getMonth()+1).padStart(2,"0"),o=String(a.getDate()).padStart(2,"0");return`${e}-${t}-${o}`}function $t(a,e){let t=[];for(let o=0;ot.cost).filter(t=>typeof t=="number");return e.length===0?null:e.reduce((t,o)=>t+o,0)/e.length}function Ct(a){let{windowStart:e,windowEnd:t,task:o,entryId:r,objectName:n}=a,s=[],l=(c,_)=>({date:c,entry_id:r,task_id:o.id,task_name:o.name,object_name:n,status:_&&(o.status==="overdue"||o.status==="triggered")?"ok":o.status,days_until_due:_?null:o.days_until_due??null,projected:_,schedule_type:o.schedule_type,interval_days:o.interval_days??null,interval_unit:o.interval_unit??null,responsible_user_id:o.responsible_user_id??null,avg_cost:At(o.history),adaptive_enabled:!!o.adaptive_config?.enabled,prediction_confidence:o.threshold_prediction_confidence??null}),d=Math.max(1,Math.round(He(o.interval_days,o.interval_unit)));if(o.status==="overdue"||o.status==="triggered"){if(s.push(l(e,!1)),o.schedule_type==="time_based"&&o.interval_days&&o.interval_days>0){let c=se(e,d),_=1;for(;c<=t&&_=e&&u<=t)s.push(l(u,!1));else if(u>t)return s;if(o.schedule_type==="time_based"&&o.interval_days&&o.interval_days>0){let c=se(u,d),_=s.length;for(;c<=t&&_=e&&(s.push(l(c,!0)),_++),c=se(c,d)}return s}var qe={overdue:0,triggered:1,due_soon:2,ok:3};function Fe(a,e,t,o=null){let r=$t(e,t),n=r[0],s=r[r.length-1],l=[];for(let p of a){let u=p.object?.name||"",c=p.entry_id,_=p.tasks||[];for(let h of _){if(o&&h.responsible_user_id!==o||h.enabled===!1)continue;let b=Ct({windowStart:n,windowEnd:s,task:h,entryId:c,objectName:u});l.push(...b)}}let d=new Map;for(let p of r)d.set(p,[]);for(let p of l){let u=d.get(p.date);u&&u.push(p)}for(let[,p]of d)p.sort((u,c)=>{let _=qe[u.status]??99,h=qe[c.status]??99;if(_!==h)return _-h;if(u.projected!==c.projected)return u.projected?1:-1;let b=u.object_name.localeCompare(c.object_name);return b!==0?b:u.task_name.localeCompare(c.task_name)});return r.map(p=>({date:p,events:d.get(p)??[]}))}var jt={completed:"ok",reset:"ok",skipped:"due_soon",triggered:"triggered",trigger_replaced:"triggered",trigger_removed:"ok"};function Et(a,e){let t=[];for(let o=e-1;o>=0;o--){let r=new Date(a);r.setDate(r.getDate()-o),r.setHours(0,0,0,0),t.push(J(r))}return t}function Be(a,e,t,o=null){let r=Et(e,t),n=r[0],s=r[r.length-1],l=new Map;for(let p of r)l.set(p,[]);for(let p of a){let u=p.object?.name||"",c=p.entry_id,_=p.tasks||[];for(let h of _){if(o&&h.responsible_user_id!==o)continue;let b=h.history||[];for(let y of b){if(typeof y?.timestamp!="string")continue;let R=y.timestamp.slice(0,10);if(Rs)continue;let M=l.get(R);if(!M)continue;let U=y.type??"completed";M.push({date:R,entry_id:c,task_id:h.id,task_name:h.name,object_name:u,status:jt[U]??"ok",days_until_due:null,projected:!1,schedule_type:h.schedule_type,interval_days:h.interval_days??null,responsible_user_id:h.responsible_user_id??null,avg_cost:typeof y.cost=="number"?y.cost:null,adaptive_enabled:!!h.adaptive_config?.enabled,prediction_confidence:null,history_timestamp:y.timestamp,history_type:U,history_cost:typeof y.cost=="number"?y.cost:null,history_notes:typeof y.notes=="string"?y.notes:null,history_duration:typeof y.duration=="number"?y.duration:null})}}}let d={completed:0,reset:1,skipped:2,triggered:3,trigger_replaced:4};for(let[,p]of l)p.sort((u,c)=>{let _=d[u.history_type??""]??99,h=d[c.history_type??""]??99;if(_!==h)return _-h;let b=u.object_name.localeCompare(c.object_name);return b!==0?b:u.task_name.localeCompare(c.task_name)});return r.map(p=>({date:p,events:l.get(p)??[]}))}var We=k` .cal-controls { display: flex; gap: 12px; @@ -198,9 +198,9 @@ var it=Object.defineProperty;var lt=Object.getOwnPropertyDescriptor;var x=(a,e,t .cal-pill-day { font-size: 17px; } .cal-user-filter { margin-left: 0; width: 100%; } } -`;var Ge={maintenance:"Maintenance",objects:"Objects",tasks:"Tasks",overdue:"Overdue",due_soon:"Due Soon",triggered:"Triggered",trigger_replaced:"Trigger replaced",ok:"OK",all:"All",new_object:"+ New Object",templates_from:"From template",templates_title:"Start from a template",templates_task_count:"{n} tasks",template_created:"Created from template",onboard_hint:"Add your first object to start tracking maintenance.",edit:"Edit",duplicate:"Duplicate",task_duplicated:"Task duplicated",object_duplicated:"Object duplicated",delete:"Delete",add_task:"+ Add Task",complete:"Complete",completed:"Completed",skip:"Skip",skipped:"Skipped",missed:"Missed",reset:"Reset",snooze:"Snooze",snoozed:"Snoozed",cancel:"Cancel",bulk_select:"Select",bulk_select_all:"Select all",bulk_n_selected:"{n} selected",bulk_completed:"{n} tasks completed",bulk_archived:"{n} tasks archived",completing:"Completing\u2026",interval:"Interval",warning:"Warning",last_performed:"Last performed",next_due:"Next due",days_until_due:"Days until due",avg_duration:"Avg duration",trigger:"Trigger",trigger_type:"Trigger type",threshold_above:"Upper limit",threshold_below:"Lower limit",threshold:"Threshold",counter:"Counter",state_change:"State change",runtime:"Runtime",runtime_hours:"Target runtime (hours)",target_value:"Target value",baseline:"Baseline",target_changes:"Target changes",for_minutes:"For (minutes)",time_based:"Time-based",sensor_based:"Sensor-based",manual:"Manual",one_time:"One-time",weekdays:"Weekdays",nth_weekday:"Nth weekday of month",day_of_month:"Day of month",recurrence_on_days:"Repeat on",recurrence_occurrence:"Occurrence",recurrence_weekday:"Weekday",recurrence_day:"Day of month (1\u201331)",recurrence_last_day:"Last day of the month",recurrence_business_day:"Business days only (roll back from weekend)",recurrence_offset:"Offset (days, \xB1)",recurrence_offset_help:"Shift the date by \xB1N days, e.g. -2 = two days before.",last_day_month:"Last day of month",last_business_day_month:"Last business day",ord_1:"1st",ord_2:"2nd",ord_3:"3rd",ord_4:"4th",ord_5:"5th",ord_last:"Last",day_word:"Day",interval_value:"Interval",interval_unit:"Unit",unit_days:"Days",unit_weeks:"Weeks",unit_months:"Months",unit_years:"Years",due_date:"Due date",cleaning:"Cleaning",inspection:"Inspection",replacement:"Replacement",calibration:"Calibration",service:"Service",reading:"Reading",custom:"Custom",history:"History",cost:"Cost",report_button:"Report",report_title:"Maintenance report",report_generated:"Generated",report_times_done:"Done",report_total_cost:"Total cost",report_every:"every {n} {unit}",report_notes:"Notes",report_col_type:"Type",report_col_status:"Status",report_col_schedule:"Schedule",duration:"Duration",both:"Both",trigger_val:"Trigger value",complete_title:"Complete: ",checklist:"Checklist",require_on_completion:"Require on completion",checklist_steps_optional:"Checklist steps (optional)",checklist_placeholder:`Clean filter +`;var Ye={maintenance:"Maintenance",objects:"Objects",tasks:"Tasks",overdue:"Overdue",due_soon:"Due Soon",triggered:"Triggered",trigger_replaced:"Trigger replaced",ok:"OK",all:"All",new_object:"+ New Object",templates_from:"From template",templates_title:"Start from a template",templates_task_count:"{n} tasks",template_created:"Created from template",onboard_hint:"Add your first object to start tracking maintenance.",edit:"Edit",duplicate:"Duplicate",task_duplicated:"Task duplicated",object_duplicated:"Object duplicated",delete:"Delete",add_task:"+ Add Task",complete:"Complete",completed:"Completed",skip:"Skip",skipped:"Skipped",missed:"Missed",reset:"Reset",snooze:"Snooze",snoozed:"Snoozed",cancel:"Cancel",bulk_select:"Select",bulk_select_all:"Select all",bulk_n_selected:"{n} selected",bulk_completed:"{n} tasks completed",bulk_archived:"{n} tasks archived",completing:"Completing\u2026",interval:"Interval",warning:"Warning",last_performed:"Last performed",next_due:"Next due",days_until_due:"Days until due",avg_duration:"Avg duration",trigger:"Trigger",trigger_type:"Trigger type",threshold_above:"Upper limit",threshold_below:"Lower limit",threshold:"Threshold",counter:"Counter",state_change:"State change",runtime:"Runtime",runtime_hours:"Target runtime (hours)",target_value:"Target value",baseline:"Baseline",target_changes:"Target changes",for_minutes:"For (minutes)",time_based:"Time-based",sensor_based:"Sensor-based",manual:"Manual",one_time:"One-time",weekdays:"Weekdays",nth_weekday:"Nth weekday of month",day_of_month:"Day of month",recurrence_on_days:"Repeat on",recurrence_occurrence:"Occurrence",recurrence_weekday:"Weekday",recurrence_day:"Day of month (1\u201331)",recurrence_last_day:"Last day of the month",recurrence_business_day:"Business days only (roll back from weekend)",recurrence_offset:"Offset (days, \xB1)",recurrence_offset_help:"Shift the date by \xB1N days, e.g. -2 = two days before.",last_day_month:"Last day of month",last_business_day_month:"Last business day",ord_1:"1st",ord_2:"2nd",ord_3:"3rd",ord_4:"4th",ord_5:"5th",ord_last:"Last",day_word:"Day",interval_value:"Interval",interval_unit:"Unit",unit_days:"Days",unit_weeks:"Weeks",unit_months:"Months",unit_years:"Years",due_date:"Due date",cleaning:"Cleaning",inspection:"Inspection",replacement:"Replacement",calibration:"Calibration",service:"Service",reading:"Reading",custom:"Custom",history:"History",cost:"Cost",report_button:"Report",report_title:"Maintenance report",report_generated:"Generated",report_times_done:"Done",report_total_cost:"Total cost",report_every:"every {n} {unit}",report_notes:"Notes",report_col_type:"Type",report_col_status:"Status",report_col_schedule:"Schedule",duration:"Duration",both:"Both",trigger_val:"Trigger value",complete_title:"Complete: ",checklist:"Checklist",require_on_completion:"Require on completion",checklist_steps_optional:"Checklist steps (optional)",checklist_placeholder:`Clean filter Replace seal -Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:"{field}: too long (max {n} characters)",err_too_short:"{field}: too short (min {n} characters)",err_value_too_high:"{field}: too large (max {n})",err_value_too_low:"{field}: too small (min {n})",err_required:"{field}: required",err_wrong_type:"{field}: wrong type (expected: {type})",err_invalid_choice:"{field}: not an allowed value",err_invalid_value:"{field}: invalid value",feat_schedule_time:"Time-of-day scheduling",feat_schedule_time_desc:"Tasks become overdue at a specific time of day instead of midnight.",schedule_time_optional:"Due at time (optional, HH:MM)",schedule_time_help:"Empty = midnight (default). HA timezone.",at_time:"at",notes_optional:"Notes (optional)",cost_optional:"Cost (optional)",duration_minutes:"Duration in minutes (optional)",days:"days",day:"day",today:"Today",d_overdue:"d overdue",no_tasks:"No maintenance tasks yet. Create an object to get started.",no_tasks_short:"No tasks",no_history:"No history entries yet.",show_all:"Show all",cost_duration_chart:"Cost & Duration",installed:"Installed",confirm_delete_object:"Delete this object and all its tasks?",confirm_delete_task:"Delete this task?",min:"Min",max:"Max",save:"Save",saving:"Saving\u2026",edit_task:"Edit Task",new_task:"New Maintenance Task",task_name:"Task name",maintenance_type:"Maintenance type",priority:"Priority",labels:"Labels",labels_placeholder:"e.g. safety, seasonal, tenant-visible",labels_help:"Comma-separated tags for filtering and reporting.",priority_low:"Low",priority_normal:"Normal",priority_high:"High",schedule_type:"Schedule type",interval_days:"Interval (days)",warning_days:"Warning days",earliest_completion_days:"Earliest completion (days before due)",earliest_completion_days_help:"Leave empty to allow completing any time. 0 = only on/after the due date.",last_performed_optional:"Last performed (optional)",interval_anchor:"Interval anchor",anchor_completion:"From completion date",anchor_planned:"From planned date (no drift)",edit_object:"Edit Object",name:"Name",manufacturer_optional:"Manufacturer (optional)",model_optional:"Model (optional)",serial_number_optional:"Serial number (optional)",serial_number_label:"S/N",documentation_url_label:"Manual",object_notes_label:"Notes",sort_due_date:"Due date",sort_object:"Object name",sort_type:"Type",sort_task_name:"Task name",all_objects:"All objects",all_parts:"All parts",tasks_lower:"tasks",no_tasks_yet:"No tasks yet",add_first_task:"Add first task",trigger_configuration:"Trigger Configuration",entity_id:"Entity ID",comma_separated:"comma-separated",entity_logic:"Entity logic",entity_logic_any:"Any entity triggers",entity_logic_all:"All entities must trigger",entities:"entities",attribute_optional:"Attribute (optional, blank = state)",use_entity_state:"Use entity state (no attribute)",trigger_above:"Trigger above",trigger_below:"Trigger below",for_at_least_minutes:"For at least (minutes)",safety_interval_days:"Safety interval (days, optional)",safety_interval:"Safety interval (optional)",delta_mode:"Delta mode",from_state_optional:"From state (optional)",to_state_optional:"To state (optional)",documentation_url_optional:"Documentation URL (optional)",object_notes_optional:"Notes (optional)",nfc_tag_id_optional:"NFC Tag ID (optional)",nfc_tags_empty_help:"No NFC tags registered in Home Assistant yet.",nfc_tags_open_settings:"Open Tags settings",nfc_tags_refresh:"Refresh",environmental_entity_optional:"Environmental sensor (optional)",environmental_entity_helper:"e.g. sensor.outdoor_temperature \u2014 adjusts the interval based on environmental conditions",adaptive_prediction_enabled:"Enable sensor-driven predictions",adaptive_seasonal_enabled:"Enable seasonal awareness",adaptive_max_interval:"Maximum interval (days)",adaptive_min_interval:"Minimum interval (days)",adaptive_ewa_alpha:"Learning rate (alpha)",adaptive_enabled:"Enable adaptive scheduling",adaptive_section_title:"Adaptive Scheduling",environmental_attribute_optional:"Environmental attribute (optional)",nfc_tag_id:"NFC Tag ID",nfc_linked:"NFC tag linked",nfc_link_hint:"Click to link NFC tag",responsible_user:"Responsible User",shared_with:"Shared with (rotation)",shared_with_help:"Pick multiple people to share this task; the responsible person rotates on each completion.",rotation_strategy:"Rotation",rotation_none:"No rotation",rotation_round_robin:"Round-robin",rotation_least_completed:"Least completed",rotation_random:"Random",no_user_assigned:"(No user assigned)",all_users:"All Users",my_tasks:"My Tasks",tab_calendar:"Calendar",cal_no_events:"No maintenance",cal_window_7:"7 days",cal_window_14:"14 days",cal_window_30:"30 days",cal_window_365:"1 year",cal_every_n_days:"every {n} days",cal_source_time:"Time-based",cal_source_time_adaptive:"Time-based (adaptive)",cal_source_sensor:"Sensor-based",cal_predicted:"predicted",cal_confidence_high:"high confidence",cal_confidence_medium:"medium confidence",cal_confidence_low:"low confidence",budget_monthly:"Monthly budget",budget_yearly:"Yearly budget",groups:"Groups",new_group:"New group",edit_group:"Edit group",no_groups:"No groups yet",delete_group:"Delete group",delete_group_confirm:"Delete group '{name}'?",group_select_tasks:"Select tasks",group_name_required:"Name is required",description_optional:"Description (optional)",selected:"Selected",loading_chart:"Loading chart data...",hide_outliers:"Hide outliers (sensor glitches)",was_maintenance_needed:"Was this maintenance needed?",feedback_needed:"Needed",feedback_not_needed:"Not needed",feedback_not_sure:"Not sure",suggested_interval:"Suggested interval",apply_suggestion:"Apply",reanalyze:"Re-analyze",reanalyze_result:"New analysis",reanalyze_insufficient_data:"Not enough data to produce a recommendation",data_points:"data points",dismiss_suggestion:"Dismiss",confidence_low:"Low",confidence_medium:"Medium",confidence_high:"High",recommended:"recommended",seasonal_awareness:"Seasonal Awareness",edit_seasonal_overrides:"Edit seasonal factors",seasonal_overrides_title:"Seasonal factors (override)",seasonal_overrides_hint:"Factor per month (0.1\u20135.0). Empty = learned automatically.",seasonal_override_invalid:"Invalid value",seasonal_override_range:"Factor must be between 0.1 and 5.0",clear_all:"Clear all",seasonal_chart_title:"Seasonal Factors",seasonal_learned:"Learned",seasonal_manual:"Manual",month_jan:"Jan",month_feb:"Feb",month_mar:"Mar",month_apr:"Apr",month_may:"May",month_jun:"Jun",month_jul:"Jul",month_aug:"Aug",month_sep:"Sep",month_oct:"Oct",month_nov:"Nov",month_dec:"Dec",sensor_prediction:"Sensor Prediction",degradation_trend:"Trend",trend_rising:"Rising",trend_falling:"Falling",trend_stable:"Stable",trend_insufficient_data:"Insufficient data",days_until_threshold:"Days until threshold",threshold_exceeded:"Threshold exceeded",environmental_adjustment:"Environmental factor",sensor_prediction_urgency:"Sensor predicts threshold in ~{days} days",day_short:"day",weibull_reliability_curve:"Reliability Curve",weibull_failure_probability:"Failure Probability",weibull_r_squared:"Fit R\xB2",beta_early_failures:"Early Failures",beta_random_failures:"Random Failures",beta_wear_out:"Wear-out",beta_highly_predictable:"Highly Predictable",confidence_interval:"Confidence Interval",confidence_conservative:"Conservative",confidence_aggressive:"Optimistic",current_interval_marker:"Current interval",recommended_marker:"Recommended",characteristic_life:"Characteristic life",chart_mini_sparkline:"Trend sparkline",chart_history:"Cost and duration history",chart_seasonal:"Seasonal factors, 12 months",chart_weibull:"Weibull reliability curve",chart_sparkline:"Sensor trigger value chart",days_progress:"Days progress",qr_code:"QR Code",qr_generating:"Generating QR code\u2026",qr_error:"Failed to generate QR code.",qr_error_no_url:"No HA URL configured. Please set an external or internal URL in Settings \u2192 System \u2192 Network.",save_error:"Failed to save. Please try again.",qr_print:"Print",qr_download:"Download SVG",qr_action:"Action on scan",qr_action_view:"View maintenance info",qr_action_complete:"Mark maintenance as complete",qr_url_mode:"Link type",qr_mode_companion:"Companion App",qr_mode_local:"Local (mDNS)",qr_mode_server:"Server URL",overview:"Overview",analysis:"Analysis",recent_activities:"Recent Activities",search_notes:"Search notes",avg_cost:"Avg Cost",no_advanced_features:"No advanced features enabled",no_advanced_features_hint:"Enable \u201CAdaptive Intervals\u201D or \u201CSeasonal Patterns\u201D in the integration settings to see analysis data here.",analysis_not_enough_data:"Not enough data for analysis yet.",analysis_not_enough_data_hint:"Weibull analysis requires at least 5 completed maintenances; seasonal patterns become visible after 6+ data points per month.",analysis_manual_task_hint:"Manual tasks without an interval do not generate analysis data.",completions:"completions",current:"Current",shorter:"Shorter",longer:"Longer",normal:"Normal",disabled:"Disabled",compound_logic:"Compound logic",compound:"Compound (multiple conditions)",compound_logic_and:"AND \u2014 all conditions must trigger",compound_logic_or:"OR \u2014 any condition triggers",compound_help:"Combine several sensor conditions into one trigger.",compound_no_conditions:"No conditions yet \u2014 add at least one.",compound_add_condition:"Add condition",compound_condition:"Condition",compound_remove_condition:"Remove condition",card_title:"Title",card_show_header:"Show header with statistics",card_show_actions:"Show action buttons",card_compact:"Compact mode",card_max_items:"Max items (0 = all)",card_filter_status:"Filter by status",card_filter_status_help:"Empty = show all statuses.",card_filter_objects:"Filter by objects",card_filter_objects_help:"Empty = show all objects.",card_filter_areas:"Filter by areas",card_filter_areas_help:"Empty = show all areas.",card_filter_entities:"Filter by entities (entity_ids)",card_filter_entities_help:"Pick sensor / binary_sensor entities from this integration. Empty = all.",card_loading_objects:"Loading objects\u2026",card_load_error:"Could not load objects \u2014 check the WebSocket connection.",card_no_tasks_title:"No maintenance tasks yet",card_no_tasks_cta:"\u2192 Create one in the Maintenance panel",no_objects:"No objects yet.",action_error:"Action failed. Please try again.",area_id_optional:"Area (optional)",installation_date_optional:"Installation date (optional)",warranty_expiry_optional:"Warranty expiry (optional)",warranty:"Warranty",warranty_valid_until:"valid until {date}",warranty_expires_in:"expires in {days} days",warranty_expired:"expired",cal_past_windows:"Past windows",cal_forward_windows:"Forward windows",history_edit_title:"Edit history entry",history_edit_timestamp:"Timestamp",manufacturer:"Manufacturer",model:"Model",area:"Area",actions:"Actions",view_mode_label:"View",view_cards:"Card view",view_table:"Table view",objects_table_columns_label:"Objects table columns",objects_table_columns_hint:"Choose which columns appear in the objects table view.",custom_icon_optional:"Icon (optional, e.g. mdi:wrench)",task_enabled:"Task enabled",skip_reason_prompt:"Skip this task?",reason_optional:"Reason (optional)",reset_date_prompt:"Mark task as performed?",reset_date_optional:"Last performed date (optional, defaults to today)",notes_label:"Notes",documentation_label:"Documentation",no_nfc_tag:"\u2014 No tag \u2014",dashboard:"Dashboard",tab_today:"Today",palette_placeholder:"Search objects and tasks\u2026",palette_no_results:"No matches",palette_hint:"\u2191\u2193 to navigate \xB7 Enter to open \xB7 Esc to close",today_all_caught_up:"All caught up! Nothing due this week.",today_overdue:"Overdue",today_due_today:"Due today",today_this_week:"This week",settings:"Settings",settings_features:"Advanced Features",settings_features_desc:"Enable or disable advanced features. Disabling hides them from the UI but does not delete data.",feat_adaptive:"Adaptive Scheduling",feat_adaptive_desc:"Learn optimal intervals from maintenance history",feat_predictions:"Sensor Predictions",feat_predictions_desc:"Predict trigger dates from sensor degradation",feat_seasonal:"Seasonal Adjustments",feat_seasonal_desc:"Adjust intervals based on seasonal patterns",feat_environmental:"Environmental Correlation",feat_environmental_desc:"Correlate intervals with temperature/humidity",feat_budget:"Budget Tracking",feat_budget_desc:"Track monthly and yearly maintenance spending",feat_groups:"Task Groups",feat_groups_desc:"Organize tasks into logical groups",feat_checklists:"Checklists",feat_checklists_desc:"Multi-step procedures for task completion",settings_general:"General",settings_default_warning:"Default warning days",settings_panel_enabled:"Sidebar panel",settings_panel_title:"Sidebar panel title",settings_notifications:"Notifications",settings_notify_service:"Notification service",settings_install_assist_sentences:"Install Assist sentences",settings_install_assist_sentences_hint:"Copies the voice sentences into your configuration so the classic Assist agent recognises them. A file you edited yourself is never overwritten.",test_notification:"Test notification",send_test:"Send test",testing:"Sending\u2026",test_notification_success:"Test notification sent",test_notification_failed:"Test notification failed",notify_per_person:"Per-person delivery",notify_no_own_device:"No own device \u2014 uses the household service",settings_notify_due_soon:"Notify when due soon",settings_notify_overdue:"Notify when overdue",settings_notify_triggered:"Notify when triggered",settings_interval_hours:"Repeat interval (hours, 0 = once)",settings_quiet_hours:"Quiet hours",settings_quiet_start:"Start",settings_quiet_end:"End",settings_max_per_day:"Max notifications per day (0 = unlimited)",settings_bundling:"Bundle notifications",settings_bundle_threshold:"Bundle threshold",settings_reminder_leads:"Extra reminders (days before due)",settings_reminder_leads_hint:"Comma-separated lead times, e.g. 14, 3, 0 \u2014 one extra reminder fires on each matching day. Empty = off.",settings_actions:"Mobile Action Buttons",settings_action_complete:"Show 'Complete' button",settings_action_skip:"Show 'Skip' button",settings_action_snooze:"Show 'Snooze' button",settings_weekly_digest:"Weekly digest",settings_weekly_digest_hint:"A single summary notification on Monday morning when tasks are due.",settings_warranty_reminder:"Warranty expiry reminder",settings_warranty_reminder_days:"Days before expiry",settings_warranty_reminder_hint:"Notify once when an object's warranty is this many days from expiring.",settings_snooze_hours:"Snooze duration (hours)",settings_budget:"Budget",settings_currency:"Currency",settings_budget_monthly:"Monthly budget",settings_budget_yearly:"Yearly budget",settings_budget_alerts:"Budget alerts",settings_budget_threshold:"Alert threshold (%)",settings_import_export:"Import / Export",settings_export_json:"Export JSON",settings_export_yaml:"Export YAML",settings_export_csv:"Export CSV",settings_import_csv:"Import CSV",settings_import_placeholder:"Paste JSON or CSV content here\u2026",settings_import_btn:"Import",settings_import_success:"{count} objects imported successfully.",settings_export_success:"Export downloaded.",settings_saved:"Setting saved.",settings_include_history:"Include history",settings_export_selection:"Limit to selected objects (optional)",settings_docs_archive:"Documents archive (with files)",settings_docs_archive_hint:"The JSON/YAML/CSV exports carry settings only. This ZIP includes the uploaded file contents so a restore is complete.",settings_docs_export_btn:"Download documents ZIP",settings_docs_import_btn:"Restore documents ZIP",settings_docs_import_success:"Restored: {blobs} files, {docs} documents",sort_alphabetical:"Alphabetical",sort_due_soonest:"Due soonest",sort_task_count:"Task count",sort_area:"Area",sort_assigned_user:"Assigned user",sort_group:"Group",groupby_none:"No grouping",groupby_area:"By area",groupby_group:"By group",groupby_user:"By user",filter_label:"Filter",user_label:"User",photo_label:"Photo",sort_label:"Sort",group_by_label:"Group by",state_value_help:'Use the HA state value (usually lowercase, e.g. "on"/"off"). Case is normalised on save.',target_changes_help:"Number of matching transitions before the trigger fires (default: 1).",qr_print_title:"Print QR codes",qr_print_desc:"Generate a printable page of QR codes to cut out and stick on your equipment.",qr_print_load:"Load objects",qr_print_filter:"Filter",qr_print_objects:"Objects",qr_print_actions:"Actions",qr_print_url_mode:"Link type",qr_print_estimate:"Estimated QR codes",qr_print_over_limit:"cap is 200, narrow the filter",qr_print_generate:"Generate QR codes",qr_print_generating:"Generating\u2026",qr_print_ready:"QR codes ready",qr_print_print_button:"Print",qr_print_empty:"Nothing to generate",qr_action_skip:"Skip",vacation_title:"Vacation mode",vacation_active:"active",vacation_ended:"ended",vacation_desc:"Plan a vacation: notifications are paused during the period plus a buffer of days. You can opt specific tasks back in.",vacation_enable:"Enable vacation mode",vacation_start:"Start",vacation_end:"End",vacation_buffer:"Buffer (days)",vacation_exempt_title:"Notify anyway during vacation",vacation_exempt_desc:"Pick tasks that should still notify during vacation (e.g. critical pool chemistry).",vacation_load_tasks:"Load tasks",vacation_preview_btn:"Show preview",vacation_preview_affected:"tasks affected",vacation_event_due_soon:"becomes due soon",vacation_event_overdue:"becomes overdue",vacation_event_triggered_est:"sensor trigger possible",vacation_sensor_based:"(sensor-based)",vacation_action_notify:"Notify anyway",vacation_action_unsilence:"Silence again",vacation_marked_complete:"Marked complete",vacation_marked_skip:"Skipped",vacation_end_now:"End vacation now",add:"Add",show_stats:"Show stats + graphs",hide_stats:"Hide stats",adaptive_no_data:"Not enough completion history yet for adaptive analysis. Complete this task a few more times to unlock interval recommendations and reliability charts.",suggestion_applied:"Suggested interval applied",vacation_mode:"Vacation mode",vacation_status_active:"Active now",vacation_status_scheduled:"Scheduled",vacation_status_inactive:"Inactive",vacation_end_now_confirm:"End vacation immediately?",vacation_exempt_count:"exempt",vacation_advanced:"Advanced\u2026",vacation_open_panel:"Open in panel",enable:"Enable",saved:"Saved",budget_monthly_set:"Set monthly",budget_yearly_set:"Set yearly",budget_advanced:"Currency, alerts\u2026",budget_open_panel:"Open in panel",groups_empty:"No groups yet.",group_new_placeholder:"Add group\u2026",group_delete_confirm:'Delete group "{name}"?',groups_manage_tasks:"Manage task assignments\u2026",groups_open_panel:"Open in panel",unassigned:"Unassigned",no_area:"No area",has_overdue:"Has overdue tasks",object:"Object",settings_panel_access:"Panel access",settings_panel_access_desc:"Admins always have full access. To delegate create, edit and delete to specific non-admins, switch this on and pick them below \u2014 everyone else sees only Complete and Skip.",settings_operator_write:"Allow selected users to create, edit & delete",settings_operator_write_desc:"Off: only admins can change content. On: the selected users below get full access too.",no_non_admin_users:"No non-admin users found. Add some in Settings \u2192 People.",owner_label:"Owner",feat_completion_actions:"Completion actions",feat_completion_actions_desc:"Per-task HA action on complete + quick-complete QR with pre-set values.",on_complete_action_title:"On complete: trigger HA action (optional)",on_complete_action_desc:"Calls an HA service when the task is completed \u2014 e.g. reset a counter on the device.",on_complete_action_service:"Service",on_complete_action_target:"Target entity",on_complete_action_target_hint:"Note: the entity domain must match the service \u2014 e.g. 'button.press' only works on button.*, 'counter.increment' only on counter.*, 'input_button.press' only on input_button.* etc. On a mismatch the action will silently fail (HA logs 'Referenced entities ... missing or not currently available').",on_complete_action_data:"Data (JSON, optional)",on_complete_action_test:"Validate configuration",on_complete_action_test_success:"\u2713 Configuration valid (action will fire only on task completion)",on_complete_action_test_failed:"Failed",quick_complete_defaults_title:"Quick-complete defaults (for QR scans, optional)",quick_complete_defaults_desc:"Pre-set values for quick-complete QR scans. Without these, the QR opens the complete dialog.",quick_complete_defaults_notes:"Notes",quick_complete_defaults_cost:"Cost",quick_complete_defaults_duration:"Duration (minutes)",quick_complete_defaults_feedback_none:"No feedback",quick_complete_defaults_feedback_needed:"Was needed",quick_complete_defaults_feedback_not_needed:"Not needed",quick_complete_success:"Quickly marked complete",show_all_objects:"Show all objects",show_all_tasks:"Clear filter \u2014 show all tasks",filter_to_overdue:"Filter task list to overdue only",filter_to_due_soon:"Filter task list to due-soon only",filter_to_triggered:"Filter task list to triggered only",open_task:"Open task",show_details:"Show history + stats",hide_details:"Hide details",history_empty:"No history yet.",history_edit_button:"Edit entry",total_cost:"Total cost",times_performed:"Performed",older_entries:"older",open_in_panel:"Open in Maintenance panel",skip_reason:"Skip reason (optional)",reset_to_date:"Reset last_performed to",delete_task_confirm:"Delete this task and its history?",delete_object_confirm:"Delete this object and all its tasks?",loading:"Loading\u2026",archive:"Archive",undo:"Undo",task_archived:"Task archived",object_archived:"Object archived",unarchive:"Unarchive",archived:"Archived",show_archived:"Show archived",hide_archived:"Hide archived",confirm_archive_object:"Archive this object and its tasks? They keep their history and can be unarchived later.",settings_archive:"Archive & Retention",settings_archive_desc:"Retire completed one-off tasks without deleting them. Archived items are hidden and inert but keep their history and cost.",settings_archive_oneoff_days:"Auto-archive completed one-off tasks after (days, 0 = off)",settings_delete_archived_oneoff_days:"Auto-delete archived one-off tasks after (days, 0 = never)",archive_object:"Archive object",unarchive_object:"Unarchive object",documents:"Documents",documents_empty:"No documents yet.",doc_upload:"Upload file",doc_uploading:"Uploading\u2026",doc_add_link:"Add link",doc_link_url:"URL (https://\u2026)",doc_link_title:"Title (optional)",doc_open:"Open",doc_delete_confirm:'Delete "{name}"?',doc_too_large:"File is too large (max 25 MB).",doc_upload_failed:"Upload failed.",completion_photo_optional:"Completion photo (optional)",add_photo:"Add photo",uploading:"Uploading\u2026",remove:"Remove",doc_deduped:"Already stored elsewhere \u2014 shared, no extra space used.",doc_dup_in_object:"This file is already attached to this object.",doc_link_invalid:"Only http/https links are allowed.",doc_cat_manual:"Manual",doc_cat_warranty:"Warranty",doc_cat_invoice:"Invoice",doc_cat_spare_parts:"Spare parts",doc_cat_photo:"Photo",doc_cat_other:"Other",doc_link_badge:"Link",doc_storage_title:"Document storage",doc_storage_saved:"Saved via deduplication",doc_storage_refresh:"Refresh",doc_download:"Download",doc_close:"Close",doc_camera:"Take photo",doc_drop_hint:"Drop files here",doc_task_none:"No documents linked to this task.",doc_link_existing:"Link a document\u2026",doc_attach:"Link",doc_unlink:"Unlink",doc_page:"Page",chart_range_7d:"7d",chart_range_30d:"30d",chart_range_90d:"90d",chart_range_1y:"1y",chart_since_service:"since last service",chart_no_stats:"No long-term statistics for this entity \u2014 showing maintenance-event values only",auto_complete_on_recovery:"Auto-complete when the sensor recovers",auto_complete_on_recovery_help:"Records a completion (sets last performed) when the trigger clears itself \u2014 e.g. salt refilled, filter replaced.",doc_search:"Search documents\u2026",doc_search_none:"No matching documents",link_device_optional:"Link to existing device (optional)",parent_object_optional:"Parent object (optional)",parent_none:"(No parent)",paused:"Paused",pause_object:"Pause",resume_object:"Resume",pause_until_prompt:"Freeze this object's schedules \u2014 nothing becomes due and nothing notifies until it is resumed. Optionally set an auto-resume date.",pause_until_label:"Resume on (optional)",object_paused:"Object paused",object_resumed:"Object resumed \u2014 schedules restarted",object_paused_badge:"Paused",paused_until_label:"until",replace_object:"Replace\u2026",replace_object_prompt:"Retire this object and create a successor. History and costs stay archived on the old one; tasks and documents carry over to the new one, counters start fresh.",replace_name_label:"Successor name",object_replaced:"Object replaced \u2014 successor created",reading_unit_label:"Reading unit (e.g. kWh, m\xB3)",reading_unit_help:"Shown next to the recorded value when completing this task.",reading_value_label:"Reading value",reading_label:"Reading",settings_templates_label:"Template gallery",settings_templates_hint:`Untick templates you'll never need \u2014 they disappear from the "From template" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.`,worksheet:"Work sheet",worksheet_scan_view:"Scan to open the task",worksheet_scan_complete:"Scan to complete",worksheet_manual_excerpt:"Manual excerpt",worksheet_pages:"pages",worksheet_printed:"Printed",worksheet_never:"Never",card_all_caught_up:"All caught up \u2014 nothing needs attention",postpone:"Postpone",postpone_date_prompt:"Postpone this occurrence to which date?",postpone_date_label:"New due date",postponed:"Postponed",postponed_to:"Postponed to",season_window_label:"Seasonal window (months)",season_window_hint:"Only due in the selected months; off-season dates roll to the next active month. None = all year.",series_end_label:"Ends",series_end_never:"Never (repeats indefinitely)",series_end_after_count:"After a number of times",series_end_until:"On a date",series_end_count_label:"Number of times",series_end_until_label:"End date",parts_section:"Parts & consumables",parts_inventory_value:"Inventory value",part_add:"Add part",part_name:"Name",part_vendor:"Manufacturer",part_storage_location:"Storage location",part_product_url:"Product URL",part_unit:"Unit",part_cost:"Unit price",part_stock:"Stock",part_reorder_threshold:"Reorder at",part_restock_quantity:"Restock quantity",part_auto_buy:"Auto-create buy task when low",part_restock:"Adjust stock",parts_used_by:"Used by",restock_quantity_label:"Quantity bought",consumes_parts_label:"Consumes parts",shared_parts_other_objects:"Parts from other objects",shared_parts_help:"Several objects can share one stock. Completing this task takes from the owning object.",shared_part_unknown:"Unknown part",parts_load_failed:"Couldn't load this object's parts \u2014 the consumes-parts options are unavailable right now.",adopt_problem_button:"Adopt problem sensors",adopt_problem_title:"Adopt problem sensors",adopt_problem_hint:"Turn HA problem sensors (printer errors, filter warnings, low battery) into maintenance tasks that trigger while the problem is active and clear themselves when it resolves.",adopt_problem_none:"No problem sensors found that aren't already tracked.",adopt_problem_active:"active",adopt_problem_ok:"ok",adopt_problem_new_object:"(new)",adopt_problem_adopt:"Adopt selected",adopt_problem_done:"Adopted {tasks} problem sensor(s)",views_label:"Views",views_none:"\u2014 No view \u2014",views_manage:"Save / manage views",views_dialog_title:"Saved views",views_dialog_hint:"Save the current filters as a named view everyone can reuse.",views_name_placeholder:"View name",views_save_current:"Save current filters",views_none_yet:"No saved views yet.",close:"Close",trigger_hint_now:"The sensor reads {value} right now.",trigger_hint_above:"The task triggers once it rises above {target}.",trigger_hint_below:"It triggers once it falls below {target}.",trigger_hint_counter_delta:"Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",trigger_hint_counter_delta_edit:"Counts usage since the last completion: due after +{target}; the count restarts after each completion.",trigger_hint_counter_abs:"The task becomes due once the sensor reaches {target}.",trigger_hint_runtime:"The task becomes due after {hours} h of accumulated on-time; the counter restarts after each completion.",trigger_hint_state_change:"The task becomes due after {count} state change(s).",trigger_hint_state_change_to:"The task becomes due after {count} change(s) to \u201C{state}\u201D.",trigger_hint_state_now:"Current state: {value}.",adopt_problem_part:"Uses part: {name}",label_filter:"Label",all_labels:"All labels",settings_notify_scope:"Notify only for view",settings_notify_scope_all:"All tasks",settings_notify_scope_hint:"Only tasks matching the selected saved view's label/user filters send reminders. Status, sorting and grouping of the view are ignored here.",card_saved_view:"Saved view",card_saved_view_none:"None",card_saved_view_help:"Applies the view's status, user and label filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",doc_part_none:"No documents linked to this part.",settings_templates_toggle_group:"Enable or disable all templates in this group",setups_button:"Suggested setups",setups_title:"Suggested setups (Beta)",setups_hint:"Devices of supported integrations whose consumable sensors can drive maintenance tasks. Adopting creates the object and wires each task to its sensor \u2014 it triggers when the consumable runs low and resolves itself after replacement.",setups_none:"No supported devices with unwired consumable sensors found.",setups_adopt:"Set up selected",setups_done:"{tasks} sensor-wired tasks created.",complete_parts_used:"Parts used this time",part_delete_confirm:"Delete part '{name}'? Its stock tracking, task links and any open buy reminder will be removed.",baseline_start_value:"Start reading (optional)",baseline_start_help:"Counting starts from this reading. Leave empty to count from the current value; enter the reading at the last service so usage since then already counts.",setups_baseline_hint:"reading at last service (optional)",baseline_start_help_edit:"Leave empty to keep the existing counting. Entering a value re-anchors the counting (e.g. the reading at the last service).",baseline_current_effective:"Currently effective start value: {value}",runtime_on_states:"Active states",runtime_on_states_help:"States that count as running \u2014 default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",setups_target_new:"Create new: {name}",schedule_preview_title:"Next dates",schedule_preview_ontime:"Assuming on-time completion.",schedule_preview_ends:"(series ends)",adopt_problem_responsible:"Responsible user for all adopted tasks (optional)",adopt_problem_configure:"Configure",history_auto:"Automatic",battery_fleet_title:"Battery fleet",battery_fleet_none_low:"All batteries OK \u2014 nothing to replace.",battery_fleet_buy_now:"Buy now",battery_fleet_soon:"Needed soon",battery_fleet_soon_hint:"Predicted from the last replacement date \u2014 order ahead.",battery_fleet_mark_all:"Mark all replaced",battery_fleet_mark_one:"Mark this battery replaced",battery_fleet_offline:"offline",battery_fleet_trigger_lost:"This task's sensor trigger was lost \u2014 it will not fire or auto-complete.",battery_fleet_repair:"Repair",battery_fleet_exclude:"Exclude from the fleet",battery_fleet_excluded:"Excluded",battery_fleet_include:"Track again",battery_fleet_all:"All tracked batteries",battery_fleet_all_hint:"Exclude a device here to drop it from the fleet before it ever reports low \u2014 a vacuum that recharges itself, or a phone that warns you on its own.",battery_fleet_status_low:"Low",battery_fleet_status_soon:"Soon",battery_fleet_status_ok:"Healthy",battery_fleet_predicted_on:"Expected around {date}",battery_fleet_predicted_trend:"Predicted from this battery's discharge trend: around {date} ({confidence})",battery_fleet_rechargeable:"Rechargeable: charge instead of replacing \u2014 never on the shopping list",battery_fleet_sort_name:"Sort by name",battery_fleet_sort_urgency:"Sort by urgency",battery_fleet_mark_recharged:"Mark as recharged",battery_fleet_sparkline_hint:"Battery level over the last 30 days \u2014 dotted: projected until the low threshold",battery_fleet_filter_type:"Show only this battery type",battery_fleet_record_replacement:"The level jumped around {date} \u2014 record this replacement in Battery Notes",battery_fleet_total:"{n} batteries tracked",battery_fleet_setup_button:"Battery fleet",battery_fleet_setup_done:"Battery fleet set up \u2014 one task tracks all your batteries.",update_banner:"A newer version of Maintenance Supporter is on the server \u2014 reload to update the panel.",update_reload:"Reload",battery_fleet_forecast_overdue:"Predicted date passed \u2014 the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",cost_from_parts:"Use \u2248 {amount} from parts",dismiss:"Dismiss",gs_label:"Getting started \u2014 these hints retire as your setup grows",gs_setups_chip:"Suggested setups found {n} devices with pre-wired triggers",gs_adopt_chip:"{n} problem sensors can become maintenance tasks",gs_fleet_chip:"One click sets up the battery fleet"};var Ke="\u20AC",we="en",Qe=(()=>{let a=window;return a.__msLocales||(a.__msLocales={store:{},inflight:{}}),a.__msLocales})(),N=Qe.store;N.en||(N.en=Ge);var Dt=new Set(["de","nl","fr","it","es","pt","pt-br","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi","hu","ko","tr"]),Nt="/maintenance_supporter_locales",Z=Qe.inflight;function ke(a){let e=(a||we).toLowerCase();return e.startsWith("pt")&&e.endsWith("br")?"pt-br":e.substring(0,2)}function f(a,e){let t=ke(e);return N[t]?.[a]??N.en[a]??a}function Je(a){let e=ke(a);return e===we||e in N}function Ze(a){let e=ke(a);return e===we||e in N||!Dt.has(e)?Promise.resolve():(e in Z||(Z[e]=fetch(`${Nt}/${e}.json`).then(t=>t.ok?t.json():null).then(t=>{t?N[e]=t:delete Z[e]}).catch(()=>{delete Z[e]})),Z[e])}var Rt=window,Ve=Rt.__msDateTimePrefs??={};function Xe(a){a&&(Ve.date=a.date_format,Ve.time=a.time_format)}function et(a,e){if(a==null)return"\u2014";let t=e||"en";return a<0?`${Math.abs(a)} ${f("d_overdue",t)}`:a===0?f("today",t):`${a} ${f(a===1?"day":"days",t)}`}var Ho=k` +Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:"{field}: too long (max {n} characters)",err_too_short:"{field}: too short (min {n} characters)",err_value_too_high:"{field}: too large (max {n})",err_value_too_low:"{field}: too small (min {n})",err_required:"{field}: required",err_wrong_type:"{field}: wrong type (expected: {type})",err_invalid_choice:"{field}: not an allowed value",err_invalid_value:"{field}: invalid value",feat_schedule_time:"Time-of-day scheduling",feat_schedule_time_desc:"Tasks become overdue at a specific time of day instead of midnight.",schedule_time_optional:"Due at time (optional, HH:MM)",schedule_time_help:"Empty = midnight (default). HA timezone.",at_time:"at",notes_optional:"Notes (optional)",cost_optional:"Cost (optional)",duration_minutes:"Duration in minutes (optional)",days:"days",day:"day",today:"Today",d_overdue:"d overdue",no_tasks:"No maintenance tasks yet. Create an object to get started.",no_tasks_short:"No tasks",no_history:"No history entries yet.",show_all:"Show all",cost_duration_chart:"Cost & Duration",installed:"Installed",confirm_delete_object:"Delete this object and all its tasks?",confirm_delete_task:"Delete this task?",min:"Min",max:"Max",save:"Save",saving:"Saving\u2026",edit_task:"Edit Task",new_task:"New Maintenance Task",task_name:"Task name",maintenance_type:"Maintenance type",priority:"Priority",labels:"Labels",labels_placeholder:"e.g. safety, seasonal, tenant-visible",labels_help:"Comma-separated tags for filtering and reporting.",priority_low:"Low",priority_normal:"Normal",priority_high:"High",schedule_type:"Schedule type",interval_days:"Interval (days)",warning_days:"Warning days",earliest_completion_days:"Earliest completion (days before due)",earliest_completion_days_help:"Leave empty to allow completing any time. 0 = only on/after the due date.",last_performed_optional:"Last performed (optional)",interval_anchor:"Interval anchor",anchor_completion:"From completion date",anchor_planned:"From planned date (no drift)",edit_object:"Edit Object",name:"Name",manufacturer_optional:"Manufacturer (optional)",model_optional:"Model (optional)",serial_number_optional:"Serial number (optional)",serial_number_label:"S/N",documentation_url_label:"Manual",object_notes_label:"Notes",sort_due_date:"Due date",sort_object:"Object name",sort_type:"Type",sort_task_name:"Task name",all_objects:"All objects",all_parts:"All parts",tasks_lower:"tasks",no_tasks_yet:"No tasks yet",add_first_task:"Add first task",trigger_configuration:"Trigger Configuration",entity_id:"Entity ID",comma_separated:"comma-separated",entity_logic:"Entity logic",entity_logic_any:"Any entity triggers",entity_logic_all:"All entities must trigger",entities:"entities",attribute_optional:"Attribute (optional, blank = state)",use_entity_state:"Use entity state (no attribute)",trigger_above:"Trigger above",trigger_below:"Trigger below",for_at_least_minutes:"For at least (minutes)",safety_interval_days:"Safety interval (days, optional)",safety_interval:"Safety interval (optional)",delta_mode:"Delta mode",from_state_optional:"From state (optional)",to_state_optional:"To state (optional)",documentation_url_optional:"Documentation URL (optional)",object_notes_optional:"Notes (optional)",nfc_tag_id_optional:"NFC Tag ID (optional)",nfc_tags_empty_help:"No NFC tags registered in Home Assistant yet.",nfc_tags_open_settings:"Open Tags settings",nfc_tags_refresh:"Refresh",environmental_entity_optional:"Environmental sensor (optional)",environmental_entity_helper:"e.g. sensor.outdoor_temperature \u2014 adjusts the interval based on environmental conditions",adaptive_prediction_enabled:"Enable sensor-driven predictions",adaptive_seasonal_enabled:"Enable seasonal awareness",adaptive_max_interval:"Maximum interval (days)",adaptive_min_interval:"Minimum interval (days)",adaptive_ewa_alpha:"Learning rate (alpha)",adaptive_enabled:"Enable adaptive scheduling",adaptive_section_title:"Adaptive Scheduling",environmental_attribute_optional:"Environmental attribute (optional)",nfc_tag_id:"NFC Tag ID",nfc_linked:"NFC tag linked",nfc_link_hint:"Click to link NFC tag",responsible_user:"Responsible User",shared_with:"Shared with (rotation)",shared_with_help:"Pick multiple people to share this task; the responsible person rotates on each completion.",rotation_strategy:"Rotation",rotation_none:"No rotation",rotation_round_robin:"Round-robin",rotation_least_completed:"Least completed",rotation_random:"Random",no_user_assigned:"(No user assigned)",all_users:"All Users",my_tasks:"My Tasks",tab_calendar:"Calendar",cal_no_events:"No maintenance",cal_window_7:"7 days",cal_window_14:"14 days",cal_window_30:"30 days",cal_window_365:"1 year",cal_every_n_days:"every {n} days",cal_source_time:"Time-based",cal_source_time_adaptive:"Time-based (adaptive)",cal_source_sensor:"Sensor-based",cal_predicted:"predicted",cal_confidence_high:"high confidence",cal_confidence_medium:"medium confidence",cal_confidence_low:"low confidence",budget_monthly:"Monthly budget",budget_yearly:"Yearly budget",groups:"Groups",new_group:"New group",edit_group:"Edit group",no_groups:"No groups yet",delete_group:"Delete group",delete_group_confirm:"Delete group '{name}'?",group_select_tasks:"Select tasks",group_name_required:"Name is required",description_optional:"Description (optional)",selected:"Selected",loading_chart:"Loading chart data...",hide_outliers:"Hide outliers (sensor glitches)",was_maintenance_needed:"Was this maintenance needed?",feedback_needed:"Needed",feedback_not_needed:"Not needed",feedback_not_sure:"Not sure",suggested_interval:"Suggested interval",apply_suggestion:"Apply",reanalyze:"Re-analyze",reanalyze_result:"New analysis",reanalyze_insufficient_data:"Not enough data to produce a recommendation",data_points:"data points",dismiss_suggestion:"Dismiss",confidence_low:"Low",confidence_medium:"Medium",confidence_high:"High",recommended:"recommended",seasonal_awareness:"Seasonal Awareness",edit_seasonal_overrides:"Edit seasonal factors",seasonal_overrides_title:"Seasonal factors (override)",seasonal_overrides_hint:"Factor per month (0.1\u20135.0). Empty = learned automatically.",seasonal_override_invalid:"Invalid value",seasonal_override_range:"Factor must be between 0.1 and 5.0",clear_all:"Clear all",seasonal_chart_title:"Seasonal Factors",seasonal_learned:"Learned",seasonal_manual:"Manual",month_jan:"Jan",month_feb:"Feb",month_mar:"Mar",month_apr:"Apr",month_may:"May",month_jun:"Jun",month_jul:"Jul",month_aug:"Aug",month_sep:"Sep",month_oct:"Oct",month_nov:"Nov",month_dec:"Dec",sensor_prediction:"Sensor Prediction",degradation_trend:"Trend",trend_rising:"Rising",trend_falling:"Falling",trend_stable:"Stable",trend_insufficient_data:"Insufficient data",days_until_threshold:"Days until threshold",threshold_exceeded:"Threshold exceeded",environmental_adjustment:"Environmental factor",sensor_prediction_urgency:"Sensor predicts threshold in ~{days} days",day_short:"day",weibull_reliability_curve:"Reliability Curve",weibull_failure_probability:"Failure Probability",weibull_r_squared:"Fit R\xB2",beta_early_failures:"Early Failures",beta_random_failures:"Random Failures",beta_wear_out:"Wear-out",beta_highly_predictable:"Highly Predictable",confidence_interval:"Confidence Interval",confidence_conservative:"Conservative",confidence_aggressive:"Optimistic",current_interval_marker:"Current interval",recommended_marker:"Recommended",characteristic_life:"Characteristic life",chart_mini_sparkline:"Trend sparkline",chart_history:"Cost and duration history",chart_seasonal:"Seasonal factors, 12 months",chart_weibull:"Weibull reliability curve",chart_sparkline:"Sensor trigger value chart",days_progress:"Days progress",qr_code:"QR Code",qr_generating:"Generating QR code\u2026",qr_error:"Failed to generate QR code.",qr_error_no_url:"No HA URL configured. Please set an external or internal URL in Settings \u2192 System \u2192 Network.",save_error:"Failed to save. Please try again.",qr_print:"Print",qr_download:"Download SVG",qr_action:"Action on scan",qr_action_view:"View maintenance info",qr_action_complete:"Mark maintenance as complete",qr_url_mode:"Link type",qr_mode_companion:"Companion App",qr_mode_local:"Local (mDNS)",qr_mode_server:"Server URL",overview:"Overview",analysis:"Analysis",recent_activities:"Recent Activities",search_notes:"Search notes",avg_cost:"Avg Cost",no_advanced_features:"No advanced features enabled",no_advanced_features_hint:"Enable \u201CAdaptive Intervals\u201D or \u201CSeasonal Patterns\u201D in the integration settings to see analysis data here.",analysis_not_enough_data:"Not enough data for analysis yet.",analysis_not_enough_data_hint:"Weibull analysis requires at least 5 completed maintenances; seasonal patterns become visible after 6+ data points per month.",analysis_manual_task_hint:"Manual tasks without an interval do not generate analysis data.",completions:"completions",current:"Current",shorter:"Shorter",longer:"Longer",normal:"Normal",disabled:"Disabled",compound_logic:"Compound logic",compound:"Compound (multiple conditions)",compound_logic_and:"AND \u2014 all conditions must trigger",compound_logic_or:"OR \u2014 any condition triggers",compound_help:"Combine several sensor conditions into one trigger.",compound_no_conditions:"No conditions yet \u2014 add at least one.",compound_add_condition:"Add condition",compound_condition:"Condition",compound_remove_condition:"Remove condition",card_title:"Title",card_show_header:"Show header with statistics",card_show_actions:"Show action buttons",card_compact:"Compact mode",card_max_items:"Max items (0 = all)",card_filter_status:"Filter by status",card_filter_status_help:"Empty = show all statuses.",card_filter_objects:"Filter by objects",card_filter_objects_help:"Empty = show all objects.",card_filter_areas:"Filter by areas",card_filter_areas_help:"Empty = show all areas.",card_filter_entities:"Filter by entities (entity_ids)",card_filter_entities_help:"Pick sensor / binary_sensor entities from this integration. Empty = all.",card_loading_objects:"Loading objects\u2026",card_load_error:"Could not load objects \u2014 check the WebSocket connection.",card_no_tasks_title:"No maintenance tasks yet",card_no_tasks_cta:"\u2192 Create one in the Maintenance panel",no_objects:"No objects yet.",action_error:"Action failed. Please try again.",area_id_optional:"Area (optional)",installation_date_optional:"Installation date (optional)",warranty_expiry_optional:"Warranty expiry (optional)",warranty:"Warranty",warranty_valid_until:"valid until {date}",warranty_expires_in:"expires in {days} days",warranty_expired:"expired",cal_past_windows:"Past windows",cal_forward_windows:"Forward windows",history_edit_title:"Edit history entry",history_edit_timestamp:"Timestamp",manufacturer:"Manufacturer",model:"Model",area:"Area",actions:"Actions",view_mode_label:"View",view_cards:"Card view",view_table:"Table view",objects_table_columns_label:"Objects table columns",objects_table_columns_hint:"Choose which columns appear in the objects table view.",custom_icon_optional:"Icon (optional, e.g. mdi:wrench)",task_enabled:"Task enabled",skip_reason_prompt:"Skip this task?",reason_optional:"Reason (optional)",reset_date_prompt:"Mark task as performed?",reset_date_optional:"Last performed date (optional, defaults to today)",notes_label:"Notes",documentation_label:"Documentation",no_nfc_tag:"\u2014 No tag \u2014",dashboard:"Dashboard",tab_today:"Today",palette_placeholder:"Search objects and tasks\u2026",palette_no_results:"No matches",palette_hint:"\u2191\u2193 to navigate \xB7 Enter to open \xB7 Esc to close",today_all_caught_up:"All caught up! Nothing due this week.",today_overdue:"Overdue",today_due_today:"Due today",today_this_week:"This week",settings:"Settings",settings_features:"Advanced Features",settings_features_desc:"Enable or disable advanced features. Disabling hides them from the UI but does not delete data.",feat_adaptive:"Adaptive Scheduling",feat_adaptive_desc:"Learn optimal intervals from maintenance history",feat_predictions:"Sensor Predictions",feat_predictions_desc:"Predict trigger dates from sensor degradation",feat_seasonal:"Seasonal Adjustments",feat_seasonal_desc:"Adjust intervals based on seasonal patterns",feat_environmental:"Environmental Correlation",feat_environmental_desc:"Correlate intervals with temperature/humidity",feat_budget:"Budget Tracking",feat_budget_desc:"Track monthly and yearly maintenance spending",feat_groups:"Task Groups",feat_groups_desc:"Organize tasks into logical groups",feat_checklists:"Checklists",feat_checklists_desc:"Multi-step procedures for task completion",settings_general:"General",settings_default_warning:"Default warning days",settings_panel_enabled:"Sidebar panel",settings_panel_title:"Sidebar panel title",settings_notifications:"Notifications",settings_notify_service:"Notification service",settings_install_assist_sentences:"Install Assist sentences",settings_install_assist_sentences_hint:"Copies the voice sentences into your configuration so the classic Assist agent recognises them. A file you edited yourself is never overwritten.",test_notification:"Test notification",send_test:"Send test",testing:"Sending\u2026",test_notification_success:"Test notification sent",test_notification_failed:"Test notification failed",notify_per_person:"Per-person delivery",notify_no_own_device:"No own device \u2014 uses the household service",settings_notify_due_soon:"Notify when due soon",settings_notify_overdue:"Notify when overdue",settings_notify_triggered:"Notify when triggered",settings_interval_hours:"Repeat interval (hours, 0 = once)",settings_quiet_hours:"Quiet hours",settings_quiet_start:"Start",settings_quiet_end:"End",settings_max_per_day:"Max notifications per day (0 = unlimited)",settings_bundling:"Bundle notifications",settings_bundle_threshold:"Bundle threshold",settings_reminder_leads:"Extra reminders (days before due)",settings_reminder_leads_hint:"Comma-separated lead times, e.g. 14, 3, 0 \u2014 one extra reminder fires on each matching day. Empty = off.",settings_actions:"Mobile Action Buttons",settings_action_complete:"Show 'Complete' button",settings_action_skip:"Show 'Skip' button",settings_action_snooze:"Show 'Snooze' button",settings_weekly_digest:"Weekly digest",settings_weekly_digest_hint:"A single summary notification on Monday morning when tasks are due.",settings_warranty_reminder:"Warranty expiry reminder",settings_warranty_reminder_days:"Days before expiry",settings_warranty_reminder_hint:"Notify once when an object's warranty is this many days from expiring.",settings_snooze_hours:"Snooze duration (hours)",settings_budget:"Budget",settings_currency:"Currency",settings_budget_monthly:"Monthly budget",settings_budget_yearly:"Yearly budget",settings_budget_alerts:"Budget alerts",settings_budget_threshold:"Alert threshold (%)",settings_import_export:"Import / Export",settings_export_json:"Export JSON",settings_export_yaml:"Export YAML",settings_export_csv:"Export CSV",settings_export_settings:"Export settings (JSON)",settings_import_csv:"Import CSV",settings_import_placeholder:"Paste JSON or CSV content here\u2026",settings_import_btn:"Import",settings_import_success:"{count} objects imported successfully.",settings_export_success:"Export downloaded.",settings_saved:"Setting saved.",settings_include_history:"Include history",settings_export_selection:"Limit to selected objects (optional)",settings_docs_archive:"Documents archive (with files)",settings_docs_archive_hint:"The JSON/YAML/CSV exports carry settings only. This ZIP includes the uploaded file contents so a restore is complete.",settings_docs_export_btn:"Download documents ZIP",settings_docs_import_btn:"Restore documents ZIP",settings_docs_import_success:"Restored: {blobs} files, {docs} documents",sort_alphabetical:"Alphabetical",sort_due_soonest:"Due soonest",sort_task_count:"Task count",sort_area:"Area",sort_assigned_user:"Assigned user",sort_group:"Group",groupby_none:"No grouping",groupby_area:"By area",groupby_group:"By group",groupby_user:"By user",filter_label:"Filter",user_label:"User",photo_label:"Photo",sort_label:"Sort",group_by_label:"Group by",state_value_help:'Use the HA state value (usually lowercase, e.g. "on"/"off"). Case is normalised on save.',target_changes_help:"Number of matching transitions before the trigger fires (default: 1).",qr_print_title:"Print QR codes",qr_print_desc:"Generate a printable page of QR codes to cut out and stick on your equipment.",qr_print_load:"Load objects",qr_print_filter:"Filter",qr_print_objects:"Objects",qr_print_actions:"Actions",qr_print_url_mode:"Link type",qr_print_estimate:"Estimated QR codes",qr_print_over_limit:"cap is 200, narrow the filter",qr_print_generate:"Generate QR codes",qr_print_generating:"Generating\u2026",qr_print_ready:"QR codes ready",qr_print_print_button:"Print",qr_print_empty:"Nothing to generate",qr_action_skip:"Skip",vacation_title:"Vacation mode",vacation_active:"active",vacation_ended:"ended",vacation_desc:"Plan a vacation: notifications are paused during the period plus a buffer of days. You can opt specific tasks back in.",vacation_enable:"Enable vacation mode",vacation_start:"Start",vacation_end:"End",vacation_buffer:"Buffer (days)",vacation_exempt_title:"Notify anyway during vacation",vacation_exempt_desc:"Pick tasks that should still notify during vacation (e.g. critical pool chemistry).",vacation_load_tasks:"Load tasks",vacation_preview_btn:"Show preview",vacation_preview_affected:"tasks affected",vacation_event_due_soon:"becomes due soon",vacation_event_overdue:"becomes overdue",vacation_event_triggered_est:"sensor trigger possible",vacation_sensor_based:"(sensor-based)",vacation_action_notify:"Notify anyway",vacation_action_unsilence:"Silence again",vacation_marked_complete:"Marked complete",vacation_marked_skip:"Skipped",vacation_end_now:"End vacation now",add:"Add",show_stats:"Show stats + graphs",hide_stats:"Hide stats",adaptive_no_data:"Not enough completion history yet for adaptive analysis. Complete this task a few more times to unlock interval recommendations and reliability charts.",suggestion_applied:"Suggested interval applied",vacation_mode:"Vacation mode",vacation_status_active:"Active now",vacation_status_scheduled:"Scheduled",vacation_status_inactive:"Inactive",vacation_end_now_confirm:"End vacation immediately?",vacation_exempt_count:"exempt",vacation_advanced:"Advanced\u2026",vacation_open_panel:"Open in panel",enable:"Enable",saved:"Saved",budget_monthly_set:"Set monthly",budget_yearly_set:"Set yearly",budget_advanced:"Currency, alerts\u2026",budget_open_panel:"Open in panel",groups_empty:"No groups yet.",group_new_placeholder:"Add group\u2026",group_delete_confirm:'Delete group "{name}"?',groups_manage_tasks:"Manage task assignments\u2026",groups_open_panel:"Open in panel",unassigned:"Unassigned",no_area:"No area",has_overdue:"Has overdue tasks",object:"Object",settings_panel_access:"Panel access",settings_panel_access_desc:"Admins always have full access. To delegate create, edit and delete to specific non-admins, switch this on and pick them below \u2014 everyone else sees only Complete and Skip.",settings_operator_write:"Allow selected users to create, edit & delete",settings_operator_write_desc:"Off: only admins can change content. On: the selected users below get full access too.",no_non_admin_users:"No non-admin users found. Add some in Settings \u2192 People.",owner_label:"Owner",feat_completion_actions:"Completion actions",feat_completion_actions_desc:"Per-task HA action on complete + quick-complete QR with pre-set values.",on_complete_action_title:"On complete: trigger HA action (optional)",on_complete_action_desc:"Calls an HA service when the task is completed \u2014 e.g. reset a counter on the device.",on_complete_action_service:"Service",on_complete_action_target:"Target entity",on_complete_action_target_hint:"Note: the entity domain must match the service \u2014 e.g. 'button.press' only works on button.*, 'counter.increment' only on counter.*, 'input_button.press' only on input_button.* etc. On a mismatch the action will silently fail (HA logs 'Referenced entities ... missing or not currently available').",on_complete_action_data:"Data (JSON, optional)",on_complete_action_test:"Validate configuration",on_complete_action_test_success:"\u2713 Configuration valid (action will fire only on task completion)",on_complete_action_test_failed:"Failed",quick_complete_defaults_title:"Quick-complete defaults (for QR scans, optional)",quick_complete_defaults_desc:"Pre-set values for quick-complete QR scans. Without these, the QR opens the complete dialog.",quick_complete_defaults_notes:"Notes",quick_complete_defaults_cost:"Cost",quick_complete_defaults_duration:"Duration (minutes)",quick_complete_defaults_feedback_none:"No feedback",quick_complete_defaults_feedback_needed:"Was needed",quick_complete_defaults_feedback_not_needed:"Not needed",quick_complete_success:"Quickly marked complete",show_all_objects:"Show all objects",show_all_tasks:"Clear filter \u2014 show all tasks",filter_to_overdue:"Filter task list to overdue only",filter_to_due_soon:"Filter task list to due-soon only",filter_to_triggered:"Filter task list to triggered only",open_task:"Open task",show_details:"Show history + stats",hide_details:"Hide details",history_empty:"No history yet.",history_edit_button:"Edit entry",total_cost:"Total cost",times_performed:"Performed",older_entries:"older",open_in_panel:"Open in Maintenance panel",skip_reason:"Skip reason (optional)",reset_to_date:"Reset last_performed to",delete_task_confirm:"Delete this task and its history?",delete_object_confirm:"Delete this object and all its tasks?",loading:"Loading\u2026",archive:"Archive",undo:"Undo",task_archived:"Task archived",object_archived:"Object archived",unarchive:"Unarchive",archived:"Archived",show_archived:"Show archived",hide_archived:"Hide archived",confirm_archive_object:"Archive this object and its tasks? They keep their history and can be unarchived later.",settings_archive:"Archive & Retention",settings_archive_desc:"Retire completed one-off tasks without deleting them. Archived items are hidden and inert but keep their history and cost.",settings_archive_oneoff_days:"Auto-archive completed one-off tasks after (days, 0 = off)",settings_delete_archived_oneoff_days:"Auto-delete archived one-off tasks after (days, 0 = never)",archive_object:"Archive object",unarchive_object:"Unarchive object",documents:"Documents",documents_empty:"No documents yet.",doc_upload:"Upload file",doc_uploading:"Uploading\u2026",doc_add_link:"Add link",doc_link_url:"URL (https://\u2026)",doc_link_title:"Title (optional)",doc_open:"Open",doc_delete_confirm:'Delete "{name}"?',doc_too_large:"File is too large (max 25 MB).",doc_upload_failed:"Upload failed.",completion_photo_optional:"Completion photo (optional)",add_photo:"Add photo",uploading:"Uploading\u2026",remove:"Remove",doc_deduped:"Already stored elsewhere \u2014 shared, no extra space used.",doc_dup_in_object:"This file is already attached to this object.",doc_link_invalid:"Only http/https links are allowed.",doc_cat_manual:"Manual",doc_cat_warranty:"Warranty",doc_cat_invoice:"Invoice",doc_cat_spare_parts:"Spare parts",doc_cat_photo:"Photo",doc_cat_other:"Other",doc_link_badge:"Link",doc_storage_title:"Document storage",doc_storage_saved:"Saved via deduplication",doc_storage_refresh:"Refresh",doc_download:"Download",doc_close:"Close",doc_camera:"Take photo",doc_drop_hint:"Drop files here",doc_task_none:"No documents linked to this task.",doc_link_existing:"Link a document\u2026",doc_attach:"Link",doc_unlink:"Unlink",doc_page:"Page",chart_range_7d:"7d",chart_range_30d:"30d",chart_range_90d:"90d",chart_range_1y:"1y",chart_since_service:"since last service",chart_no_stats:"No long-term statistics for this entity \u2014 showing maintenance-event values only",auto_complete_on_recovery:"Auto-complete when the sensor recovers",auto_complete_on_recovery_help:"Records a completion (sets last performed) when the trigger clears itself \u2014 e.g. salt refilled, filter replaced.",doc_search:"Search documents\u2026",doc_search_none:"No matching documents",link_device_optional:"Link to existing device (optional)",parent_object_optional:"Parent object (optional)",parent_none:"(No parent)",paused:"Paused",pause_object:"Pause",resume_object:"Resume",pause_until_prompt:"Freeze this object's schedules \u2014 nothing becomes due and nothing notifies until it is resumed. Optionally set an auto-resume date.",pause_until_label:"Resume on (optional)",object_paused:"Object paused",object_resumed:"Object resumed \u2014 schedules restarted",object_paused_badge:"Paused",paused_until_label:"until",replace_object:"Replace\u2026",replace_object_prompt:"Retire this object and create a successor. History and costs stay archived on the old one; tasks and documents carry over to the new one, counters start fresh.",replace_name_label:"Successor name",object_replaced:"Object replaced \u2014 successor created",reading_unit_label:"Reading unit (e.g. kWh, m\xB3)",reading_unit_help:"Shown next to the recorded value when completing this task.",reading_value_label:"Reading value",reading_label:"Reading",settings_templates_label:"Template gallery",settings_templates_hint:`Untick templates you'll never need \u2014 they disappear from the "From template" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.`,worksheet:"Work sheet",worksheet_scan_view:"Scan to open the task",worksheet_scan_complete:"Scan to complete",worksheet_manual_excerpt:"Manual excerpt",worksheet_pages:"pages",worksheet_printed:"Printed",worksheet_never:"Never",card_all_caught_up:"All caught up \u2014 nothing needs attention",postpone:"Postpone",postpone_date_prompt:"Postpone this occurrence to which date?",postpone_date_label:"New due date",postponed:"Postponed",postponed_to:"Postponed to",season_window_label:"Seasonal window (months)",season_window_hint:"Only due in the selected months; off-season dates roll to the next active month. None = all year.",series_end_label:"Ends",series_end_never:"Never (repeats indefinitely)",series_end_after_count:"After a number of times",series_end_until:"On a date",series_end_count_label:"Number of times",series_end_until_label:"End date",parts_section:"Parts & consumables",parts_inventory_value:"Inventory value",part_add:"Add part",part_name:"Name",part_vendor:"Manufacturer",part_storage_location:"Storage location",part_product_url:"Product URL",part_unit:"Unit",part_cost:"Unit price",part_stock:"Stock",part_reorder_threshold:"Reorder at",part_restock_quantity:"Restock quantity",part_auto_buy:"Auto-create buy task when low",part_restock:"Adjust stock",parts_used_by:"Used by",restock_quantity_label:"Quantity bought",consumes_parts_label:"Consumes parts",shared_parts_other_objects:"Parts from other objects",shared_parts_help:"Several objects can share one stock. Completing this task takes from the owning object.",shared_part_unknown:"Unknown part",parts_load_failed:"Couldn't load this object's parts \u2014 the consumes-parts options are unavailable right now.",adopt_problem_button:"Adopt problem sensors",adopt_problem_title:"Adopt problem sensors",adopt_problem_hint:"Turn HA problem sensors (printer errors, filter warnings, low battery) into maintenance tasks that trigger while the problem is active and clear themselves when it resolves.",adopt_problem_none:"No problem sensors found that aren't already tracked.",adopt_problem_active:"active",adopt_problem_ok:"ok",adopt_problem_new_object:"(new)",adopt_problem_adopt:"Adopt selected",adopt_problem_done:"Adopted {tasks} problem sensor(s)",views_label:"Views",views_none:"\u2014 No view \u2014",views_manage:"Save / manage views",views_dialog_title:"Saved views",views_dialog_hint:"Save the current filters as a named view everyone can reuse.",views_name_placeholder:"View name",views_save_current:"Save current filters",views_none_yet:"No saved views yet.",close:"Close",trigger_hint_now:"The sensor reads {value} right now.",trigger_hint_above:"The task triggers once it rises above {target}.",trigger_hint_below:"It triggers once it falls below {target}.",trigger_hint_counter_delta:"Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",trigger_hint_counter_delta_edit:"Counts usage since the last completion: due after +{target}; the count restarts after each completion.",trigger_hint_counter_abs:"The task becomes due once the sensor reaches {target}.",trigger_hint_runtime:"The task becomes due after {hours} h of accumulated on-time; the counter restarts after each completion.",trigger_hint_state_change:"The task becomes due after {count} state change(s).",trigger_hint_state_change_to:"The task becomes due after {count} change(s) to \u201C{state}\u201D.",trigger_hint_state_now:"Current state: {value}.",adopt_problem_part:"Uses part: {name}",label_filter:"Label",all_labels:"All labels",settings_notify_scope:"Notify only for view",settings_notify_scope_all:"All tasks",settings_notify_scope_hint:"Only tasks matching the selected saved view's label/user filters send reminders. Status, sorting and grouping of the view are ignored here.",card_saved_view:"Saved view",card_saved_view_none:"None",card_saved_view_help:"Applies the view's status, user and label filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",doc_part_none:"No documents linked to this part.",settings_templates_toggle_group:"Enable or disable all templates in this group",setups_button:"Suggested setups",setups_title:"Suggested setups (Beta)",setups_hint:"Devices of supported integrations whose consumable sensors can drive maintenance tasks. Adopting creates the object and wires each task to its sensor \u2014 it triggers when the consumable runs low and resolves itself after replacement.",setups_none:"No supported devices with unwired consumable sensors found.",setups_adopt:"Set up selected",setups_done:"{tasks} sensor-wired tasks created.",complete_parts_used:"Parts used this time",part_delete_confirm:"Delete part '{name}'? Its stock tracking, task links and any open buy reminder will be removed.",baseline_start_value:"Start reading (optional)",baseline_start_help:"Counting starts from this reading. Leave empty to count from the current value; enter the reading at the last service so usage since then already counts.",setups_baseline_hint:"reading at last service (optional)",baseline_start_help_edit:"Leave empty to keep the existing counting. Entering a value re-anchors the counting (e.g. the reading at the last service).",baseline_current_effective:"Currently effective start value: {value}",runtime_on_states:"Active states",runtime_on_states_help:"States that count as running \u2014 default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",setups_target_new:"Create new: {name}",schedule_preview_title:"Next dates",schedule_preview_ontime:"Assuming on-time completion.",schedule_preview_ends:"(series ends)",adopt_problem_responsible:"Responsible user for all adopted tasks (optional)",adopt_problem_configure:"Configure",history_auto:"Automatic",battery_fleet_title:"Battery fleet",battery_fleet_none_low:"All batteries OK \u2014 nothing to replace.",battery_fleet_buy_now:"Buy now",battery_fleet_soon:"Needed soon",battery_fleet_soon_hint:"Predicted from the last replacement date \u2014 order ahead.",battery_fleet_mark_all:"Mark all replaced",battery_fleet_mark_one:"Mark this battery replaced",battery_fleet_offline:"offline",battery_fleet_trigger_lost:"This task's sensor trigger was lost \u2014 it will not fire or auto-complete.",battery_fleet_repair:"Repair",battery_fleet_exclude:"Exclude from the fleet",battery_fleet_excluded:"Excluded",battery_fleet_include:"Track again",battery_fleet_all:"All tracked batteries",battery_fleet_all_hint:"Exclude a device here to drop it from the fleet before it ever reports low \u2014 a vacuum that recharges itself, or a phone that warns you on its own.",battery_fleet_status_low:"Low",battery_fleet_status_soon:"Soon",battery_fleet_status_ok:"Healthy",battery_fleet_predicted_on:"Expected around {date}",battery_fleet_predicted_trend:"Predicted from this battery's discharge trend: around {date} ({confidence})",battery_fleet_rechargeable:"Rechargeable: charge instead of replacing \u2014 never on the shopping list",battery_fleet_sort_name:"Sort by name",battery_fleet_sort_urgency:"Sort by urgency",battery_fleet_mark_recharged:"Mark as recharged",battery_fleet_sparkline_hint:"Battery level over the last 30 days \u2014 dotted: projected until the low threshold",battery_fleet_filter_type:"Show only this battery type",battery_fleet_record_replacement:"The level jumped around {date} \u2014 record this replacement in Battery Notes",battery_fleet_total:"{n} batteries tracked",battery_fleet_setup_button:"Battery fleet",battery_fleet_setup_done:"Battery fleet set up \u2014 one task tracks all your batteries.",update_banner:"A newer version of Maintenance Supporter is on the server \u2014 reload to update the panel.",update_reload:"Reload",battery_fleet_forecast_overdue:"Predicted date passed \u2014 the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",cost_from_parts:"Use \u2248 {amount} from parts",dismiss:"Dismiss",gs_label:"Getting started \u2014 these hints retire as your setup grows",gs_setups_chip:"Suggested setups found {n} devices with pre-wired triggers",gs_adopt_chip:"{n} problem sensors can become maintenance tasks",gs_fleet_chip:"One click sets up the battery fleet"};var Ve="\u20AC",xe="en",Ke=(()=>{let a=window;return a.__msLocales||(a.__msLocales={store:{},inflight:{}}),a.__msLocales})(),N=Ke.store;N.en||(N.en=Ye);var Dt=new Set(["de","nl","fr","it","es","pt","pt-br","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi","hu","ko","tr"]),Nt="/maintenance_supporter_locales",Z=Ke.inflight;function we(a){let e=(a||xe).toLowerCase();return e.startsWith("pt")&&e.endsWith("br")?"pt-br":e.substring(0,2)}function f(a,e){let t=we(e);return N[t]?.[a]??N.en[a]??a}function Qe(a){return a?.language||"en"}function Je(a){let e=we(a);return e===xe||e in N}function Ze(a){let e=we(a);return e===xe||e in N||!Dt.has(e)?Promise.resolve():(e in Z||(Z[e]=fetch(`${Nt}/${e}.json`).then(t=>t.ok?t.json():null).then(t=>{t?N[e]=t:delete Z[e]}).catch(()=>{delete Z[e]})),Z[e])}var Rt=window,Ge=Rt.__msDateTimePrefs??={};function Xe(a){a&&(Ge.date=a.date_format,Ge.time=a.time_format)}function et(a,e){if(a==null)return"\u2014";let t=e||"en";return a<0?`${Math.abs(a)} ${f("d_overdue",t)}`:a===0?f("today",t):`${a} ${f(a===1?"day":"days",t)}`}var Uo=k` .field { display: flex; flex-direction: column; gap: 4px; } .field-label { font-size: 12px; color: var(--secondary-text-color); } .field-input { @@ -1354,32 +1354,32 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .stat-item .stat-label { font-size: 11px; white-space: normal; text-align: center; line-height: 1.2; } .stat-value { font-size: 20px; } } -`;var v=class extends ${constructor(){super(...arguments);this._config={type:"custom:maintenance-supporter-calendar-card"};this._objects=[];this._stats=null;this._windowDays=30;this._pastDays=0;this._userFilter="";this._objectFilter="";this._configuredObjects=[];this._unsub=null;this._dataLoaded=!1;this._lastConnection=null}static getConfigElement(){return document.createElement("maintenance-supporter-calendar-card-editor")}static getStubConfig(){return{type:"custom:maintenance-supporter-calendar-card",window_days:30,show_window_chips:!0,show_user_filter:!0}}setConfig(t){if(this._config={...t},t.past_days&&[30,90].includes(t.past_days)?this._pastDays=t.past_days:t.window_days&&[7,14,30,365].includes(t.window_days)&&(this._windowDays=t.window_days,this._pastDays=0),typeof t.user_filter=="string"&&(this._userFilter=t.user_filter),typeof t.object_filter=="string")this._objectFilter=t.object_filter,this._configuredObjects=[];else if(Array.isArray(t.object_filter)){let o=t.object_filter.filter(r=>typeof r=="string"&&r!=="");this._objectFilter=o.length===1?o[0]:"",this._configuredObjects=o.length>1?o:[]}}getCardSize(){return 6}get _lang(){return this.hass?.language||"en"}disconnectedCallback(){if(super.disconnectedCallback(),this._unsub){try{this._unsub()}catch{}this._unsub=null}this._dataLoaded=!1,this._lastConnection=null}updated(t){super.updated(t),t.has("hass")&&Xe(this.hass?.locale);let o=this.hass?.language;if(o&&!Je(o)&&Ze(o).then(()=>this.requestUpdate()),t.has("hass")&&this.hass){if(!this._dataLoaded)this._dataLoaded=!0,this._lastConnection=this.hass.connection,this._loadData(),this._subscribe();else if(this.hass.connection!==this._lastConnection){if(this._lastConnection=this.hass.connection,this._unsub){try{this._unsub()}catch{}this._unsub=null}this._subscribe(),this._loadData()}}}async _loadData(){try{let[t,o]=await Promise.all([this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/statistics"})]);this._objects=t.objects,this._stats=o}catch{}}async _subscribe(){try{let t=await this.hass.connection.subscribeMessage(o=>{let r=o;this._objects=r.objects},{type:"maintenance_supporter/subscribe"});if(!this.isConnected){t();return}this._unsub=t}catch{}}_onEventClick(t){if(t.history_timestamp){this.dispatchEvent(new CustomEvent("ll-custom",{detail:{type:"maintenance-supporter:edit-history",entry_id:t.entry_id,task_id:t.task_id,original_timestamp:t.history_timestamp},bubbles:!0,composed:!0}));return}this.dispatchEvent(new CustomEvent("ll-custom",{detail:{type:"maintenance-supporter:open-task",entry_id:t.entry_id,task_id:t.task_id},bubbles:!0,composed:!0}))}render(){if(!this.hass)return g;let t=this._lang,o=this._config.show_window_chips!==!1,r=this._config.show_user_filter!==!1,n=this._config.title,s=null;this._userFilter&&(s=this._userFilter==="current_user"?this.hass?.user?.id??null:this._userFilter);let l=i=>{let j=i.toLowerCase();return this._objects.find(P=>P.entry_id===i||P.object.name.toLowerCase()===j)?.entry_id??null},d=new Set(this._configuredObjects.map(l).filter(i=>i!==null)),p=d.size?this._objects.filter(i=>d.has(i.entry_id)):this._objects,u=this._config.show_object_filter!==!1&&p.length>1,c=this._objectFilter?l(this._objectFilter):null,_=c&&p.some(i=>i.entry_id===c)?p.filter(i=>i.entry_id===c):p,h=new Date;h.setHours(0,0,0,0);let b=this._pastDays>0,y=b?We(_,h,this._pastDays,s):Be(_,h,this._windowDays,s),R=J(h),M=this._windowDays===365||b,U=M?y.filter(i=>i.events.length>0):y,rt=i=>{let j=`cal-status-${i.status}`,X=i.projected?"cal-event-projected":"",P=i.status==="overdue"&&i.days_until_due!=null?` (${et(i.days_until_due,t)})`:"",H=i.projected&&i.interval_days?m`${i.interval_unit&&i.interval_unit!=="days"?`${i.interval_days} ${f("unit_"+i.interval_unit,t)}`:f("cal_every_n_days",t).replace("{n}",String(i.interval_days))}`:g,I=i.schedule_type==="sensor_based",le=I?m`t.type===a.type)||e.customCards.push(a)}var v=class extends ${constructor(){super(...arguments);this._config={type:"custom:maintenance-supporter-calendar-card"};this._objects=[];this._stats=null;this._windowDays=30;this._pastDays=0;this._userFilter="";this._objectFilter="";this._configuredObjects=[];this._unsub=null;this._dataLoaded=!1;this._lastConnection=null}static getConfigElement(){return document.createElement("maintenance-supporter-calendar-card-editor")}static getStubConfig(){return{type:"custom:maintenance-supporter-calendar-card",window_days:30,show_window_chips:!0,show_user_filter:!0}}setConfig(t){if(this._config={...t},t.past_days&&[30,90].includes(t.past_days)?this._pastDays=t.past_days:t.window_days&&[7,14,30,365].includes(t.window_days)&&(this._windowDays=t.window_days,this._pastDays=0),typeof t.user_filter=="string"&&(this._userFilter=t.user_filter),typeof t.object_filter=="string")this._objectFilter=t.object_filter,this._configuredObjects=[];else if(Array.isArray(t.object_filter)){let o=t.object_filter.filter(r=>typeof r=="string"&&r!=="");this._objectFilter=o.length===1?o[0]:"",this._configuredObjects=o.length>1?o:[]}}getCardSize(){return 6}get _lang(){return Qe(this.hass)}disconnectedCallback(){if(super.disconnectedCallback(),this._unsub){try{this._unsub()}catch{}this._unsub=null}this._dataLoaded=!1,this._lastConnection=null}updated(t){super.updated(t),t.has("hass")&&Xe(this.hass?.locale);let o=this.hass?.language;if(o&&!Je(o)&&Ze(o).then(()=>this.requestUpdate()),t.has("hass")&&this.hass){if(!this._dataLoaded)this._dataLoaded=!0,this._lastConnection=this.hass.connection,this._loadData(),this._subscribe();else if(this.hass.connection!==this._lastConnection){if(this._lastConnection=this.hass.connection,this._unsub){try{this._unsub()}catch{}this._unsub=null}this._subscribe(),this._loadData()}}}async _loadData(){try{let[t,o]=await Promise.all([this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/statistics"})]);this._objects=t.objects,this._stats=o}catch{}}async _subscribe(){try{let t=await this.hass.connection.subscribeMessage(o=>{let r=o;this._objects=r.objects},{type:"maintenance_supporter/subscribe"});if(!this.isConnected){t();return}this._unsub=t}catch{}}_onEventClick(t){if(t.history_timestamp){this.dispatchEvent(new CustomEvent("ll-custom",{detail:{type:"maintenance-supporter:edit-history",entry_id:t.entry_id,task_id:t.task_id,original_timestamp:t.history_timestamp},bubbles:!0,composed:!0}));return}this.dispatchEvent(new CustomEvent("ll-custom",{detail:{type:"maintenance-supporter:open-task",entry_id:t.entry_id,task_id:t.task_id},bubbles:!0,composed:!0}))}render(){if(!this.hass)return g;let t=this._lang,o=this._config.show_window_chips!==!1,r=this._config.show_user_filter!==!1,n=this._config.title,s=null;this._userFilter&&(s=this._userFilter==="current_user"?this.hass?.user?.id??null:this._userFilter);let l=i=>{let C=i.toLowerCase();return this._objects.find(P=>P.entry_id===i||P.object.name.toLowerCase()===C)?.entry_id??null},d=new Set(this._configuredObjects.map(l).filter(i=>i!==null)),p=d.size?this._objects.filter(i=>d.has(i.entry_id)):this._objects,u=this._config.show_object_filter!==!1&&p.length>1,c=this._objectFilter?l(this._objectFilter):null,_=c&&p.some(i=>i.entry_id===c)?p.filter(i=>i.entry_id===c):p,h=new Date;h.setHours(0,0,0,0);let b=this._pastDays>0,y=b?Be(_,h,this._pastDays,s):Fe(_,h,this._windowDays,s),R=J(h),M=this._windowDays===365||b,U=M?y.filter(i=>i.events.length>0):y,rt=i=>{let C=`cal-status-${i.status}`,X=i.projected?"cal-event-projected":"",P=i.status==="overdue"&&i.days_until_due!=null?` (${et(i.days_until_due,t)})`:"",H=i.projected&&i.interval_days?m`${i.interval_unit&&i.interval_unit!=="days"?`${i.interval_days} ${f("unit_"+i.interval_unit,t)}`:f("cal_every_n_days",t).replace("{n}",String(i.interval_days))}`:g,I=i.schedule_type==="sensor_based",ie=I?m``:m``,ce=I&&i.prediction_confidence&&i.status!=="triggered"&&!i.projected?m` + icon="${i.adaptive_enabled?"mdi:clock-time-four-outline":"mdi:clock-outline"}">`,le=I&&i.prediction_confidence&&i.status!=="triggered"&&!i.projected?m` ${f("cal_predicted",t)} · ${f(`cal_confidence_${i.prediction_confidence}`,t)} - `:g,nt=this._stats?.budget?.currency_symbol||Ke,st=i.history_type?f(i.history_type,t):f(i.status,t);return m` + `:g,nt=this._stats?.budget?.currency_symbol||Ve,st=i.history_type?f(i.history_type,t):f(i.status,t);return m`
this._onEventClick(i)}> - ${le} - ${st} + ${ie} + ${st}
${i.object_name} · ${i.task_name}${P}
- ${ce} + ${le} ${H}
${i.avg_cost!=null&&i.avg_cost>0?m`${i.avg_cost.toFixed(0)} ${nt}`:g}
- `},at=i=>{let[j,X,P]=i.date.split("-").map(Number),H=new Date(j,X-1,P),I=i.date===R,le=H.toLocaleDateString(t,{weekday:"short"}),ce=H.toLocaleDateString(t,{month:"long"});return m` + `},at=i=>{let[C,X,P]=i.date.split("-").map(Number),H=new Date(C,X-1,P),I=i.date===R,ie=H.toLocaleDateString(t,{weekday:"short"}),le=H.toLocaleDateString(t,{month:"long"});return m`
- ${le} + ${ie} ${H.getDate()}
- ${ce} + ${le} ${I?m`${f("today",t)}`:g}
${i.events.length===0?m`
${f("cal_no_events",t)}
`:i.events.map(rt)} @@ -1421,7 +1421,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .value=${c??""} @change=${i=>{this._objectFilter=i.target.value}}> - ${[...p].sort((i,j)=>i.object.name.localeCompare(j.object.name)).map(i=>m``)} + ${[...p].sort((i,C)=>i.object.name.localeCompare(C.object.name)).map(i=>m``)} `:g}
@@ -1430,7 +1430,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" ${U.length===0&&M?m`
${f("cal_no_events",t)}
`:U.map(at)}
- `}};v.styles=[tt,Ye,k` + `}};v.styles=[tt,We,k` :host { display: block; } ha-card { padding: 0; overflow: hidden; } `],x([Q({attribute:!1})],v.prototype,"hass",2),x([w()],v.prototype,"_config",2),x([w()],v.prototype,"_objects",2),x([w()],v.prototype,"_stats",2),x([w()],v.prototype,"_windowDays",2),x([w()],v.prototype,"_pastDays",2),x([w()],v.prototype,"_userFilter",2),x([w()],v.prototype,"_objectFilter",2),x([w()],v.prototype,"_unsub",2);var Pt=[{value:7,label:"Week (7 days)"},{value:14,label:"Fortnight (14 days)"},{value:30,label:"Month (30 days, default)"},{value:365,label:"Year (365 days, empty days collapsed)"}],L=class extends ${constructor(){super(...arguments);this._config={type:"custom:maintenance-supporter-calendar-card"}}setConfig(t){this._config={...t}}_valueChanged(t,o){let r={...this._config,[t]:o};t==="show_window_chips"&&o===!0&&delete r.show_window_chips,t==="show_user_filter"&&o===!0&&delete r.show_user_filter,t==="show_object_filter"&&o===!0&&delete r.show_object_filter,t==="title"&&(!o||typeof o=="string"&&o.trim()==="")&&delete r.title,t==="user_filter"&&o===""&&delete r.user_filter,this._config=r,this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:r},bubbles:!0,composed:!0}))}render(){let t=this._config.window_days??30,o=this._config.show_window_chips!==!1,r=this._config.show_user_filter!==!1,n=this._config.user_filter??"",s=this._config.title??"";return m` @@ -1525,7 +1525,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" font-size: 12px; color: var(--secondary-text-color, #666); } - `,x([Q({attribute:!1})],L.prototype,"hass",2),x([w()],L.prototype,"_config",2);customElements.get("maintenance-supporter-calendar-card")||customElements.define("maintenance-supporter-calendar-card",v);customElements.get("maintenance-supporter-calendar-card-editor")||customElements.define("maintenance-supporter-calendar-card-editor",L);var ie=window;ie.customCards=ie.customCards||[];var ot="maintenance-supporter-calendar-card",zt=ie.customCards.some(a=>a.type===ot);zt||ie.customCards.push({type:ot,name:"Maintenance Supporter \u2014 Calendar",description:"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",preview:!0});export{v as MaintenanceCalendarCard}; + `,x([Q({attribute:!1})],L.prototype,"hass",2),x([w()],L.prototype,"_config",2);customElements.get("maintenance-supporter-calendar-card")||customElements.define("maintenance-supporter-calendar-card",v);customElements.get("maintenance-supporter-calendar-card-editor")||customElements.define("maintenance-supporter-calendar-card-editor",L);ot({type:"maintenance-supporter-calendar-card",name:"Maintenance Supporter \u2014 Calendar",description:"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",preview:!0});export{v as MaintenanceCalendarCard}; /*! Bundled license information: @lit/reactive-element/css-tag.js: diff --git a/custom_components/maintenance_supporter/frontend/maintenance-card.js b/custom_components/maintenance_supporter/frontend/maintenance-card.js index 3f6fa806..882193e7 100644 --- a/custom_components/maintenance_supporter/frontend/maintenance-card.js +++ b/custom_components/maintenance_supporter/frontend/maintenance-card.js @@ -1,9 +1,9 @@ -/*! maintenance_supporter frontend 2.57.0 */ -var ft=Object.defineProperty;var Hi=Object.getOwnPropertyDescriptor;var $=(a,s,e)=>()=>{if(e)throw e[0];try{return a&&(s=a(a=0)),s}catch(t){throw e=[t],t}};var Mi=(a,s)=>{for(var e in s)ft(a,e,{get:s[e],enumerable:!0})};var d=(a,s,e,t)=>{for(var i=t>1?void 0:t?Hi(s,e):s,n=a.length-1,l;n>=0;n--)(l=a[n])&&(i=(t?l(s,e,i):l(i))||i);return t&&i&&ft(s,e,i),i};var Se,Ae,Ue,vt,pe,bt,E,yt,Ve,Be=$(()=>{Se=globalThis,Ae=Se.ShadowRoot&&(Se.ShadyCSS===void 0||Se.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Ue=Symbol(),vt=new WeakMap,pe=class{constructor(s,e,t){if(this._$cssResult$=!0,t!==Ue)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=s,this.t=e}get styleSheet(){let s=this.o,e=this.t;if(Ae&&s===void 0){let t=e!==void 0&&e.length===1;t&&(s=vt.get(e)),s===void 0&&((this.o=s=new CSSStyleSheet).replaceSync(this.cssText),t&&vt.set(e,s))}return s}toString(){return this.cssText}},bt=a=>new pe(typeof a=="string"?a:a+"",void 0,Ue),E=(a,...s)=>{let e=a.length===1?a[0]:s.reduce((t,i,n)=>t+(l=>{if(l._$cssResult$===!0)return l.cssText;if(typeof l=="number")return l;throw Error("Value passed to 'css' function must be a 'css' function result: "+l+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+a[n+1],a[0]);return new pe(e,a,Ue)},yt=(a,s)=>{if(Ae)a.adoptedStyleSheets=s.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of s){let t=document.createElement("style"),i=Se.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,a.appendChild(t)}},Ve=Ae?a=>a:a=>a instanceof CSSStyleSheet?(s=>{let e="";for(let t of s.cssRules)e+=t.cssText;return bt(e)})(a):a});var Oi,qi,zi,Fi,Di,Ui,Te,xt,Vi,Bi,he,_e,Ie,$t,W,ue=$(()=>{Be();Be();({is:Oi,defineProperty:qi,getOwnPropertyDescriptor:zi,getOwnPropertyNames:Fi,getOwnPropertySymbols:Di,getPrototypeOf:Ui}=Object),Te=globalThis,xt=Te.trustedTypes,Vi=xt?xt.emptyScript:"",Bi=Te.reactiveElementPolyfillSupport,he=(a,s)=>a,_e={toAttribute(a,s){switch(s){case Boolean:a=a?Vi:null;break;case Object:case Array:a=a==null?a:JSON.stringify(a)}return a},fromAttribute(a,s){let e=a;switch(s){case Boolean:e=a!==null;break;case Number:e=a===null?null:Number(a);break;case Object:case Array:try{e=JSON.parse(a)}catch{e=null}}return e}},Ie=(a,s)=>!Oi(a,s),$t={attribute:!0,type:String,converter:_e,reflect:!1,useDefault:!1,hasChanged:Ie};Symbol.metadata??=Symbol("metadata"),Te.litPropertyMetadata??=new WeakMap;W=class extends HTMLElement{static addInitializer(s){this._$Ei(),(this.l??=[]).push(s)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(s,e=$t){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(s)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(s,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(s,t,e);i!==void 0&&qi(this.prototype,s,i)}}static getPropertyDescriptor(s,e,t){let{get:i,set:n}=zi(this.prototype,s)??{get(){return this[e]},set(l){this[e]=l}};return{get:i,set(l){let c=i?.call(this);n?.call(this,l),this.requestUpdate(s,c,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(s){return this.elementProperties.get(s)??$t}static _$Ei(){if(this.hasOwnProperty(he("elementProperties")))return;let s=Ui(this);s.finalize(),s.l!==void 0&&(this.l=[...s.l]),this.elementProperties=new Map(s.elementProperties)}static finalize(){if(this.hasOwnProperty(he("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(he("properties"))){let e=this.properties,t=[...Fi(e),...Di(e)];for(let i of t)this.createProperty(i,e[i])}let s=this[Symbol.metadata];if(s!==null){let e=litPropertyMetadata.get(s);if(e!==void 0)for(let[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let i=this._$Eu(e,t);i!==void 0&&this._$Eh.set(i,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(s){let e=[];if(Array.isArray(s)){let t=new Set(s.flat(1/0).reverse());for(let i of t)e.unshift(Ve(i))}else s!==void 0&&e.push(Ve(s));return e}static _$Eu(s,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof s=="string"?s.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(s=>this.enableUpdating=s),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(s=>s(this))}addController(s){(this._$EO??=new Set).add(s),this.renderRoot!==void 0&&this.isConnected&&s.hostConnected?.()}removeController(s){this._$EO?.delete(s)}_$E_(){let s=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(s.set(t,this[t]),delete this[t]);s.size>0&&(this._$Ep=s)}createRenderRoot(){let s=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return yt(s,this.constructor.elementStyles),s}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(s=>s.hostConnected?.())}enableUpdating(s){}disconnectedCallback(){this._$EO?.forEach(s=>s.hostDisconnected?.())}attributeChangedCallback(s,e,t){this._$AK(s,t)}_$ET(s,e){let t=this.constructor.elementProperties.get(s),i=this.constructor._$Eu(s,t);if(i!==void 0&&t.reflect===!0){let n=(t.converter?.toAttribute!==void 0?t.converter:_e).toAttribute(e,t.type);this._$Em=s,n==null?this.removeAttribute(i):this.setAttribute(i,n),this._$Em=null}}_$AK(s,e){let t=this.constructor,i=t._$Eh.get(s);if(i!==void 0&&this._$Em!==i){let n=t.getPropertyOptions(i),l=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:_e;this._$Em=i;let c=l.fromAttribute(e,n.type);this[i]=c??this._$Ej?.get(i)??c,this._$Em=null}}requestUpdate(s,e,t,i=!1,n){if(s!==void 0){let l=this.constructor;if(i===!1&&(n=this[s]),t??=l.getPropertyOptions(s),!((t.hasChanged??Ie)(n,e)||t.useDefault&&t.reflect&&n===this._$Ej?.get(s)&&!this.hasAttribute(l._$Eu(s,t))))return;this.C(s,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(s,e,{useDefault:t,reflect:i,wrapped:n},l){t&&!(this._$Ej??=new Map).has(s)&&(this._$Ej.set(s,l??e??this[s]),n!==!0||l!==void 0)||(this._$AL.has(s)||(this.hasUpdated||t||(e=void 0),this._$AL.set(s,e)),i===!0&&this._$Em!==s&&(this._$Eq??=new Set).add(s))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let s=this.scheduleUpdate();return s!=null&&await s,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,n]of this._$Ep)this[i]=n;this._$Ep=void 0}let t=this.constructor.elementProperties;if(t.size>0)for(let[i,n]of t){let{wrapped:l}=n,c=this[i];l!==!0||this._$AL.has(i)||c===void 0||this.C(i,void 0,n,c)}}let s=!1,e=this._$AL;try{s=this.shouldUpdate(e),s?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw s=!1,this._$EM(),t}s&&this._$AE(e)}willUpdate(s){}_$AE(s){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(s)),this.updated(s)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(s){return!0}update(s){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(s){}firstUpdated(s){}};W.elementStyles=[],W.shadowRootOptions={mode:"open"},W[he("elementProperties")]=new Map,W[he("finalized")]=new Map,Bi?.({ReactiveElement:W}),(Te.reactiveElementVersions??=[]).push("2.1.2")});function Rt(a,s){if(!Xe(a)||!a.hasOwnProperty("raw"))throw Error("invalid template strings array");return kt!==void 0?kt.createHTML(s):s}function ae(a,s,e=a,t){if(s===te)return s;let i=t!==void 0?e._$Co?.[t]:e._$Cl,n=fe(s)?void 0:s._$litDirective$;return i?.constructor!==n&&(i?._$AO?.(!1),n===void 0?i=void 0:(i=new n(a),i._$AT(a,e,t)),t!==void 0?(e._$Co??=[])[t]=i:e._$Cl=i),i!==void 0&&(s=ae(a,i._$AS(a,s.values),i,t)),s}var Ze,wt,Ce,kt,Ct,J,Lt,Wi,ee,me,fe,Xe,Ki,We,ge,Et,St,Z,At,Tt,Pt,et,o,oe,Gs,te,_,It,X,Gi,ve,Ke,be,ne,Ge,Ye,Qe,Je,Yi,jt,Le=$(()=>{Ze=globalThis,wt=a=>a,Ce=Ze.trustedTypes,kt=Ce?Ce.createPolicy("lit-html",{createHTML:a=>a}):void 0,Ct="$lit$",J=`lit$${Math.random().toFixed(9).slice(2)}$`,Lt="?"+J,Wi=`<${Lt}>`,ee=document,me=()=>ee.createComment(""),fe=a=>a===null||typeof a!="object"&&typeof a!="function",Xe=Array.isArray,Ki=a=>Xe(a)||typeof a?.[Symbol.iterator]=="function",We=`[ -\f\r]`,ge=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Et=/-->/g,St=/>/g,Z=RegExp(`>|${We}(?:([^\\s"'>=/]+)(${We}*=${We}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),At=/'/g,Tt=/"/g,Pt=/^(?:script|style|textarea|title)$/i,et=a=>(s,...e)=>({_$litType$:a,strings:s,values:e}),o=et(1),oe=et(2),Gs=et(3),te=Symbol.for("lit-noChange"),_=Symbol.for("lit-nothing"),It=new WeakMap,X=ee.createTreeWalker(ee,129);Gi=(a,s)=>{let e=a.length-1,t=[],i,n=s===2?"":s===3?"":"",l=ge;for(let c=0;c"?(l=i??ge,f=-1):v[1]===void 0?f=-2:(f=l.lastIndex-v[2].length,u=v[1],l=v[3]===void 0?Z:v[3]==='"'?Tt:At):l===Tt||l===At?l=Z:l===Et||l===St?l=ge:(l=Z,i=void 0);let g=l===Z&&a[c+1].startsWith("/>")?" ":"";n+=l===ge?p+Wi:f>=0?(t.push(u),p.slice(0,f)+Ct+p.slice(f)+J+g):p+J+(f===-2?c:g)}return[Rt(a,n+(a[e]||"")+(s===2?"":s===3?"":"")),t]},ve=class a{constructor({strings:s,_$litType$:e},t){let i;this.parts=[];let n=0,l=0,c=s.length-1,p=this.parts,[u,v]=Gi(s,e);if(this.el=a.createElement(u,t),X.currentNode=this.el.content,e===2||e===3){let f=this.el.content.firstChild;f.replaceWith(...f.childNodes)}for(;(i=X.nextNode())!==null&&p.length0){i.textContent=Ce?Ce.emptyScript:"";for(let g=0;g2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=_}_$AI(s,e=this,t,i){let n=this.strings,l=!1;if(n===void 0)s=ae(this,s,e,0),l=!fe(s)||s!==this._$AH&&s!==te,l&&(this._$AH=s);else{let c=s,p,u;for(s=n[0],p=0;p{let t=e?.renderBefore??s,i=t._$litPart$;if(i===void 0){let n=e?.renderBefore??null;t._$litPart$=i=new be(s.insertBefore(me(),n),n,void 0,e??{})}return i._$AI(a),i}});var tt,k,Qi,Nt=$(()=>{ue();ue();Le();Le();tt=globalThis,k=class extends W{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let s=super.createRenderRoot();return this.renderOptions.renderBefore??=s.firstChild,s}update(s){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(s),this._$Do=jt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return te}};k._$litElement$=!0,k.finalized=!0,tt.litElementHydrateSupport?.({LitElement:k});Qi=tt.litElementPolyfillSupport;Qi?.({LitElement:k});(tt.litElementVersions??=[]).push("4.2.2")});var Ht=$(()=>{});var L=$(()=>{ue();Le();Nt();Ht()});var Ot=$(()=>{});function b(a){return(s,e)=>typeof e=="object"?rs(a,s,e):((t,i,n)=>{let l=i.hasOwnProperty(n);return i.constructor.createProperty(n,t),l?Object.getOwnPropertyDescriptor(i,n):void 0})(a,s,e)}var ss,rs,st=$(()=>{ue();ss={attribute:!0,type:String,converter:_e,reflect:!1,hasChanged:Ie},rs=(a=ss,s,e)=>{let{kind:t,metadata:i}=e,n=globalThis.litPropertyMetadata.get(i);if(n===void 0&&globalThis.litPropertyMetadata.set(i,n=new Map),t==="setter"&&((a=Object.create(a)).wrapped=!0),n.set(e.name,a),t==="accessor"){let{name:l}=e;return{set(c){let p=s.get.call(this);s.set.call(this,c),this.requestUpdate(l,p,a,!0,c)},init(c){return c!==void 0&&this.C(l,void 0,a,c),c}}}if(t==="setter"){let{name:l}=e;return function(c){let p=this[l];s.call(this,c),this.requestUpdate(l,p,a,!0,c)}}throw Error("Unsupported decorator location: "+t)}});function h(a){return b({...a,state:!0,attribute:!1})}var qt=$(()=>{st();});var zt=$(()=>{});var le=$(()=>{});var Ft=$(()=>{le();});var Dt=$(()=>{le();});var Ut=$(()=>{le();});var Vt=$(()=>{le();});var Bt=$(()=>{le();});var q=$(()=>{Ot();st();qt();zt();Ft();Dt();Ut();Vt();Bt()});var Kt,Wt=$(()=>{Kt={maintenance:"Maintenance",objects:"Objects",tasks:"Tasks",overdue:"Overdue",due_soon:"Due Soon",triggered:"Triggered",trigger_replaced:"Trigger replaced",ok:"OK",all:"All",new_object:"+ New Object",templates_from:"From template",templates_title:"Start from a template",templates_task_count:"{n} tasks",template_created:"Created from template",onboard_hint:"Add your first object to start tracking maintenance.",edit:"Edit",duplicate:"Duplicate",task_duplicated:"Task duplicated",object_duplicated:"Object duplicated",delete:"Delete",add_task:"+ Add Task",complete:"Complete",completed:"Completed",skip:"Skip",skipped:"Skipped",missed:"Missed",reset:"Reset",snooze:"Snooze",snoozed:"Snoozed",cancel:"Cancel",bulk_select:"Select",bulk_select_all:"Select all",bulk_n_selected:"{n} selected",bulk_completed:"{n} tasks completed",bulk_archived:"{n} tasks archived",completing:"Completing\u2026",interval:"Interval",warning:"Warning",last_performed:"Last performed",next_due:"Next due",days_until_due:"Days until due",avg_duration:"Avg duration",trigger:"Trigger",trigger_type:"Trigger type",threshold_above:"Upper limit",threshold_below:"Lower limit",threshold:"Threshold",counter:"Counter",state_change:"State change",runtime:"Runtime",runtime_hours:"Target runtime (hours)",target_value:"Target value",baseline:"Baseline",target_changes:"Target changes",for_minutes:"For (minutes)",time_based:"Time-based",sensor_based:"Sensor-based",manual:"Manual",one_time:"One-time",weekdays:"Weekdays",nth_weekday:"Nth weekday of month",day_of_month:"Day of month",recurrence_on_days:"Repeat on",recurrence_occurrence:"Occurrence",recurrence_weekday:"Weekday",recurrence_day:"Day of month (1\u201331)",recurrence_last_day:"Last day of the month",recurrence_business_day:"Business days only (roll back from weekend)",recurrence_offset:"Offset (days, \xB1)",recurrence_offset_help:"Shift the date by \xB1N days, e.g. -2 = two days before.",last_day_month:"Last day of month",last_business_day_month:"Last business day",ord_1:"1st",ord_2:"2nd",ord_3:"3rd",ord_4:"4th",ord_5:"5th",ord_last:"Last",day_word:"Day",interval_value:"Interval",interval_unit:"Unit",unit_days:"Days",unit_weeks:"Weeks",unit_months:"Months",unit_years:"Years",due_date:"Due date",cleaning:"Cleaning",inspection:"Inspection",replacement:"Replacement",calibration:"Calibration",service:"Service",reading:"Reading",custom:"Custom",history:"History",cost:"Cost",report_button:"Report",report_title:"Maintenance report",report_generated:"Generated",report_times_done:"Done",report_total_cost:"Total cost",report_every:"every {n} {unit}",report_notes:"Notes",report_col_type:"Type",report_col_status:"Status",report_col_schedule:"Schedule",duration:"Duration",both:"Both",trigger_val:"Trigger value",complete_title:"Complete: ",checklist:"Checklist",require_on_completion:"Require on completion",checklist_steps_optional:"Checklist steps (optional)",checklist_placeholder:`Clean filter +/*! maintenance_supporter frontend 2.58.0 */ +var bt=Object.defineProperty;var zi=Object.getOwnPropertyDescriptor;var $=(a,s,e)=>()=>{if(e)throw e[0];try{return a&&(s=a(a=0)),s}catch(t){throw e=[t],t}};var Fi=(a,s)=>{for(var e in s)bt(a,e,{get:s[e],enumerable:!0})};var d=(a,s,e,t)=>{for(var i=t>1?void 0:t?zi(s,e):s,n=a.length-1,l;n>=0;n--)(l=a[n])&&(i=(t?l(s,e,i):l(i))||i);return t&&i&&bt(s,e,i),i};var Te,Ce,Be,yt,_e,xt,E,$t,We,Ke=$(()=>{Te=globalThis,Ce=Te.ShadowRoot&&(Te.ShadyCSS===void 0||Te.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Be=Symbol(),yt=new WeakMap,_e=class{constructor(s,e,t){if(this._$cssResult$=!0,t!==Be)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=s,this.t=e}get styleSheet(){let s=this.o,e=this.t;if(Ce&&s===void 0){let t=e!==void 0&&e.length===1;t&&(s=yt.get(e)),s===void 0&&((this.o=s=new CSSStyleSheet).replaceSync(this.cssText),t&&yt.set(e,s))}return s}toString(){return this.cssText}},xt=a=>new _e(typeof a=="string"?a:a+"",void 0,Be),E=(a,...s)=>{let e=a.length===1?a[0]:s.reduce((t,i,n)=>t+(l=>{if(l._$cssResult$===!0)return l.cssText;if(typeof l=="number")return l;throw Error("Value passed to 'css' function must be a 'css' function result: "+l+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+a[n+1],a[0]);return new _e(e,a,Be)},$t=(a,s)=>{if(Ce)a.adoptedStyleSheets=s.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of s){let t=document.createElement("style"),i=Te.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,a.appendChild(t)}},We=Ce?a=>a:a=>a instanceof CSSStyleSheet?(s=>{let e="";for(let t of s.cssRules)e+=t.cssText;return xt(e)})(a):a});var Di,Ui,Vi,Bi,Wi,Ki,Ie,wt,Gi,Yi,ue,ge,Le,kt,K,me=$(()=>{Ke();Ke();({is:Di,defineProperty:Ui,getOwnPropertyDescriptor:Vi,getOwnPropertyNames:Bi,getOwnPropertySymbols:Wi,getPrototypeOf:Ki}=Object),Ie=globalThis,wt=Ie.trustedTypes,Gi=wt?wt.emptyScript:"",Yi=Ie.reactiveElementPolyfillSupport,ue=(a,s)=>a,ge={toAttribute(a,s){switch(s){case Boolean:a=a?Gi:null;break;case Object:case Array:a=a==null?a:JSON.stringify(a)}return a},fromAttribute(a,s){let e=a;switch(s){case Boolean:e=a!==null;break;case Number:e=a===null?null:Number(a);break;case Object:case Array:try{e=JSON.parse(a)}catch{e=null}}return e}},Le=(a,s)=>!Di(a,s),kt={attribute:!0,type:String,converter:ge,reflect:!1,useDefault:!1,hasChanged:Le};Symbol.metadata??=Symbol("metadata"),Ie.litPropertyMetadata??=new WeakMap;K=class extends HTMLElement{static addInitializer(s){this._$Ei(),(this.l??=[]).push(s)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(s,e=kt){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(s)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(s,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(s,t,e);i!==void 0&&Ui(this.prototype,s,i)}}static getPropertyDescriptor(s,e,t){let{get:i,set:n}=Vi(this.prototype,s)??{get(){return this[e]},set(l){this[e]=l}};return{get:i,set(l){let c=i?.call(this);n?.call(this,l),this.requestUpdate(s,c,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(s){return this.elementProperties.get(s)??kt}static _$Ei(){if(this.hasOwnProperty(ue("elementProperties")))return;let s=Ki(this);s.finalize(),s.l!==void 0&&(this.l=[...s.l]),this.elementProperties=new Map(s.elementProperties)}static finalize(){if(this.hasOwnProperty(ue("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(ue("properties"))){let e=this.properties,t=[...Bi(e),...Wi(e)];for(let i of t)this.createProperty(i,e[i])}let s=this[Symbol.metadata];if(s!==null){let e=litPropertyMetadata.get(s);if(e!==void 0)for(let[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let i=this._$Eu(e,t);i!==void 0&&this._$Eh.set(i,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(s){let e=[];if(Array.isArray(s)){let t=new Set(s.flat(1/0).reverse());for(let i of t)e.unshift(We(i))}else s!==void 0&&e.push(We(s));return e}static _$Eu(s,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof s=="string"?s.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(s=>this.enableUpdating=s),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(s=>s(this))}addController(s){(this._$EO??=new Set).add(s),this.renderRoot!==void 0&&this.isConnected&&s.hostConnected?.()}removeController(s){this._$EO?.delete(s)}_$E_(){let s=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(s.set(t,this[t]),delete this[t]);s.size>0&&(this._$Ep=s)}createRenderRoot(){let s=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return $t(s,this.constructor.elementStyles),s}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(s=>s.hostConnected?.())}enableUpdating(s){}disconnectedCallback(){this._$EO?.forEach(s=>s.hostDisconnected?.())}attributeChangedCallback(s,e,t){this._$AK(s,t)}_$ET(s,e){let t=this.constructor.elementProperties.get(s),i=this.constructor._$Eu(s,t);if(i!==void 0&&t.reflect===!0){let n=(t.converter?.toAttribute!==void 0?t.converter:ge).toAttribute(e,t.type);this._$Em=s,n==null?this.removeAttribute(i):this.setAttribute(i,n),this._$Em=null}}_$AK(s,e){let t=this.constructor,i=t._$Eh.get(s);if(i!==void 0&&this._$Em!==i){let n=t.getPropertyOptions(i),l=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:ge;this._$Em=i;let c=l.fromAttribute(e,n.type);this[i]=c??this._$Ej?.get(i)??c,this._$Em=null}}requestUpdate(s,e,t,i=!1,n){if(s!==void 0){let l=this.constructor;if(i===!1&&(n=this[s]),t??=l.getPropertyOptions(s),!((t.hasChanged??Le)(n,e)||t.useDefault&&t.reflect&&n===this._$Ej?.get(s)&&!this.hasAttribute(l._$Eu(s,t))))return;this.C(s,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(s,e,{useDefault:t,reflect:i,wrapped:n},l){t&&!(this._$Ej??=new Map).has(s)&&(this._$Ej.set(s,l??e??this[s]),n!==!0||l!==void 0)||(this._$AL.has(s)||(this.hasUpdated||t||(e=void 0),this._$AL.set(s,e)),i===!0&&this._$Em!==s&&(this._$Eq??=new Set).add(s))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let s=this.scheduleUpdate();return s!=null&&await s,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,n]of this._$Ep)this[i]=n;this._$Ep=void 0}let t=this.constructor.elementProperties;if(t.size>0)for(let[i,n]of t){let{wrapped:l}=n,c=this[i];l!==!0||this._$AL.has(i)||c===void 0||this.C(i,void 0,n,c)}}let s=!1,e=this._$AL;try{s=this.shouldUpdate(e),s?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw s=!1,this._$EM(),t}s&&this._$AE(e)}willUpdate(s){}_$AE(s){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(s)),this.updated(s)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(s){return!0}update(s){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(s){}firstUpdated(s){}};K.elementStyles=[],K.shadowRootOptions={mode:"open"},K[ue("elementProperties")]=new Map,K[ue("finalized")]=new Map,Yi?.({ReactiveElement:K}),(Ie.reactiveElementVersions??=[]).push("2.1.2")});function Nt(a,s){if(!tt(a)||!a.hasOwnProperty("raw"))throw Error("invalid template strings array");return St!==void 0?St.createHTML(s):s}function oe(a,s,e=a,t){if(s===se)return s;let i=t!==void 0?e._$Co?.[t]:e._$Cl,n=be(s)?void 0:s._$litDirective$;return i?.constructor!==n&&(i?._$AO?.(!1),n===void 0?i=void 0:(i=new n(a),i._$AT(a,e,t)),t!==void 0?(e._$Co??=[])[t]=i:e._$Cl=i),i!==void 0&&(s=oe(a,i._$AS(a,s.values),i,t)),s}var et,Et,Pe,St,Pt,X,Rt,Qi,ie,ve,be,tt,Ji,Ge,fe,At,Tt,ee,Ct,It,jt,it,o,de,er,se,_,Lt,te,Zi,ye,Ye,xe,le,Qe,Je,Ze,Xe,Xi,Ht,Re=$(()=>{et=globalThis,Et=a=>a,Pe=et.trustedTypes,St=Pe?Pe.createPolicy("lit-html",{createHTML:a=>a}):void 0,Pt="$lit$",X=`lit$${Math.random().toFixed(9).slice(2)}$`,Rt="?"+X,Qi=`<${Rt}>`,ie=document,ve=()=>ie.createComment(""),be=a=>a===null||typeof a!="object"&&typeof a!="function",tt=Array.isArray,Ji=a=>tt(a)||typeof a?.[Symbol.iterator]=="function",Ge=`[ +\f\r]`,fe=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,At=/-->/g,Tt=/>/g,ee=RegExp(`>|${Ge}(?:([^\\s"'>=/]+)(${Ge}*=${Ge}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`,"g"),Ct=/'/g,It=/"/g,jt=/^(?:script|style|textarea|title)$/i,it=a=>(s,...e)=>({_$litType$:a,strings:s,values:e}),o=it(1),de=it(2),er=it(3),se=Symbol.for("lit-noChange"),_=Symbol.for("lit-nothing"),Lt=new WeakMap,te=ie.createTreeWalker(ie,129);Zi=(a,s)=>{let e=a.length-1,t=[],i,n=s===2?"":s===3?"":"",l=fe;for(let c=0;c"?(l=i??fe,f=-1):v[1]===void 0?f=-2:(f=l.lastIndex-v[2].length,u=v[1],l=v[3]===void 0?ee:v[3]==='"'?It:Ct):l===It||l===Ct?l=ee:l===At||l===Tt?l=fe:(l=ee,i=void 0);let g=l===ee&&a[c+1].startsWith("/>")?" ":"";n+=l===fe?p+Qi:f>=0?(t.push(u),p.slice(0,f)+Pt+p.slice(f)+X+g):p+X+(f===-2?c:g)}return[Nt(a,n+(a[e]||"")+(s===2?"":s===3?"":"")),t]},ye=class a{constructor({strings:s,_$litType$:e},t){let i;this.parts=[];let n=0,l=0,c=s.length-1,p=this.parts,[u,v]=Zi(s,e);if(this.el=a.createElement(u,t),te.currentNode=this.el.content,e===2||e===3){let f=this.el.content.firstChild;f.replaceWith(...f.childNodes)}for(;(i=te.nextNode())!==null&&p.length0){i.textContent=Pe?Pe.emptyScript:"";for(let g=0;g2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=_}_$AI(s,e=this,t,i){let n=this.strings,l=!1;if(n===void 0)s=oe(this,s,e,0),l=!be(s)||s!==this._$AH&&s!==se,l&&(this._$AH=s);else{let c=s,p,u;for(s=n[0],p=0;p{let t=e?.renderBefore??s,i=t._$litPart$;if(i===void 0){let n=e?.renderBefore??null;t._$litPart$=i=new xe(s.insertBefore(ve(),n),n,void 0,e??{})}return i._$AI(a),i}});var st,k,es,Ot=$(()=>{me();me();Re();Re();st=globalThis,k=class extends K{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let s=super.createRenderRoot();return this.renderOptions.renderBefore??=s.firstChild,s}update(s){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(s),this._$Do=Ht(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return se}};k._$litElement$=!0,k.finalized=!0,st.litElementHydrateSupport?.({LitElement:k});es=st.litElementPolyfillSupport;es?.({LitElement:k});(st.litElementVersions??=[]).push("4.2.2")});var Mt=$(()=>{});var L=$(()=>{me();Re();Ot();Mt()});var zt=$(()=>{});function b(a){return(s,e)=>typeof e=="object"?ls(a,s,e):((t,i,n)=>{let l=i.hasOwnProperty(n);return i.constructor.createProperty(n,t),l?Object.getOwnPropertyDescriptor(i,n):void 0})(a,s,e)}var os,ls,at=$(()=>{me();os={attribute:!0,type:String,converter:ge,reflect:!1,hasChanged:Le},ls=(a=os,s,e)=>{let{kind:t,metadata:i}=e,n=globalThis.litPropertyMetadata.get(i);if(n===void 0&&globalThis.litPropertyMetadata.set(i,n=new Map),t==="setter"&&((a=Object.create(a)).wrapped=!0),n.set(e.name,a),t==="accessor"){let{name:l}=e;return{set(c){let p=s.get.call(this);s.set.call(this,c),this.requestUpdate(l,p,a,!0,c)},init(c){return c!==void 0&&this.C(l,void 0,a,c),c}}}if(t==="setter"){let{name:l}=e;return function(c){let p=this[l];s.call(this,c),this.requestUpdate(l,p,a,!0,c)}}throw Error("Unsupported decorator location: "+t)}});function h(a){return b({...a,state:!0,attribute:!1})}var Ft=$(()=>{at();});var Dt=$(()=>{});var ce=$(()=>{});var Ut=$(()=>{ce();});var Vt=$(()=>{ce();});var Bt=$(()=>{ce();});var Wt=$(()=>{ce();});var Kt=$(()=>{ce();});var z=$(()=>{zt();at();Ft();Dt();Ut();Vt();Bt();Wt();Kt()});var Yt,Gt=$(()=>{Yt={maintenance:"Maintenance",objects:"Objects",tasks:"Tasks",overdue:"Overdue",due_soon:"Due Soon",triggered:"Triggered",trigger_replaced:"Trigger replaced",ok:"OK",all:"All",new_object:"+ New Object",templates_from:"From template",templates_title:"Start from a template",templates_task_count:"{n} tasks",template_created:"Created from template",onboard_hint:"Add your first object to start tracking maintenance.",edit:"Edit",duplicate:"Duplicate",task_duplicated:"Task duplicated",object_duplicated:"Object duplicated",delete:"Delete",add_task:"+ Add Task",complete:"Complete",completed:"Completed",skip:"Skip",skipped:"Skipped",missed:"Missed",reset:"Reset",snooze:"Snooze",snoozed:"Snoozed",cancel:"Cancel",bulk_select:"Select",bulk_select_all:"Select all",bulk_n_selected:"{n} selected",bulk_completed:"{n} tasks completed",bulk_archived:"{n} tasks archived",completing:"Completing\u2026",interval:"Interval",warning:"Warning",last_performed:"Last performed",next_due:"Next due",days_until_due:"Days until due",avg_duration:"Avg duration",trigger:"Trigger",trigger_type:"Trigger type",threshold_above:"Upper limit",threshold_below:"Lower limit",threshold:"Threshold",counter:"Counter",state_change:"State change",runtime:"Runtime",runtime_hours:"Target runtime (hours)",target_value:"Target value",baseline:"Baseline",target_changes:"Target changes",for_minutes:"For (minutes)",time_based:"Time-based",sensor_based:"Sensor-based",manual:"Manual",one_time:"One-time",weekdays:"Weekdays",nth_weekday:"Nth weekday of month",day_of_month:"Day of month",recurrence_on_days:"Repeat on",recurrence_occurrence:"Occurrence",recurrence_weekday:"Weekday",recurrence_day:"Day of month (1\u201331)",recurrence_last_day:"Last day of the month",recurrence_business_day:"Business days only (roll back from weekend)",recurrence_offset:"Offset (days, \xB1)",recurrence_offset_help:"Shift the date by \xB1N days, e.g. -2 = two days before.",last_day_month:"Last day of month",last_business_day_month:"Last business day",ord_1:"1st",ord_2:"2nd",ord_3:"3rd",ord_4:"4th",ord_5:"5th",ord_last:"Last",day_word:"Day",interval_value:"Interval",interval_unit:"Unit",unit_days:"Days",unit_weeks:"Weeks",unit_months:"Months",unit_years:"Years",due_date:"Due date",cleaning:"Cleaning",inspection:"Inspection",replacement:"Replacement",calibration:"Calibration",service:"Service",reading:"Reading",custom:"Custom",history:"History",cost:"Cost",report_button:"Report",report_title:"Maintenance report",report_generated:"Generated",report_times_done:"Done",report_total_cost:"Total cost",report_every:"every {n} {unit}",report_notes:"Notes",report_col_type:"Type",report_col_status:"Status",report_col_schedule:"Schedule",duration:"Duration",both:"Both",trigger_val:"Trigger value",complete_title:"Complete: ",checklist:"Checklist",require_on_completion:"Require on completion",checklist_steps_optional:"Checklist steps (optional)",checklist_placeholder:`Clean filter Replace seal -Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:"{field}: too long (max {n} characters)",err_too_short:"{field}: too short (min {n} characters)",err_value_too_high:"{field}: too large (max {n})",err_value_too_low:"{field}: too small (min {n})",err_required:"{field}: required",err_wrong_type:"{field}: wrong type (expected: {type})",err_invalid_choice:"{field}: not an allowed value",err_invalid_value:"{field}: invalid value",feat_schedule_time:"Time-of-day scheduling",feat_schedule_time_desc:"Tasks become overdue at a specific time of day instead of midnight.",schedule_time_optional:"Due at time (optional, HH:MM)",schedule_time_help:"Empty = midnight (default). HA timezone.",at_time:"at",notes_optional:"Notes (optional)",cost_optional:"Cost (optional)",duration_minutes:"Duration in minutes (optional)",days:"days",day:"day",today:"Today",d_overdue:"d overdue",no_tasks:"No maintenance tasks yet. Create an object to get started.",no_tasks_short:"No tasks",no_history:"No history entries yet.",show_all:"Show all",cost_duration_chart:"Cost & Duration",installed:"Installed",confirm_delete_object:"Delete this object and all its tasks?",confirm_delete_task:"Delete this task?",min:"Min",max:"Max",save:"Save",saving:"Saving\u2026",edit_task:"Edit Task",new_task:"New Maintenance Task",task_name:"Task name",maintenance_type:"Maintenance type",priority:"Priority",labels:"Labels",labels_placeholder:"e.g. safety, seasonal, tenant-visible",labels_help:"Comma-separated tags for filtering and reporting.",priority_low:"Low",priority_normal:"Normal",priority_high:"High",schedule_type:"Schedule type",interval_days:"Interval (days)",warning_days:"Warning days",earliest_completion_days:"Earliest completion (days before due)",earliest_completion_days_help:"Leave empty to allow completing any time. 0 = only on/after the due date.",last_performed_optional:"Last performed (optional)",interval_anchor:"Interval anchor",anchor_completion:"From completion date",anchor_planned:"From planned date (no drift)",edit_object:"Edit Object",name:"Name",manufacturer_optional:"Manufacturer (optional)",model_optional:"Model (optional)",serial_number_optional:"Serial number (optional)",serial_number_label:"S/N",documentation_url_label:"Manual",object_notes_label:"Notes",sort_due_date:"Due date",sort_object:"Object name",sort_type:"Type",sort_task_name:"Task name",all_objects:"All objects",all_parts:"All parts",tasks_lower:"tasks",no_tasks_yet:"No tasks yet",add_first_task:"Add first task",trigger_configuration:"Trigger Configuration",entity_id:"Entity ID",comma_separated:"comma-separated",entity_logic:"Entity logic",entity_logic_any:"Any entity triggers",entity_logic_all:"All entities must trigger",entities:"entities",attribute_optional:"Attribute (optional, blank = state)",use_entity_state:"Use entity state (no attribute)",trigger_above:"Trigger above",trigger_below:"Trigger below",for_at_least_minutes:"For at least (minutes)",safety_interval_days:"Safety interval (days, optional)",safety_interval:"Safety interval (optional)",delta_mode:"Delta mode",from_state_optional:"From state (optional)",to_state_optional:"To state (optional)",documentation_url_optional:"Documentation URL (optional)",object_notes_optional:"Notes (optional)",nfc_tag_id_optional:"NFC Tag ID (optional)",nfc_tags_empty_help:"No NFC tags registered in Home Assistant yet.",nfc_tags_open_settings:"Open Tags settings",nfc_tags_refresh:"Refresh",environmental_entity_optional:"Environmental sensor (optional)",environmental_entity_helper:"e.g. sensor.outdoor_temperature \u2014 adjusts the interval based on environmental conditions",adaptive_prediction_enabled:"Enable sensor-driven predictions",adaptive_seasonal_enabled:"Enable seasonal awareness",adaptive_max_interval:"Maximum interval (days)",adaptive_min_interval:"Minimum interval (days)",adaptive_ewa_alpha:"Learning rate (alpha)",adaptive_enabled:"Enable adaptive scheduling",adaptive_section_title:"Adaptive Scheduling",environmental_attribute_optional:"Environmental attribute (optional)",nfc_tag_id:"NFC Tag ID",nfc_linked:"NFC tag linked",nfc_link_hint:"Click to link NFC tag",responsible_user:"Responsible User",shared_with:"Shared with (rotation)",shared_with_help:"Pick multiple people to share this task; the responsible person rotates on each completion.",rotation_strategy:"Rotation",rotation_none:"No rotation",rotation_round_robin:"Round-robin",rotation_least_completed:"Least completed",rotation_random:"Random",no_user_assigned:"(No user assigned)",all_users:"All Users",my_tasks:"My Tasks",tab_calendar:"Calendar",cal_no_events:"No maintenance",cal_window_7:"7 days",cal_window_14:"14 days",cal_window_30:"30 days",cal_window_365:"1 year",cal_every_n_days:"every {n} days",cal_source_time:"Time-based",cal_source_time_adaptive:"Time-based (adaptive)",cal_source_sensor:"Sensor-based",cal_predicted:"predicted",cal_confidence_high:"high confidence",cal_confidence_medium:"medium confidence",cal_confidence_low:"low confidence",budget_monthly:"Monthly budget",budget_yearly:"Yearly budget",groups:"Groups",new_group:"New group",edit_group:"Edit group",no_groups:"No groups yet",delete_group:"Delete group",delete_group_confirm:"Delete group '{name}'?",group_select_tasks:"Select tasks",group_name_required:"Name is required",description_optional:"Description (optional)",selected:"Selected",loading_chart:"Loading chart data...",hide_outliers:"Hide outliers (sensor glitches)",was_maintenance_needed:"Was this maintenance needed?",feedback_needed:"Needed",feedback_not_needed:"Not needed",feedback_not_sure:"Not sure",suggested_interval:"Suggested interval",apply_suggestion:"Apply",reanalyze:"Re-analyze",reanalyze_result:"New analysis",reanalyze_insufficient_data:"Not enough data to produce a recommendation",data_points:"data points",dismiss_suggestion:"Dismiss",confidence_low:"Low",confidence_medium:"Medium",confidence_high:"High",recommended:"recommended",seasonal_awareness:"Seasonal Awareness",edit_seasonal_overrides:"Edit seasonal factors",seasonal_overrides_title:"Seasonal factors (override)",seasonal_overrides_hint:"Factor per month (0.1\u20135.0). Empty = learned automatically.",seasonal_override_invalid:"Invalid value",seasonal_override_range:"Factor must be between 0.1 and 5.0",clear_all:"Clear all",seasonal_chart_title:"Seasonal Factors",seasonal_learned:"Learned",seasonal_manual:"Manual",month_jan:"Jan",month_feb:"Feb",month_mar:"Mar",month_apr:"Apr",month_may:"May",month_jun:"Jun",month_jul:"Jul",month_aug:"Aug",month_sep:"Sep",month_oct:"Oct",month_nov:"Nov",month_dec:"Dec",sensor_prediction:"Sensor Prediction",degradation_trend:"Trend",trend_rising:"Rising",trend_falling:"Falling",trend_stable:"Stable",trend_insufficient_data:"Insufficient data",days_until_threshold:"Days until threshold",threshold_exceeded:"Threshold exceeded",environmental_adjustment:"Environmental factor",sensor_prediction_urgency:"Sensor predicts threshold in ~{days} days",day_short:"day",weibull_reliability_curve:"Reliability Curve",weibull_failure_probability:"Failure Probability",weibull_r_squared:"Fit R\xB2",beta_early_failures:"Early Failures",beta_random_failures:"Random Failures",beta_wear_out:"Wear-out",beta_highly_predictable:"Highly Predictable",confidence_interval:"Confidence Interval",confidence_conservative:"Conservative",confidence_aggressive:"Optimistic",current_interval_marker:"Current interval",recommended_marker:"Recommended",characteristic_life:"Characteristic life",chart_mini_sparkline:"Trend sparkline",chart_history:"Cost and duration history",chart_seasonal:"Seasonal factors, 12 months",chart_weibull:"Weibull reliability curve",chart_sparkline:"Sensor trigger value chart",days_progress:"Days progress",qr_code:"QR Code",qr_generating:"Generating QR code\u2026",qr_error:"Failed to generate QR code.",qr_error_no_url:"No HA URL configured. Please set an external or internal URL in Settings \u2192 System \u2192 Network.",save_error:"Failed to save. Please try again.",qr_print:"Print",qr_download:"Download SVG",qr_action:"Action on scan",qr_action_view:"View maintenance info",qr_action_complete:"Mark maintenance as complete",qr_url_mode:"Link type",qr_mode_companion:"Companion App",qr_mode_local:"Local (mDNS)",qr_mode_server:"Server URL",overview:"Overview",analysis:"Analysis",recent_activities:"Recent Activities",search_notes:"Search notes",avg_cost:"Avg Cost",no_advanced_features:"No advanced features enabled",no_advanced_features_hint:"Enable \u201CAdaptive Intervals\u201D or \u201CSeasonal Patterns\u201D in the integration settings to see analysis data here.",analysis_not_enough_data:"Not enough data for analysis yet.",analysis_not_enough_data_hint:"Weibull analysis requires at least 5 completed maintenances; seasonal patterns become visible after 6+ data points per month.",analysis_manual_task_hint:"Manual tasks without an interval do not generate analysis data.",completions:"completions",current:"Current",shorter:"Shorter",longer:"Longer",normal:"Normal",disabled:"Disabled",compound_logic:"Compound logic",compound:"Compound (multiple conditions)",compound_logic_and:"AND \u2014 all conditions must trigger",compound_logic_or:"OR \u2014 any condition triggers",compound_help:"Combine several sensor conditions into one trigger.",compound_no_conditions:"No conditions yet \u2014 add at least one.",compound_add_condition:"Add condition",compound_condition:"Condition",compound_remove_condition:"Remove condition",card_title:"Title",card_show_header:"Show header with statistics",card_show_actions:"Show action buttons",card_compact:"Compact mode",card_max_items:"Max items (0 = all)",card_filter_status:"Filter by status",card_filter_status_help:"Empty = show all statuses.",card_filter_objects:"Filter by objects",card_filter_objects_help:"Empty = show all objects.",card_filter_areas:"Filter by areas",card_filter_areas_help:"Empty = show all areas.",card_filter_entities:"Filter by entities (entity_ids)",card_filter_entities_help:"Pick sensor / binary_sensor entities from this integration. Empty = all.",card_loading_objects:"Loading objects\u2026",card_load_error:"Could not load objects \u2014 check the WebSocket connection.",card_no_tasks_title:"No maintenance tasks yet",card_no_tasks_cta:"\u2192 Create one in the Maintenance panel",no_objects:"No objects yet.",action_error:"Action failed. Please try again.",area_id_optional:"Area (optional)",installation_date_optional:"Installation date (optional)",warranty_expiry_optional:"Warranty expiry (optional)",warranty:"Warranty",warranty_valid_until:"valid until {date}",warranty_expires_in:"expires in {days} days",warranty_expired:"expired",cal_past_windows:"Past windows",cal_forward_windows:"Forward windows",history_edit_title:"Edit history entry",history_edit_timestamp:"Timestamp",manufacturer:"Manufacturer",model:"Model",area:"Area",actions:"Actions",view_mode_label:"View",view_cards:"Card view",view_table:"Table view",objects_table_columns_label:"Objects table columns",objects_table_columns_hint:"Choose which columns appear in the objects table view.",custom_icon_optional:"Icon (optional, e.g. mdi:wrench)",task_enabled:"Task enabled",skip_reason_prompt:"Skip this task?",reason_optional:"Reason (optional)",reset_date_prompt:"Mark task as performed?",reset_date_optional:"Last performed date (optional, defaults to today)",notes_label:"Notes",documentation_label:"Documentation",no_nfc_tag:"\u2014 No tag \u2014",dashboard:"Dashboard",tab_today:"Today",palette_placeholder:"Search objects and tasks\u2026",palette_no_results:"No matches",palette_hint:"\u2191\u2193 to navigate \xB7 Enter to open \xB7 Esc to close",today_all_caught_up:"All caught up! Nothing due this week.",today_overdue:"Overdue",today_due_today:"Due today",today_this_week:"This week",settings:"Settings",settings_features:"Advanced Features",settings_features_desc:"Enable or disable advanced features. Disabling hides them from the UI but does not delete data.",feat_adaptive:"Adaptive Scheduling",feat_adaptive_desc:"Learn optimal intervals from maintenance history",feat_predictions:"Sensor Predictions",feat_predictions_desc:"Predict trigger dates from sensor degradation",feat_seasonal:"Seasonal Adjustments",feat_seasonal_desc:"Adjust intervals based on seasonal patterns",feat_environmental:"Environmental Correlation",feat_environmental_desc:"Correlate intervals with temperature/humidity",feat_budget:"Budget Tracking",feat_budget_desc:"Track monthly and yearly maintenance spending",feat_groups:"Task Groups",feat_groups_desc:"Organize tasks into logical groups",feat_checklists:"Checklists",feat_checklists_desc:"Multi-step procedures for task completion",settings_general:"General",settings_default_warning:"Default warning days",settings_panel_enabled:"Sidebar panel",settings_panel_title:"Sidebar panel title",settings_notifications:"Notifications",settings_notify_service:"Notification service",settings_install_assist_sentences:"Install Assist sentences",settings_install_assist_sentences_hint:"Copies the voice sentences into your configuration so the classic Assist agent recognises them. A file you edited yourself is never overwritten.",test_notification:"Test notification",send_test:"Send test",testing:"Sending\u2026",test_notification_success:"Test notification sent",test_notification_failed:"Test notification failed",notify_per_person:"Per-person delivery",notify_no_own_device:"No own device \u2014 uses the household service",settings_notify_due_soon:"Notify when due soon",settings_notify_overdue:"Notify when overdue",settings_notify_triggered:"Notify when triggered",settings_interval_hours:"Repeat interval (hours, 0 = once)",settings_quiet_hours:"Quiet hours",settings_quiet_start:"Start",settings_quiet_end:"End",settings_max_per_day:"Max notifications per day (0 = unlimited)",settings_bundling:"Bundle notifications",settings_bundle_threshold:"Bundle threshold",settings_reminder_leads:"Extra reminders (days before due)",settings_reminder_leads_hint:"Comma-separated lead times, e.g. 14, 3, 0 \u2014 one extra reminder fires on each matching day. Empty = off.",settings_actions:"Mobile Action Buttons",settings_action_complete:"Show 'Complete' button",settings_action_skip:"Show 'Skip' button",settings_action_snooze:"Show 'Snooze' button",settings_weekly_digest:"Weekly digest",settings_weekly_digest_hint:"A single summary notification on Monday morning when tasks are due.",settings_warranty_reminder:"Warranty expiry reminder",settings_warranty_reminder_days:"Days before expiry",settings_warranty_reminder_hint:"Notify once when an object's warranty is this many days from expiring.",settings_snooze_hours:"Snooze duration (hours)",settings_budget:"Budget",settings_currency:"Currency",settings_budget_monthly:"Monthly budget",settings_budget_yearly:"Yearly budget",settings_budget_alerts:"Budget alerts",settings_budget_threshold:"Alert threshold (%)",settings_import_export:"Import / Export",settings_export_json:"Export JSON",settings_export_yaml:"Export YAML",settings_export_csv:"Export CSV",settings_import_csv:"Import CSV",settings_import_placeholder:"Paste JSON or CSV content here\u2026",settings_import_btn:"Import",settings_import_success:"{count} objects imported successfully.",settings_export_success:"Export downloaded.",settings_saved:"Setting saved.",settings_include_history:"Include history",settings_export_selection:"Limit to selected objects (optional)",settings_docs_archive:"Documents archive (with files)",settings_docs_archive_hint:"The JSON/YAML/CSV exports carry settings only. This ZIP includes the uploaded file contents so a restore is complete.",settings_docs_export_btn:"Download documents ZIP",settings_docs_import_btn:"Restore documents ZIP",settings_docs_import_success:"Restored: {blobs} files, {docs} documents",sort_alphabetical:"Alphabetical",sort_due_soonest:"Due soonest",sort_task_count:"Task count",sort_area:"Area",sort_assigned_user:"Assigned user",sort_group:"Group",groupby_none:"No grouping",groupby_area:"By area",groupby_group:"By group",groupby_user:"By user",filter_label:"Filter",user_label:"User",photo_label:"Photo",sort_label:"Sort",group_by_label:"Group by",state_value_help:'Use the HA state value (usually lowercase, e.g. "on"/"off"). Case is normalised on save.',target_changes_help:"Number of matching transitions before the trigger fires (default: 1).",qr_print_title:"Print QR codes",qr_print_desc:"Generate a printable page of QR codes to cut out and stick on your equipment.",qr_print_load:"Load objects",qr_print_filter:"Filter",qr_print_objects:"Objects",qr_print_actions:"Actions",qr_print_url_mode:"Link type",qr_print_estimate:"Estimated QR codes",qr_print_over_limit:"cap is 200, narrow the filter",qr_print_generate:"Generate QR codes",qr_print_generating:"Generating\u2026",qr_print_ready:"QR codes ready",qr_print_print_button:"Print",qr_print_empty:"Nothing to generate",qr_action_skip:"Skip",vacation_title:"Vacation mode",vacation_active:"active",vacation_ended:"ended",vacation_desc:"Plan a vacation: notifications are paused during the period plus a buffer of days. You can opt specific tasks back in.",vacation_enable:"Enable vacation mode",vacation_start:"Start",vacation_end:"End",vacation_buffer:"Buffer (days)",vacation_exempt_title:"Notify anyway during vacation",vacation_exempt_desc:"Pick tasks that should still notify during vacation (e.g. critical pool chemistry).",vacation_load_tasks:"Load tasks",vacation_preview_btn:"Show preview",vacation_preview_affected:"tasks affected",vacation_event_due_soon:"becomes due soon",vacation_event_overdue:"becomes overdue",vacation_event_triggered_est:"sensor trigger possible",vacation_sensor_based:"(sensor-based)",vacation_action_notify:"Notify anyway",vacation_action_unsilence:"Silence again",vacation_marked_complete:"Marked complete",vacation_marked_skip:"Skipped",vacation_end_now:"End vacation now",add:"Add",show_stats:"Show stats + graphs",hide_stats:"Hide stats",adaptive_no_data:"Not enough completion history yet for adaptive analysis. Complete this task a few more times to unlock interval recommendations and reliability charts.",suggestion_applied:"Suggested interval applied",vacation_mode:"Vacation mode",vacation_status_active:"Active now",vacation_status_scheduled:"Scheduled",vacation_status_inactive:"Inactive",vacation_end_now_confirm:"End vacation immediately?",vacation_exempt_count:"exempt",vacation_advanced:"Advanced\u2026",vacation_open_panel:"Open in panel",enable:"Enable",saved:"Saved",budget_monthly_set:"Set monthly",budget_yearly_set:"Set yearly",budget_advanced:"Currency, alerts\u2026",budget_open_panel:"Open in panel",groups_empty:"No groups yet.",group_new_placeholder:"Add group\u2026",group_delete_confirm:'Delete group "{name}"?',groups_manage_tasks:"Manage task assignments\u2026",groups_open_panel:"Open in panel",unassigned:"Unassigned",no_area:"No area",has_overdue:"Has overdue tasks",object:"Object",settings_panel_access:"Panel access",settings_panel_access_desc:"Admins always have full access. To delegate create, edit and delete to specific non-admins, switch this on and pick them below \u2014 everyone else sees only Complete and Skip.",settings_operator_write:"Allow selected users to create, edit & delete",settings_operator_write_desc:"Off: only admins can change content. On: the selected users below get full access too.",no_non_admin_users:"No non-admin users found. Add some in Settings \u2192 People.",owner_label:"Owner",feat_completion_actions:"Completion actions",feat_completion_actions_desc:"Per-task HA action on complete + quick-complete QR with pre-set values.",on_complete_action_title:"On complete: trigger HA action (optional)",on_complete_action_desc:"Calls an HA service when the task is completed \u2014 e.g. reset a counter on the device.",on_complete_action_service:"Service",on_complete_action_target:"Target entity",on_complete_action_target_hint:"Note: the entity domain must match the service \u2014 e.g. 'button.press' only works on button.*, 'counter.increment' only on counter.*, 'input_button.press' only on input_button.* etc. On a mismatch the action will silently fail (HA logs 'Referenced entities ... missing or not currently available').",on_complete_action_data:"Data (JSON, optional)",on_complete_action_test:"Validate configuration",on_complete_action_test_success:"\u2713 Configuration valid (action will fire only on task completion)",on_complete_action_test_failed:"Failed",quick_complete_defaults_title:"Quick-complete defaults (for QR scans, optional)",quick_complete_defaults_desc:"Pre-set values for quick-complete QR scans. Without these, the QR opens the complete dialog.",quick_complete_defaults_notes:"Notes",quick_complete_defaults_cost:"Cost",quick_complete_defaults_duration:"Duration (minutes)",quick_complete_defaults_feedback_none:"No feedback",quick_complete_defaults_feedback_needed:"Was needed",quick_complete_defaults_feedback_not_needed:"Not needed",quick_complete_success:"Quickly marked complete",show_all_objects:"Show all objects",show_all_tasks:"Clear filter \u2014 show all tasks",filter_to_overdue:"Filter task list to overdue only",filter_to_due_soon:"Filter task list to due-soon only",filter_to_triggered:"Filter task list to triggered only",open_task:"Open task",show_details:"Show history + stats",hide_details:"Hide details",history_empty:"No history yet.",history_edit_button:"Edit entry",total_cost:"Total cost",times_performed:"Performed",older_entries:"older",open_in_panel:"Open in Maintenance panel",skip_reason:"Skip reason (optional)",reset_to_date:"Reset last_performed to",delete_task_confirm:"Delete this task and its history?",delete_object_confirm:"Delete this object and all its tasks?",loading:"Loading\u2026",archive:"Archive",undo:"Undo",task_archived:"Task archived",object_archived:"Object archived",unarchive:"Unarchive",archived:"Archived",show_archived:"Show archived",hide_archived:"Hide archived",confirm_archive_object:"Archive this object and its tasks? They keep their history and can be unarchived later.",settings_archive:"Archive & Retention",settings_archive_desc:"Retire completed one-off tasks without deleting them. Archived items are hidden and inert but keep their history and cost.",settings_archive_oneoff_days:"Auto-archive completed one-off tasks after (days, 0 = off)",settings_delete_archived_oneoff_days:"Auto-delete archived one-off tasks after (days, 0 = never)",archive_object:"Archive object",unarchive_object:"Unarchive object",documents:"Documents",documents_empty:"No documents yet.",doc_upload:"Upload file",doc_uploading:"Uploading\u2026",doc_add_link:"Add link",doc_link_url:"URL (https://\u2026)",doc_link_title:"Title (optional)",doc_open:"Open",doc_delete_confirm:'Delete "{name}"?',doc_too_large:"File is too large (max 25 MB).",doc_upload_failed:"Upload failed.",completion_photo_optional:"Completion photo (optional)",add_photo:"Add photo",uploading:"Uploading\u2026",remove:"Remove",doc_deduped:"Already stored elsewhere \u2014 shared, no extra space used.",doc_dup_in_object:"This file is already attached to this object.",doc_link_invalid:"Only http/https links are allowed.",doc_cat_manual:"Manual",doc_cat_warranty:"Warranty",doc_cat_invoice:"Invoice",doc_cat_spare_parts:"Spare parts",doc_cat_photo:"Photo",doc_cat_other:"Other",doc_link_badge:"Link",doc_storage_title:"Document storage",doc_storage_saved:"Saved via deduplication",doc_storage_refresh:"Refresh",doc_download:"Download",doc_close:"Close",doc_camera:"Take photo",doc_drop_hint:"Drop files here",doc_task_none:"No documents linked to this task.",doc_link_existing:"Link a document\u2026",doc_attach:"Link",doc_unlink:"Unlink",doc_page:"Page",chart_range_7d:"7d",chart_range_30d:"30d",chart_range_90d:"90d",chart_range_1y:"1y",chart_since_service:"since last service",chart_no_stats:"No long-term statistics for this entity \u2014 showing maintenance-event values only",auto_complete_on_recovery:"Auto-complete when the sensor recovers",auto_complete_on_recovery_help:"Records a completion (sets last performed) when the trigger clears itself \u2014 e.g. salt refilled, filter replaced.",doc_search:"Search documents\u2026",doc_search_none:"No matching documents",link_device_optional:"Link to existing device (optional)",parent_object_optional:"Parent object (optional)",parent_none:"(No parent)",paused:"Paused",pause_object:"Pause",resume_object:"Resume",pause_until_prompt:"Freeze this object's schedules \u2014 nothing becomes due and nothing notifies until it is resumed. Optionally set an auto-resume date.",pause_until_label:"Resume on (optional)",object_paused:"Object paused",object_resumed:"Object resumed \u2014 schedules restarted",object_paused_badge:"Paused",paused_until_label:"until",replace_object:"Replace\u2026",replace_object_prompt:"Retire this object and create a successor. History and costs stay archived on the old one; tasks and documents carry over to the new one, counters start fresh.",replace_name_label:"Successor name",object_replaced:"Object replaced \u2014 successor created",reading_unit_label:"Reading unit (e.g. kWh, m\xB3)",reading_unit_help:"Shown next to the recorded value when completing this task.",reading_value_label:"Reading value",reading_label:"Reading",settings_templates_label:"Template gallery",settings_templates_hint:`Untick templates you'll never need \u2014 they disappear from the "From template" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.`,worksheet:"Work sheet",worksheet_scan_view:"Scan to open the task",worksheet_scan_complete:"Scan to complete",worksheet_manual_excerpt:"Manual excerpt",worksheet_pages:"pages",worksheet_printed:"Printed",worksheet_never:"Never",card_all_caught_up:"All caught up \u2014 nothing needs attention",postpone:"Postpone",postpone_date_prompt:"Postpone this occurrence to which date?",postpone_date_label:"New due date",postponed:"Postponed",postponed_to:"Postponed to",season_window_label:"Seasonal window (months)",season_window_hint:"Only due in the selected months; off-season dates roll to the next active month. None = all year.",series_end_label:"Ends",series_end_never:"Never (repeats indefinitely)",series_end_after_count:"After a number of times",series_end_until:"On a date",series_end_count_label:"Number of times",series_end_until_label:"End date",parts_section:"Parts & consumables",parts_inventory_value:"Inventory value",part_add:"Add part",part_name:"Name",part_vendor:"Manufacturer",part_storage_location:"Storage location",part_product_url:"Product URL",part_unit:"Unit",part_cost:"Unit price",part_stock:"Stock",part_reorder_threshold:"Reorder at",part_restock_quantity:"Restock quantity",part_auto_buy:"Auto-create buy task when low",part_restock:"Adjust stock",parts_used_by:"Used by",restock_quantity_label:"Quantity bought",consumes_parts_label:"Consumes parts",shared_parts_other_objects:"Parts from other objects",shared_parts_help:"Several objects can share one stock. Completing this task takes from the owning object.",shared_part_unknown:"Unknown part",parts_load_failed:"Couldn't load this object's parts \u2014 the consumes-parts options are unavailable right now.",adopt_problem_button:"Adopt problem sensors",adopt_problem_title:"Adopt problem sensors",adopt_problem_hint:"Turn HA problem sensors (printer errors, filter warnings, low battery) into maintenance tasks that trigger while the problem is active and clear themselves when it resolves.",adopt_problem_none:"No problem sensors found that aren't already tracked.",adopt_problem_active:"active",adopt_problem_ok:"ok",adopt_problem_new_object:"(new)",adopt_problem_adopt:"Adopt selected",adopt_problem_done:"Adopted {tasks} problem sensor(s)",views_label:"Views",views_none:"\u2014 No view \u2014",views_manage:"Save / manage views",views_dialog_title:"Saved views",views_dialog_hint:"Save the current filters as a named view everyone can reuse.",views_name_placeholder:"View name",views_save_current:"Save current filters",views_none_yet:"No saved views yet.",close:"Close",trigger_hint_now:"The sensor reads {value} right now.",trigger_hint_above:"The task triggers once it rises above {target}.",trigger_hint_below:"It triggers once it falls below {target}.",trigger_hint_counter_delta:"Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",trigger_hint_counter_delta_edit:"Counts usage since the last completion: due after +{target}; the count restarts after each completion.",trigger_hint_counter_abs:"The task becomes due once the sensor reaches {target}.",trigger_hint_runtime:"The task becomes due after {hours} h of accumulated on-time; the counter restarts after each completion.",trigger_hint_state_change:"The task becomes due after {count} state change(s).",trigger_hint_state_change_to:"The task becomes due after {count} change(s) to \u201C{state}\u201D.",trigger_hint_state_now:"Current state: {value}.",adopt_problem_part:"Uses part: {name}",label_filter:"Label",all_labels:"All labels",settings_notify_scope:"Notify only for view",settings_notify_scope_all:"All tasks",settings_notify_scope_hint:"Only tasks matching the selected saved view's label/user filters send reminders. Status, sorting and grouping of the view are ignored here.",card_saved_view:"Saved view",card_saved_view_none:"None",card_saved_view_help:"Applies the view's status, user and label filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",doc_part_none:"No documents linked to this part.",settings_templates_toggle_group:"Enable or disable all templates in this group",setups_button:"Suggested setups",setups_title:"Suggested setups (Beta)",setups_hint:"Devices of supported integrations whose consumable sensors can drive maintenance tasks. Adopting creates the object and wires each task to its sensor \u2014 it triggers when the consumable runs low and resolves itself after replacement.",setups_none:"No supported devices with unwired consumable sensors found.",setups_adopt:"Set up selected",setups_done:"{tasks} sensor-wired tasks created.",complete_parts_used:"Parts used this time",part_delete_confirm:"Delete part '{name}'? Its stock tracking, task links and any open buy reminder will be removed.",baseline_start_value:"Start reading (optional)",baseline_start_help:"Counting starts from this reading. Leave empty to count from the current value; enter the reading at the last service so usage since then already counts.",setups_baseline_hint:"reading at last service (optional)",baseline_start_help_edit:"Leave empty to keep the existing counting. Entering a value re-anchors the counting (e.g. the reading at the last service).",baseline_current_effective:"Currently effective start value: {value}",runtime_on_states:"Active states",runtime_on_states_help:"States that count as running \u2014 default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",setups_target_new:"Create new: {name}",schedule_preview_title:"Next dates",schedule_preview_ontime:"Assuming on-time completion.",schedule_preview_ends:"(series ends)",adopt_problem_responsible:"Responsible user for all adopted tasks (optional)",adopt_problem_configure:"Configure",history_auto:"Automatic",battery_fleet_title:"Battery fleet",battery_fleet_none_low:"All batteries OK \u2014 nothing to replace.",battery_fleet_buy_now:"Buy now",battery_fleet_soon:"Needed soon",battery_fleet_soon_hint:"Predicted from the last replacement date \u2014 order ahead.",battery_fleet_mark_all:"Mark all replaced",battery_fleet_mark_one:"Mark this battery replaced",battery_fleet_offline:"offline",battery_fleet_trigger_lost:"This task's sensor trigger was lost \u2014 it will not fire or auto-complete.",battery_fleet_repair:"Repair",battery_fleet_exclude:"Exclude from the fleet",battery_fleet_excluded:"Excluded",battery_fleet_include:"Track again",battery_fleet_all:"All tracked batteries",battery_fleet_all_hint:"Exclude a device here to drop it from the fleet before it ever reports low \u2014 a vacuum that recharges itself, or a phone that warns you on its own.",battery_fleet_status_low:"Low",battery_fleet_status_soon:"Soon",battery_fleet_status_ok:"Healthy",battery_fleet_predicted_on:"Expected around {date}",battery_fleet_predicted_trend:"Predicted from this battery's discharge trend: around {date} ({confidence})",battery_fleet_rechargeable:"Rechargeable: charge instead of replacing \u2014 never on the shopping list",battery_fleet_sort_name:"Sort by name",battery_fleet_sort_urgency:"Sort by urgency",battery_fleet_mark_recharged:"Mark as recharged",battery_fleet_sparkline_hint:"Battery level over the last 30 days \u2014 dotted: projected until the low threshold",battery_fleet_filter_type:"Show only this battery type",battery_fleet_record_replacement:"The level jumped around {date} \u2014 record this replacement in Battery Notes",battery_fleet_total:"{n} batteries tracked",battery_fleet_setup_button:"Battery fleet",battery_fleet_setup_done:"Battery fleet set up \u2014 one task tracks all your batteries.",update_banner:"A newer version of Maintenance Supporter is on the server \u2014 reload to update the panel.",update_reload:"Reload",battery_fleet_forecast_overdue:"Predicted date passed \u2014 the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",cost_from_parts:"Use \u2248 {amount} from parts",dismiss:"Dismiss",gs_label:"Getting started \u2014 these hints retire as your setup grows",gs_setups_chip:"Suggested setups found {n} devices with pre-wired triggers",gs_adopt_chip:"{n} problem sensors can become maintenance tasks",gs_fleet_chip:"One click sets up the battery fleet"}});var ie,Gt=$(()=>{"use strict";ie={ok:"var(--success-color, #4caf50)",due_soon:"var(--warning-color, #ff9800)",overdue:"var(--error-color, #f44336)",triggered:"var(--deep-orange-color, #ff5722)",archived:"var(--disabled-color, #9e9e9e)",paused:"var(--info-color, #2196f3)"}});function Ne(a){let s=(a||rt).toLowerCase();return s.startsWith("pt")&&s.endsWith("br")?"pt-br":s.substring(0,2)}function r(a,s){let e=Ne(s);return se[e]?.[a]??se.en[a]??a}function He(a){let s=Ne(a);return s===rt||s in se}function Me(a){let s=Ne(a);return s===rt||s in se||!ns.has(s)?Promise.resolve():(s in xe||(xe[s]=fetch(`${os}/${s}.json`).then(e=>e.ok?e.json():null).then(e=>{e?se[s]=e:delete xe[s]}).catch(()=>{delete xe[s]})),xe[s])}function $e(a){let s=Ne(a);return{de:"de-DE",en:"en-US",nl:"nl-NL",fr:"fr-FR",it:"it-IT",es:"es-ES",pt:"pt-PT",ru:"ru-RU",uk:"uk-UA",zh:"zh-CN",da:"da-DK",fi:"fi-FI",nb:"nb-NO",ja:"ja-JP",hi:"hi-IN",pl:"pl-PL",cs:"cs-CZ",sv:"sv-SE","pt-br":"pt-BR",hu:"hu-HU",ko:"ko-KR",tr:"tr-TR"}[s]??"en-US"}function Qt(a){a&&(Re.date=a.date_format,Re.time=a.time_format)}function Jt(a,s){let e=String(a.getDate()).padStart(2,"0"),t=String(a.getMonth()+1).padStart(2,"0"),i=String(a.getFullYear());switch(Re.date){case"DMY":return`${e}/${t}/${i}`;case"MDY":return`${t}/${e}/${i}`;case"YMD":return`${i}-${t}-${e}`;case"system":return a.toLocaleDateString(void 0,{day:"2-digit",month:"2-digit",year:"numeric"});default:return a.toLocaleDateString($e(s),{day:"2-digit",month:"2-digit",year:"numeric"})}}function ds(a,s){switch(Re.time){case"12":return a.toLocaleTimeString($e(s),{hour:"2-digit",minute:"2-digit",hour12:!0});case"24":return a.toLocaleTimeString($e(s),{hour:"2-digit",minute:"2-digit",hour12:!1});case"system":return a.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit"});default:return a.toLocaleTimeString($e(s),{hour:"2-digit",minute:"2-digit"})}}function K(a,s){if(!a)return"\u2014";try{let e=a.includes("T")?a:a+"T00:00:00";return Jt(new Date(e),s)}catch{return a}}function Zt(a,s){if(!a)return"\u2014";try{let e=new Date(a);return Jt(e,s)+" "+ds(e,s)}catch{return a}}function at(a,s){if(a==null)return"\u2014";let e=s||"en";return a<0?`${Math.abs(a)} ${r("d_overdue",e)}`:a===0?r("today",e):`${a} ${r(a===1?"day":"days",e)}`}function je(a,s,e){return a==null?"\u2014":`${a} ${r("unit_"+(s||"days"),e)}`}function we(a,s,e="long"){return new Date(Date.UTC(2024,0,1+a)).toLocaleDateString($e(s),{weekday:e,timeZone:"UTC"})}function Xt(a,s){let e=a.schedule,t=e?.offset?` ${e.offset>0?"+":"\u2212"}${Math.abs(e.offset)}d`:"";switch(e?.kind){case"weekdays":return((e.weekdays||[]).map(i=>we(i,s,"short")).join(" & ")||"\u2014")+t;case"nth_weekday":return e.weekday==null||e.nth==null?"\u2014":`${e.nth===-1?r("ord_last",s):r("ord_"+e.nth,s)} ${we(e.weekday,s,"long")}${t}`;case"day_of_month":return e.day==null?"\u2014":(e.day===-1?r(e.business?"last_business_day_month":"last_day_month",s):`${r("day_word",s)} ${e.day}`)+t;case"one_time":return a.due_date?K(a.due_date,s):r("one_time",s);case"manual":return r("manual",s);case"interval":return je(e.every,e.unit,s)}return a.schedule_type==="one_time"?a.due_date?K(a.due_date,s):r("one_time",s):a.schedule_type==="manual"?r("manual",s):a.schedule_type==="sensor_based"?r("sensor_based",s):a.interval_days!=null?je(a.interval_days,a.interval_unit,s):"\u2014"}function ei(a,s){a.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:s},bubbles:!0,composed:!0}))}var rt,Yt,se,ns,os,xe,ls,Re,ti,Oe,C=$(()=>{"use strict";L();Wt();Gt();rt="en",Yt=(()=>{let a=window;return a.__msLocales||(a.__msLocales={store:{},inflight:{}}),a.__msLocales})(),se=Yt.store;se.en||(se.en=Kt);ns=new Set(["de","nl","fr","it","es","pt","pt-br","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi","hu","ko","tr"]),os="/maintenance_supporter_locales",xe=Yt.inflight;ls=window,Re=ls.__msDateTimePrefs??={};ti=E` +Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:"{field}: too long (max {n} characters)",err_too_short:"{field}: too short (min {n} characters)",err_value_too_high:"{field}: too large (max {n})",err_value_too_low:"{field}: too small (min {n})",err_required:"{field}: required",err_wrong_type:"{field}: wrong type (expected: {type})",err_invalid_choice:"{field}: not an allowed value",err_invalid_value:"{field}: invalid value",feat_schedule_time:"Time-of-day scheduling",feat_schedule_time_desc:"Tasks become overdue at a specific time of day instead of midnight.",schedule_time_optional:"Due at time (optional, HH:MM)",schedule_time_help:"Empty = midnight (default). HA timezone.",at_time:"at",notes_optional:"Notes (optional)",cost_optional:"Cost (optional)",duration_minutes:"Duration in minutes (optional)",days:"days",day:"day",today:"Today",d_overdue:"d overdue",no_tasks:"No maintenance tasks yet. Create an object to get started.",no_tasks_short:"No tasks",no_history:"No history entries yet.",show_all:"Show all",cost_duration_chart:"Cost & Duration",installed:"Installed",confirm_delete_object:"Delete this object and all its tasks?",confirm_delete_task:"Delete this task?",min:"Min",max:"Max",save:"Save",saving:"Saving\u2026",edit_task:"Edit Task",new_task:"New Maintenance Task",task_name:"Task name",maintenance_type:"Maintenance type",priority:"Priority",labels:"Labels",labels_placeholder:"e.g. safety, seasonal, tenant-visible",labels_help:"Comma-separated tags for filtering and reporting.",priority_low:"Low",priority_normal:"Normal",priority_high:"High",schedule_type:"Schedule type",interval_days:"Interval (days)",warning_days:"Warning days",earliest_completion_days:"Earliest completion (days before due)",earliest_completion_days_help:"Leave empty to allow completing any time. 0 = only on/after the due date.",last_performed_optional:"Last performed (optional)",interval_anchor:"Interval anchor",anchor_completion:"From completion date",anchor_planned:"From planned date (no drift)",edit_object:"Edit Object",name:"Name",manufacturer_optional:"Manufacturer (optional)",model_optional:"Model (optional)",serial_number_optional:"Serial number (optional)",serial_number_label:"S/N",documentation_url_label:"Manual",object_notes_label:"Notes",sort_due_date:"Due date",sort_object:"Object name",sort_type:"Type",sort_task_name:"Task name",all_objects:"All objects",all_parts:"All parts",tasks_lower:"tasks",no_tasks_yet:"No tasks yet",add_first_task:"Add first task",trigger_configuration:"Trigger Configuration",entity_id:"Entity ID",comma_separated:"comma-separated",entity_logic:"Entity logic",entity_logic_any:"Any entity triggers",entity_logic_all:"All entities must trigger",entities:"entities",attribute_optional:"Attribute (optional, blank = state)",use_entity_state:"Use entity state (no attribute)",trigger_above:"Trigger above",trigger_below:"Trigger below",for_at_least_minutes:"For at least (minutes)",safety_interval_days:"Safety interval (days, optional)",safety_interval:"Safety interval (optional)",delta_mode:"Delta mode",from_state_optional:"From state (optional)",to_state_optional:"To state (optional)",documentation_url_optional:"Documentation URL (optional)",object_notes_optional:"Notes (optional)",nfc_tag_id_optional:"NFC Tag ID (optional)",nfc_tags_empty_help:"No NFC tags registered in Home Assistant yet.",nfc_tags_open_settings:"Open Tags settings",nfc_tags_refresh:"Refresh",environmental_entity_optional:"Environmental sensor (optional)",environmental_entity_helper:"e.g. sensor.outdoor_temperature \u2014 adjusts the interval based on environmental conditions",adaptive_prediction_enabled:"Enable sensor-driven predictions",adaptive_seasonal_enabled:"Enable seasonal awareness",adaptive_max_interval:"Maximum interval (days)",adaptive_min_interval:"Minimum interval (days)",adaptive_ewa_alpha:"Learning rate (alpha)",adaptive_enabled:"Enable adaptive scheduling",adaptive_section_title:"Adaptive Scheduling",environmental_attribute_optional:"Environmental attribute (optional)",nfc_tag_id:"NFC Tag ID",nfc_linked:"NFC tag linked",nfc_link_hint:"Click to link NFC tag",responsible_user:"Responsible User",shared_with:"Shared with (rotation)",shared_with_help:"Pick multiple people to share this task; the responsible person rotates on each completion.",rotation_strategy:"Rotation",rotation_none:"No rotation",rotation_round_robin:"Round-robin",rotation_least_completed:"Least completed",rotation_random:"Random",no_user_assigned:"(No user assigned)",all_users:"All Users",my_tasks:"My Tasks",tab_calendar:"Calendar",cal_no_events:"No maintenance",cal_window_7:"7 days",cal_window_14:"14 days",cal_window_30:"30 days",cal_window_365:"1 year",cal_every_n_days:"every {n} days",cal_source_time:"Time-based",cal_source_time_adaptive:"Time-based (adaptive)",cal_source_sensor:"Sensor-based",cal_predicted:"predicted",cal_confidence_high:"high confidence",cal_confidence_medium:"medium confidence",cal_confidence_low:"low confidence",budget_monthly:"Monthly budget",budget_yearly:"Yearly budget",groups:"Groups",new_group:"New group",edit_group:"Edit group",no_groups:"No groups yet",delete_group:"Delete group",delete_group_confirm:"Delete group '{name}'?",group_select_tasks:"Select tasks",group_name_required:"Name is required",description_optional:"Description (optional)",selected:"Selected",loading_chart:"Loading chart data...",hide_outliers:"Hide outliers (sensor glitches)",was_maintenance_needed:"Was this maintenance needed?",feedback_needed:"Needed",feedback_not_needed:"Not needed",feedback_not_sure:"Not sure",suggested_interval:"Suggested interval",apply_suggestion:"Apply",reanalyze:"Re-analyze",reanalyze_result:"New analysis",reanalyze_insufficient_data:"Not enough data to produce a recommendation",data_points:"data points",dismiss_suggestion:"Dismiss",confidence_low:"Low",confidence_medium:"Medium",confidence_high:"High",recommended:"recommended",seasonal_awareness:"Seasonal Awareness",edit_seasonal_overrides:"Edit seasonal factors",seasonal_overrides_title:"Seasonal factors (override)",seasonal_overrides_hint:"Factor per month (0.1\u20135.0). Empty = learned automatically.",seasonal_override_invalid:"Invalid value",seasonal_override_range:"Factor must be between 0.1 and 5.0",clear_all:"Clear all",seasonal_chart_title:"Seasonal Factors",seasonal_learned:"Learned",seasonal_manual:"Manual",month_jan:"Jan",month_feb:"Feb",month_mar:"Mar",month_apr:"Apr",month_may:"May",month_jun:"Jun",month_jul:"Jul",month_aug:"Aug",month_sep:"Sep",month_oct:"Oct",month_nov:"Nov",month_dec:"Dec",sensor_prediction:"Sensor Prediction",degradation_trend:"Trend",trend_rising:"Rising",trend_falling:"Falling",trend_stable:"Stable",trend_insufficient_data:"Insufficient data",days_until_threshold:"Days until threshold",threshold_exceeded:"Threshold exceeded",environmental_adjustment:"Environmental factor",sensor_prediction_urgency:"Sensor predicts threshold in ~{days} days",day_short:"day",weibull_reliability_curve:"Reliability Curve",weibull_failure_probability:"Failure Probability",weibull_r_squared:"Fit R\xB2",beta_early_failures:"Early Failures",beta_random_failures:"Random Failures",beta_wear_out:"Wear-out",beta_highly_predictable:"Highly Predictable",confidence_interval:"Confidence Interval",confidence_conservative:"Conservative",confidence_aggressive:"Optimistic",current_interval_marker:"Current interval",recommended_marker:"Recommended",characteristic_life:"Characteristic life",chart_mini_sparkline:"Trend sparkline",chart_history:"Cost and duration history",chart_seasonal:"Seasonal factors, 12 months",chart_weibull:"Weibull reliability curve",chart_sparkline:"Sensor trigger value chart",days_progress:"Days progress",qr_code:"QR Code",qr_generating:"Generating QR code\u2026",qr_error:"Failed to generate QR code.",qr_error_no_url:"No HA URL configured. Please set an external or internal URL in Settings \u2192 System \u2192 Network.",save_error:"Failed to save. Please try again.",qr_print:"Print",qr_download:"Download SVG",qr_action:"Action on scan",qr_action_view:"View maintenance info",qr_action_complete:"Mark maintenance as complete",qr_url_mode:"Link type",qr_mode_companion:"Companion App",qr_mode_local:"Local (mDNS)",qr_mode_server:"Server URL",overview:"Overview",analysis:"Analysis",recent_activities:"Recent Activities",search_notes:"Search notes",avg_cost:"Avg Cost",no_advanced_features:"No advanced features enabled",no_advanced_features_hint:"Enable \u201CAdaptive Intervals\u201D or \u201CSeasonal Patterns\u201D in the integration settings to see analysis data here.",analysis_not_enough_data:"Not enough data for analysis yet.",analysis_not_enough_data_hint:"Weibull analysis requires at least 5 completed maintenances; seasonal patterns become visible after 6+ data points per month.",analysis_manual_task_hint:"Manual tasks without an interval do not generate analysis data.",completions:"completions",current:"Current",shorter:"Shorter",longer:"Longer",normal:"Normal",disabled:"Disabled",compound_logic:"Compound logic",compound:"Compound (multiple conditions)",compound_logic_and:"AND \u2014 all conditions must trigger",compound_logic_or:"OR \u2014 any condition triggers",compound_help:"Combine several sensor conditions into one trigger.",compound_no_conditions:"No conditions yet \u2014 add at least one.",compound_add_condition:"Add condition",compound_condition:"Condition",compound_remove_condition:"Remove condition",card_title:"Title",card_show_header:"Show header with statistics",card_show_actions:"Show action buttons",card_compact:"Compact mode",card_max_items:"Max items (0 = all)",card_filter_status:"Filter by status",card_filter_status_help:"Empty = show all statuses.",card_filter_objects:"Filter by objects",card_filter_objects_help:"Empty = show all objects.",card_filter_areas:"Filter by areas",card_filter_areas_help:"Empty = show all areas.",card_filter_entities:"Filter by entities (entity_ids)",card_filter_entities_help:"Pick sensor / binary_sensor entities from this integration. Empty = all.",card_loading_objects:"Loading objects\u2026",card_load_error:"Could not load objects \u2014 check the WebSocket connection.",card_no_tasks_title:"No maintenance tasks yet",card_no_tasks_cta:"\u2192 Create one in the Maintenance panel",no_objects:"No objects yet.",action_error:"Action failed. Please try again.",area_id_optional:"Area (optional)",installation_date_optional:"Installation date (optional)",warranty_expiry_optional:"Warranty expiry (optional)",warranty:"Warranty",warranty_valid_until:"valid until {date}",warranty_expires_in:"expires in {days} days",warranty_expired:"expired",cal_past_windows:"Past windows",cal_forward_windows:"Forward windows",history_edit_title:"Edit history entry",history_edit_timestamp:"Timestamp",manufacturer:"Manufacturer",model:"Model",area:"Area",actions:"Actions",view_mode_label:"View",view_cards:"Card view",view_table:"Table view",objects_table_columns_label:"Objects table columns",objects_table_columns_hint:"Choose which columns appear in the objects table view.",custom_icon_optional:"Icon (optional, e.g. mdi:wrench)",task_enabled:"Task enabled",skip_reason_prompt:"Skip this task?",reason_optional:"Reason (optional)",reset_date_prompt:"Mark task as performed?",reset_date_optional:"Last performed date (optional, defaults to today)",notes_label:"Notes",documentation_label:"Documentation",no_nfc_tag:"\u2014 No tag \u2014",dashboard:"Dashboard",tab_today:"Today",palette_placeholder:"Search objects and tasks\u2026",palette_no_results:"No matches",palette_hint:"\u2191\u2193 to navigate \xB7 Enter to open \xB7 Esc to close",today_all_caught_up:"All caught up! Nothing due this week.",today_overdue:"Overdue",today_due_today:"Due today",today_this_week:"This week",settings:"Settings",settings_features:"Advanced Features",settings_features_desc:"Enable or disable advanced features. Disabling hides them from the UI but does not delete data.",feat_adaptive:"Adaptive Scheduling",feat_adaptive_desc:"Learn optimal intervals from maintenance history",feat_predictions:"Sensor Predictions",feat_predictions_desc:"Predict trigger dates from sensor degradation",feat_seasonal:"Seasonal Adjustments",feat_seasonal_desc:"Adjust intervals based on seasonal patterns",feat_environmental:"Environmental Correlation",feat_environmental_desc:"Correlate intervals with temperature/humidity",feat_budget:"Budget Tracking",feat_budget_desc:"Track monthly and yearly maintenance spending",feat_groups:"Task Groups",feat_groups_desc:"Organize tasks into logical groups",feat_checklists:"Checklists",feat_checklists_desc:"Multi-step procedures for task completion",settings_general:"General",settings_default_warning:"Default warning days",settings_panel_enabled:"Sidebar panel",settings_panel_title:"Sidebar panel title",settings_notifications:"Notifications",settings_notify_service:"Notification service",settings_install_assist_sentences:"Install Assist sentences",settings_install_assist_sentences_hint:"Copies the voice sentences into your configuration so the classic Assist agent recognises them. A file you edited yourself is never overwritten.",test_notification:"Test notification",send_test:"Send test",testing:"Sending\u2026",test_notification_success:"Test notification sent",test_notification_failed:"Test notification failed",notify_per_person:"Per-person delivery",notify_no_own_device:"No own device \u2014 uses the household service",settings_notify_due_soon:"Notify when due soon",settings_notify_overdue:"Notify when overdue",settings_notify_triggered:"Notify when triggered",settings_interval_hours:"Repeat interval (hours, 0 = once)",settings_quiet_hours:"Quiet hours",settings_quiet_start:"Start",settings_quiet_end:"End",settings_max_per_day:"Max notifications per day (0 = unlimited)",settings_bundling:"Bundle notifications",settings_bundle_threshold:"Bundle threshold",settings_reminder_leads:"Extra reminders (days before due)",settings_reminder_leads_hint:"Comma-separated lead times, e.g. 14, 3, 0 \u2014 one extra reminder fires on each matching day. Empty = off.",settings_actions:"Mobile Action Buttons",settings_action_complete:"Show 'Complete' button",settings_action_skip:"Show 'Skip' button",settings_action_snooze:"Show 'Snooze' button",settings_weekly_digest:"Weekly digest",settings_weekly_digest_hint:"A single summary notification on Monday morning when tasks are due.",settings_warranty_reminder:"Warranty expiry reminder",settings_warranty_reminder_days:"Days before expiry",settings_warranty_reminder_hint:"Notify once when an object's warranty is this many days from expiring.",settings_snooze_hours:"Snooze duration (hours)",settings_budget:"Budget",settings_currency:"Currency",settings_budget_monthly:"Monthly budget",settings_budget_yearly:"Yearly budget",settings_budget_alerts:"Budget alerts",settings_budget_threshold:"Alert threshold (%)",settings_import_export:"Import / Export",settings_export_json:"Export JSON",settings_export_yaml:"Export YAML",settings_export_csv:"Export CSV",settings_export_settings:"Export settings (JSON)",settings_import_csv:"Import CSV",settings_import_placeholder:"Paste JSON or CSV content here\u2026",settings_import_btn:"Import",settings_import_success:"{count} objects imported successfully.",settings_export_success:"Export downloaded.",settings_saved:"Setting saved.",settings_include_history:"Include history",settings_export_selection:"Limit to selected objects (optional)",settings_docs_archive:"Documents archive (with files)",settings_docs_archive_hint:"The JSON/YAML/CSV exports carry settings only. This ZIP includes the uploaded file contents so a restore is complete.",settings_docs_export_btn:"Download documents ZIP",settings_docs_import_btn:"Restore documents ZIP",settings_docs_import_success:"Restored: {blobs} files, {docs} documents",sort_alphabetical:"Alphabetical",sort_due_soonest:"Due soonest",sort_task_count:"Task count",sort_area:"Area",sort_assigned_user:"Assigned user",sort_group:"Group",groupby_none:"No grouping",groupby_area:"By area",groupby_group:"By group",groupby_user:"By user",filter_label:"Filter",user_label:"User",photo_label:"Photo",sort_label:"Sort",group_by_label:"Group by",state_value_help:'Use the HA state value (usually lowercase, e.g. "on"/"off"). Case is normalised on save.',target_changes_help:"Number of matching transitions before the trigger fires (default: 1).",qr_print_title:"Print QR codes",qr_print_desc:"Generate a printable page of QR codes to cut out and stick on your equipment.",qr_print_load:"Load objects",qr_print_filter:"Filter",qr_print_objects:"Objects",qr_print_actions:"Actions",qr_print_url_mode:"Link type",qr_print_estimate:"Estimated QR codes",qr_print_over_limit:"cap is 200, narrow the filter",qr_print_generate:"Generate QR codes",qr_print_generating:"Generating\u2026",qr_print_ready:"QR codes ready",qr_print_print_button:"Print",qr_print_empty:"Nothing to generate",qr_action_skip:"Skip",vacation_title:"Vacation mode",vacation_active:"active",vacation_ended:"ended",vacation_desc:"Plan a vacation: notifications are paused during the period plus a buffer of days. You can opt specific tasks back in.",vacation_enable:"Enable vacation mode",vacation_start:"Start",vacation_end:"End",vacation_buffer:"Buffer (days)",vacation_exempt_title:"Notify anyway during vacation",vacation_exempt_desc:"Pick tasks that should still notify during vacation (e.g. critical pool chemistry).",vacation_load_tasks:"Load tasks",vacation_preview_btn:"Show preview",vacation_preview_affected:"tasks affected",vacation_event_due_soon:"becomes due soon",vacation_event_overdue:"becomes overdue",vacation_event_triggered_est:"sensor trigger possible",vacation_sensor_based:"(sensor-based)",vacation_action_notify:"Notify anyway",vacation_action_unsilence:"Silence again",vacation_marked_complete:"Marked complete",vacation_marked_skip:"Skipped",vacation_end_now:"End vacation now",add:"Add",show_stats:"Show stats + graphs",hide_stats:"Hide stats",adaptive_no_data:"Not enough completion history yet for adaptive analysis. Complete this task a few more times to unlock interval recommendations and reliability charts.",suggestion_applied:"Suggested interval applied",vacation_mode:"Vacation mode",vacation_status_active:"Active now",vacation_status_scheduled:"Scheduled",vacation_status_inactive:"Inactive",vacation_end_now_confirm:"End vacation immediately?",vacation_exempt_count:"exempt",vacation_advanced:"Advanced\u2026",vacation_open_panel:"Open in panel",enable:"Enable",saved:"Saved",budget_monthly_set:"Set monthly",budget_yearly_set:"Set yearly",budget_advanced:"Currency, alerts\u2026",budget_open_panel:"Open in panel",groups_empty:"No groups yet.",group_new_placeholder:"Add group\u2026",group_delete_confirm:'Delete group "{name}"?',groups_manage_tasks:"Manage task assignments\u2026",groups_open_panel:"Open in panel",unassigned:"Unassigned",no_area:"No area",has_overdue:"Has overdue tasks",object:"Object",settings_panel_access:"Panel access",settings_panel_access_desc:"Admins always have full access. To delegate create, edit and delete to specific non-admins, switch this on and pick them below \u2014 everyone else sees only Complete and Skip.",settings_operator_write:"Allow selected users to create, edit & delete",settings_operator_write_desc:"Off: only admins can change content. On: the selected users below get full access too.",no_non_admin_users:"No non-admin users found. Add some in Settings \u2192 People.",owner_label:"Owner",feat_completion_actions:"Completion actions",feat_completion_actions_desc:"Per-task HA action on complete + quick-complete QR with pre-set values.",on_complete_action_title:"On complete: trigger HA action (optional)",on_complete_action_desc:"Calls an HA service when the task is completed \u2014 e.g. reset a counter on the device.",on_complete_action_service:"Service",on_complete_action_target:"Target entity",on_complete_action_target_hint:"Note: the entity domain must match the service \u2014 e.g. 'button.press' only works on button.*, 'counter.increment' only on counter.*, 'input_button.press' only on input_button.* etc. On a mismatch the action will silently fail (HA logs 'Referenced entities ... missing or not currently available').",on_complete_action_data:"Data (JSON, optional)",on_complete_action_test:"Validate configuration",on_complete_action_test_success:"\u2713 Configuration valid (action will fire only on task completion)",on_complete_action_test_failed:"Failed",quick_complete_defaults_title:"Quick-complete defaults (for QR scans, optional)",quick_complete_defaults_desc:"Pre-set values for quick-complete QR scans. Without these, the QR opens the complete dialog.",quick_complete_defaults_notes:"Notes",quick_complete_defaults_cost:"Cost",quick_complete_defaults_duration:"Duration (minutes)",quick_complete_defaults_feedback_none:"No feedback",quick_complete_defaults_feedback_needed:"Was needed",quick_complete_defaults_feedback_not_needed:"Not needed",quick_complete_success:"Quickly marked complete",show_all_objects:"Show all objects",show_all_tasks:"Clear filter \u2014 show all tasks",filter_to_overdue:"Filter task list to overdue only",filter_to_due_soon:"Filter task list to due-soon only",filter_to_triggered:"Filter task list to triggered only",open_task:"Open task",show_details:"Show history + stats",hide_details:"Hide details",history_empty:"No history yet.",history_edit_button:"Edit entry",total_cost:"Total cost",times_performed:"Performed",older_entries:"older",open_in_panel:"Open in Maintenance panel",skip_reason:"Skip reason (optional)",reset_to_date:"Reset last_performed to",delete_task_confirm:"Delete this task and its history?",delete_object_confirm:"Delete this object and all its tasks?",loading:"Loading\u2026",archive:"Archive",undo:"Undo",task_archived:"Task archived",object_archived:"Object archived",unarchive:"Unarchive",archived:"Archived",show_archived:"Show archived",hide_archived:"Hide archived",confirm_archive_object:"Archive this object and its tasks? They keep their history and can be unarchived later.",settings_archive:"Archive & Retention",settings_archive_desc:"Retire completed one-off tasks without deleting them. Archived items are hidden and inert but keep their history and cost.",settings_archive_oneoff_days:"Auto-archive completed one-off tasks after (days, 0 = off)",settings_delete_archived_oneoff_days:"Auto-delete archived one-off tasks after (days, 0 = never)",archive_object:"Archive object",unarchive_object:"Unarchive object",documents:"Documents",documents_empty:"No documents yet.",doc_upload:"Upload file",doc_uploading:"Uploading\u2026",doc_add_link:"Add link",doc_link_url:"URL (https://\u2026)",doc_link_title:"Title (optional)",doc_open:"Open",doc_delete_confirm:'Delete "{name}"?',doc_too_large:"File is too large (max 25 MB).",doc_upload_failed:"Upload failed.",completion_photo_optional:"Completion photo (optional)",add_photo:"Add photo",uploading:"Uploading\u2026",remove:"Remove",doc_deduped:"Already stored elsewhere \u2014 shared, no extra space used.",doc_dup_in_object:"This file is already attached to this object.",doc_link_invalid:"Only http/https links are allowed.",doc_cat_manual:"Manual",doc_cat_warranty:"Warranty",doc_cat_invoice:"Invoice",doc_cat_spare_parts:"Spare parts",doc_cat_photo:"Photo",doc_cat_other:"Other",doc_link_badge:"Link",doc_storage_title:"Document storage",doc_storage_saved:"Saved via deduplication",doc_storage_refresh:"Refresh",doc_download:"Download",doc_close:"Close",doc_camera:"Take photo",doc_drop_hint:"Drop files here",doc_task_none:"No documents linked to this task.",doc_link_existing:"Link a document\u2026",doc_attach:"Link",doc_unlink:"Unlink",doc_page:"Page",chart_range_7d:"7d",chart_range_30d:"30d",chart_range_90d:"90d",chart_range_1y:"1y",chart_since_service:"since last service",chart_no_stats:"No long-term statistics for this entity \u2014 showing maintenance-event values only",auto_complete_on_recovery:"Auto-complete when the sensor recovers",auto_complete_on_recovery_help:"Records a completion (sets last performed) when the trigger clears itself \u2014 e.g. salt refilled, filter replaced.",doc_search:"Search documents\u2026",doc_search_none:"No matching documents",link_device_optional:"Link to existing device (optional)",parent_object_optional:"Parent object (optional)",parent_none:"(No parent)",paused:"Paused",pause_object:"Pause",resume_object:"Resume",pause_until_prompt:"Freeze this object's schedules \u2014 nothing becomes due and nothing notifies until it is resumed. Optionally set an auto-resume date.",pause_until_label:"Resume on (optional)",object_paused:"Object paused",object_resumed:"Object resumed \u2014 schedules restarted",object_paused_badge:"Paused",paused_until_label:"until",replace_object:"Replace\u2026",replace_object_prompt:"Retire this object and create a successor. History and costs stay archived on the old one; tasks and documents carry over to the new one, counters start fresh.",replace_name_label:"Successor name",object_replaced:"Object replaced \u2014 successor created",reading_unit_label:"Reading unit (e.g. kWh, m\xB3)",reading_unit_help:"Shown next to the recorded value when completing this task.",reading_value_label:"Reading value",reading_label:"Reading",settings_templates_label:"Template gallery",settings_templates_hint:`Untick templates you'll never need \u2014 they disappear from the "From template" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.`,worksheet:"Work sheet",worksheet_scan_view:"Scan to open the task",worksheet_scan_complete:"Scan to complete",worksheet_manual_excerpt:"Manual excerpt",worksheet_pages:"pages",worksheet_printed:"Printed",worksheet_never:"Never",card_all_caught_up:"All caught up \u2014 nothing needs attention",postpone:"Postpone",postpone_date_prompt:"Postpone this occurrence to which date?",postpone_date_label:"New due date",postponed:"Postponed",postponed_to:"Postponed to",season_window_label:"Seasonal window (months)",season_window_hint:"Only due in the selected months; off-season dates roll to the next active month. None = all year.",series_end_label:"Ends",series_end_never:"Never (repeats indefinitely)",series_end_after_count:"After a number of times",series_end_until:"On a date",series_end_count_label:"Number of times",series_end_until_label:"End date",parts_section:"Parts & consumables",parts_inventory_value:"Inventory value",part_add:"Add part",part_name:"Name",part_vendor:"Manufacturer",part_storage_location:"Storage location",part_product_url:"Product URL",part_unit:"Unit",part_cost:"Unit price",part_stock:"Stock",part_reorder_threshold:"Reorder at",part_restock_quantity:"Restock quantity",part_auto_buy:"Auto-create buy task when low",part_restock:"Adjust stock",parts_used_by:"Used by",restock_quantity_label:"Quantity bought",consumes_parts_label:"Consumes parts",shared_parts_other_objects:"Parts from other objects",shared_parts_help:"Several objects can share one stock. Completing this task takes from the owning object.",shared_part_unknown:"Unknown part",parts_load_failed:"Couldn't load this object's parts \u2014 the consumes-parts options are unavailable right now.",adopt_problem_button:"Adopt problem sensors",adopt_problem_title:"Adopt problem sensors",adopt_problem_hint:"Turn HA problem sensors (printer errors, filter warnings, low battery) into maintenance tasks that trigger while the problem is active and clear themselves when it resolves.",adopt_problem_none:"No problem sensors found that aren't already tracked.",adopt_problem_active:"active",adopt_problem_ok:"ok",adopt_problem_new_object:"(new)",adopt_problem_adopt:"Adopt selected",adopt_problem_done:"Adopted {tasks} problem sensor(s)",views_label:"Views",views_none:"\u2014 No view \u2014",views_manage:"Save / manage views",views_dialog_title:"Saved views",views_dialog_hint:"Save the current filters as a named view everyone can reuse.",views_name_placeholder:"View name",views_save_current:"Save current filters",views_none_yet:"No saved views yet.",close:"Close",trigger_hint_now:"The sensor reads {value} right now.",trigger_hint_above:"The task triggers once it rises above {target}.",trigger_hint_below:"It triggers once it falls below {target}.",trigger_hint_counter_delta:"Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",trigger_hint_counter_delta_edit:"Counts usage since the last completion: due after +{target}; the count restarts after each completion.",trigger_hint_counter_abs:"The task becomes due once the sensor reaches {target}.",trigger_hint_runtime:"The task becomes due after {hours} h of accumulated on-time; the counter restarts after each completion.",trigger_hint_state_change:"The task becomes due after {count} state change(s).",trigger_hint_state_change_to:"The task becomes due after {count} change(s) to \u201C{state}\u201D.",trigger_hint_state_now:"Current state: {value}.",adopt_problem_part:"Uses part: {name}",label_filter:"Label",all_labels:"All labels",settings_notify_scope:"Notify only for view",settings_notify_scope_all:"All tasks",settings_notify_scope_hint:"Only tasks matching the selected saved view's label/user filters send reminders. Status, sorting and grouping of the view are ignored here.",card_saved_view:"Saved view",card_saved_view_none:"None",card_saved_view_help:"Applies the view's status, user and label filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",doc_part_none:"No documents linked to this part.",settings_templates_toggle_group:"Enable or disable all templates in this group",setups_button:"Suggested setups",setups_title:"Suggested setups (Beta)",setups_hint:"Devices of supported integrations whose consumable sensors can drive maintenance tasks. Adopting creates the object and wires each task to its sensor \u2014 it triggers when the consumable runs low and resolves itself after replacement.",setups_none:"No supported devices with unwired consumable sensors found.",setups_adopt:"Set up selected",setups_done:"{tasks} sensor-wired tasks created.",complete_parts_used:"Parts used this time",part_delete_confirm:"Delete part '{name}'? Its stock tracking, task links and any open buy reminder will be removed.",baseline_start_value:"Start reading (optional)",baseline_start_help:"Counting starts from this reading. Leave empty to count from the current value; enter the reading at the last service so usage since then already counts.",setups_baseline_hint:"reading at last service (optional)",baseline_start_help_edit:"Leave empty to keep the existing counting. Entering a value re-anchors the counting (e.g. the reading at the last service).",baseline_current_effective:"Currently effective start value: {value}",runtime_on_states:"Active states",runtime_on_states_help:"States that count as running \u2014 default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",setups_target_new:"Create new: {name}",schedule_preview_title:"Next dates",schedule_preview_ontime:"Assuming on-time completion.",schedule_preview_ends:"(series ends)",adopt_problem_responsible:"Responsible user for all adopted tasks (optional)",adopt_problem_configure:"Configure",history_auto:"Automatic",battery_fleet_title:"Battery fleet",battery_fleet_none_low:"All batteries OK \u2014 nothing to replace.",battery_fleet_buy_now:"Buy now",battery_fleet_soon:"Needed soon",battery_fleet_soon_hint:"Predicted from the last replacement date \u2014 order ahead.",battery_fleet_mark_all:"Mark all replaced",battery_fleet_mark_one:"Mark this battery replaced",battery_fleet_offline:"offline",battery_fleet_trigger_lost:"This task's sensor trigger was lost \u2014 it will not fire or auto-complete.",battery_fleet_repair:"Repair",battery_fleet_exclude:"Exclude from the fleet",battery_fleet_excluded:"Excluded",battery_fleet_include:"Track again",battery_fleet_all:"All tracked batteries",battery_fleet_all_hint:"Exclude a device here to drop it from the fleet before it ever reports low \u2014 a vacuum that recharges itself, or a phone that warns you on its own.",battery_fleet_status_low:"Low",battery_fleet_status_soon:"Soon",battery_fleet_status_ok:"Healthy",battery_fleet_predicted_on:"Expected around {date}",battery_fleet_predicted_trend:"Predicted from this battery's discharge trend: around {date} ({confidence})",battery_fleet_rechargeable:"Rechargeable: charge instead of replacing \u2014 never on the shopping list",battery_fleet_sort_name:"Sort by name",battery_fleet_sort_urgency:"Sort by urgency",battery_fleet_mark_recharged:"Mark as recharged",battery_fleet_sparkline_hint:"Battery level over the last 30 days \u2014 dotted: projected until the low threshold",battery_fleet_filter_type:"Show only this battery type",battery_fleet_record_replacement:"The level jumped around {date} \u2014 record this replacement in Battery Notes",battery_fleet_total:"{n} batteries tracked",battery_fleet_setup_button:"Battery fleet",battery_fleet_setup_done:"Battery fleet set up \u2014 one task tracks all your batteries.",update_banner:"A newer version of Maintenance Supporter is on the server \u2014 reload to update the panel.",update_reload:"Reload",battery_fleet_forecast_overdue:"Predicted date passed \u2014 the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",cost_from_parts:"Use \u2248 {amount} from parts",dismiss:"Dismiss",gs_label:"Getting started \u2014 these hints retire as your setup grows",gs_setups_chip:"Suggested setups found {n} devices with pre-wired triggers",gs_adopt_chip:"{n} problem sensors can become maintenance tasks",gs_fleet_chip:"One click sets up the battery fleet"}});var re,Qt=$(()=>{"use strict";re={ok:"var(--success-color, #4caf50)",due_soon:"var(--warning-color, #ff9800)",overdue:"var(--error-color, #f44336)",triggered:"var(--deep-orange-color, #ff5722)",archived:"var(--disabled-color, #9e9e9e)",paused:"var(--info-color, #2196f3)"}});function Oe(a){let s=(a||nt).toLowerCase();return s.startsWith("pt")&&s.endsWith("br")?"pt-br":s.substring(0,2)}function r(a,s){let e=Oe(s);return ae[e]?.[a]??ae.en[a]??a}function N(a){return a?.language||"en"}function Me(a){let s=Oe(a);return s===nt||s in ae}function qe(a){let s=Oe(a);return s===nt||s in ae||!cs.has(s)?Promise.resolve():(s in we||(we[s]=fetch(`${ps}/${s}.json`).then(e=>e.ok?e.json():null).then(e=>{e?ae[s]=e:delete we[s]}).catch(()=>{delete we[s]})),we[s])}function ke(a){let s=Oe(a);return{de:"de-DE",en:"en-US",nl:"nl-NL",fr:"fr-FR",it:"it-IT",es:"es-ES",pt:"pt-PT",ru:"ru-RU",uk:"uk-UA",zh:"zh-CN",da:"da-DK",fi:"fi-FI",nb:"nb-NO",ja:"ja-JP",hi:"hi-IN",pl:"pl-PL",cs:"cs-CZ",sv:"sv-SE","pt-br":"pt-BR",hu:"hu-HU",ko:"ko-KR",tr:"tr-TR"}[s]??"en-US"}function Zt(a){a&&(Ne.date=a.date_format,Ne.time=a.time_format)}function Xt(a,s){let e=String(a.getDate()).padStart(2,"0"),t=String(a.getMonth()+1).padStart(2,"0"),i=String(a.getFullYear());switch(Ne.date){case"DMY":return`${e}/${t}/${i}`;case"MDY":return`${t}/${e}/${i}`;case"YMD":return`${i}-${t}-${e}`;case"system":return a.toLocaleDateString(void 0,{day:"2-digit",month:"2-digit",year:"numeric"});default:return a.toLocaleDateString(ke(s),{day:"2-digit",month:"2-digit",year:"numeric"})}}function _s(a,s){switch(Ne.time){case"12":return a.toLocaleTimeString(ke(s),{hour:"2-digit",minute:"2-digit",hour12:!0});case"24":return a.toLocaleTimeString(ke(s),{hour:"2-digit",minute:"2-digit",hour12:!1});case"system":return a.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit"});default:return a.toLocaleTimeString(ke(s),{hour:"2-digit",minute:"2-digit"})}}function G(a,s){if(!a)return"\u2014";try{let e=a.includes("T")?a:a+"T00:00:00";return Xt(new Date(e),s)}catch{return a}}function ei(a,s){if(!a)return"\u2014";try{let e=new Date(a);return Xt(e,s)+" "+_s(e,s)}catch{return a}}function ot(a,s){if(a==null)return"\u2014";let e=s||"en";return a<0?`${Math.abs(a)} ${r("d_overdue",e)}`:a===0?r("today",e):`${a} ${r(a===1?"day":"days",e)}`}function He(a,s,e){return a==null?"\u2014":`${a} ${r("unit_"+(s||"days"),e)}`}function Ee(a,s,e="long"){return new Date(Date.UTC(2024,0,1+a)).toLocaleDateString(ke(s),{weekday:e,timeZone:"UTC"})}function ti(a,s){let e=a.schedule,t=e?.offset?` ${e.offset>0?"+":"\u2212"}${Math.abs(e.offset)}d`:"";switch(e?.kind){case"weekdays":return((e.weekdays||[]).map(i=>Ee(i,s,"short")).join(" & ")||"\u2014")+t;case"nth_weekday":return e.weekday==null||e.nth==null?"\u2014":`${e.nth===-1?r("ord_last",s):r("ord_"+e.nth,s)} ${Ee(e.weekday,s,"long")}${t}`;case"day_of_month":return e.day==null?"\u2014":(e.day===-1?r(e.business?"last_business_day_month":"last_day_month",s):`${r("day_word",s)} ${e.day}`)+t;case"one_time":return a.due_date?G(a.due_date,s):r("one_time",s);case"manual":return r("manual",s);case"interval":return He(e.every,e.unit,s)}return a.schedule_type==="one_time"?a.due_date?G(a.due_date,s):r("one_time",s):a.schedule_type==="manual"?r("manual",s):a.schedule_type==="sensor_based"?r("sensor_based",s):a.interval_days!=null?He(a.interval_days,a.interval_unit,s):"\u2014"}function ii(a,s){a.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:s},bubbles:!0,composed:!0}))}var nt,Jt,ae,cs,ps,we,hs,Ne,si,ze,I=$(()=>{"use strict";L();Gt();Qt();nt="en",Jt=(()=>{let a=window;return a.__msLocales||(a.__msLocales={store:{},inflight:{}}),a.__msLocales})(),ae=Jt.store;ae.en||(ae.en=Yt);cs=new Set(["de","nl","fr","it","es","pt","pt-br","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi","hu","ko","tr"]),ps="/maintenance_supporter_locales",we=Jt.inflight;hs=window,Ne=hs.__msDateTimePrefs??={};si=E` .field { display: flex; flex-direction: column; gap: 4px; } .field-label { font-size: 12px; color: var(--secondary-text-color); } .field-input { @@ -14,7 +14,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" font-family: inherit; width: 100%; box-sizing: border-box; } .field-input:focus { outline: none; border-color: var(--primary-color); } -`,Oe=E` +`,ze=E` :host { --maint-ok-color: var(--success-color, #4caf50); --maint-due-soon-color: var(--warning-color, #ff9800); @@ -1157,7 +1157,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .stat-item .stat-label { font-size: 11px; white-space: normal; text-align: center; line-height: 1.2; } .stat-value { font-size: 20px; } } -`});var de,nt=$(()=>{"use strict";de=class{constructor(s){this.usersCache=null;this.cacheTimestamp=0;this.CACHE_TTL_MS=6e4;this.hass=s}updateHass(s){this.hass=s}async getUsers(s=!1){let e=Date.now();if(!s&&this.usersCache&&e-this.cacheTimestampt.id===s)?.name||null}getUser(s){return!s||!this.usersCache?null:this.usersCache.find(e=>e.id===s)||null}getCurrentUserId(){return this.hass.user?.id||null}isCurrentUser(s){return s?s===this.getCurrentUserId():!1}clearCache(){this.usersCache=null,this.cacheTimestamp=0}}});function D(a){return`${a.entry_id??""}\0${a.part_id}`}function cs(a,s,e,t){let i=!!a.entry_id&&a.entry_id!==s,n=i?a.entry_id:s,l=e.find(v=>v.entry_id===n),c=(l?.parts||[]).find(v=>v.id===a.part_id)||null,p=i&&l?.object?.name||"",u=c?.name||r("shared_part_unknown",t);return{part:c,foreign:i,ownerName:p,label:p?`${u} (${p})`:u}}function ii(a,s,e,t){let n=(e.find(c=>c.entry_id===s)?.parts||[]).map(c=>({...c})),l=new Set(n.map(c=>D({part_id:c.id})));for(let c of a?.consumes_parts||[]){if(!c.entry_id||c.entry_id===s)continue;let p=D(c);if(l.has(p))continue;l.add(p);let{part:u,ownerName:v}=cs(c,s,e,t);n.push({id:c.part_id,name:u?.name||r("shared_part_unknown",t),unit:u?.unit,stock:u?.stock??null,storage_location:u?.storage_location,entry_id:c.entry_id,owner_name:v})}return n}var qe=$(()=>{"use strict";C()});function _s(a,s){let e=hs[a];if(!e)return a;let t=r(e,s);return t&&t!==e?t:a}function us(a){let e=a.match(/data\['([^']+)'\]/)?.[1],t;return(t=a.match(/length of value must be at most (\d+)/))?{field:e,rule:"too_long",param:t[1]}:(t=a.match(/length of value must be at least (\d+)/))?{field:e,rule:"too_short",param:t[1]}:(t=a.match(/value must be at most (\S+)/))?{field:e,rule:"value_too_high",param:t[1]}:(t=a.match(/value must be at least (\S+)/))?{field:e,rule:"value_too_low",param:t[1]}:/required key not provided/.test(a)?{field:e,rule:"required"}:(t=a.match(/expected (\w+)/))?{field:e,rule:"wrong_type",param:t[1]}:/value must be one of/.test(a)?{field:e,rule:"invalid_choice"}:/not a valid value/.test(a)?{field:e,rule:"invalid_value"}:{field:e,rule:"unknown"}}function P(a,s,e){if(e=e??r("action_error",s),typeof a=="string")return a;if(typeof a!="object"||a===null)return e;let t=a,i=t.message||t.error?.message||"";if(!i)return e;let n=us(i),l=n.field?_s(n.field,s):"",c=p=>r(p,s).replace("{field}",l).replace("{n}",n.param??"");switch(n.rule){case"too_long":return c("err_too_long");case"too_short":return c("err_too_short");case"value_too_high":return c("err_value_too_high");case"value_too_low":return c("err_value_too_low");case"required":return c("err_required");case"wrong_type":return c("err_wrong_type").replace("{type}",n.param??"");case"invalid_choice":return c("err_invalid_choice");case"invalid_value":return c("err_invalid_value");default:return i||e}}var hs,re=$(()=>{"use strict";C();hs={name:"name",task_type:"maintenance_type",schedule_type:"schedule_type",interval_days:"interval_days",interval_anchor:"interval_anchor",warning_days:"warning_days",last_performed:"last_performed_optional",notes:"notes_optional",documentation_url:"documentation_url_optional",custom_icon:"custom_icon_optional",nfc_tag_id:"nfc_tag_id_optional",responsible_user_id:"responsible_user",entity_slug:"entity_slug",entity_id:"entity_id",area_id:"area_id_optional",manufacturer:"manufacturer_optional",model:"model_optional",serial_number:"serial_number_optional",installation_date:"installation_date_optional",warranty_expiry:"warranty_expiry_optional",checklist:"checklist_steps_optional",reason:"reason",feedback:"feedback",cost:"cost",duration:"duration",description:"description_optional",group_name:"name",group_description:"description_optional",environmental_entity:"environmental_entity_optional",environmental_attribute:"environmental_attribute_optional",trigger_above:"trigger_above",trigger_below:"trigger_below",trigger_for_minutes:"trigger_for_minutes"}});var si,ze,ot=$(()=>{"use strict";si=["notes","cost","duration","photo","user"],ze={notes:"notes_label",cost:"cost",duration:"duration",photo:"photo_label",user:"user_label"}});var w,lt=$(()=>{"use strict";L();q();C();re();qe();ot();w=class extends k{constructor(){super(...arguments);this.entryId="";this.taskId="";this.taskName="";this.lang="en";this.checklist=[];this.adaptiveEnabled=!1;this.taskType="";this.readingUnit="";this.restockDefault=null;this.restockUnitCost=null;this.currencySymbol="";this.parts=[];this.consumesParts=[];this.consumesInfo=[];this.requiredFields=[];this._open=!1;this._notes="";this._cost="";this._duration="";this._loading=!1;this._error="";this._checklistState={};this._feedback="needed";this._photoDocId="";this._photoPreview="";this._photoUploading=!1;this._readingValue="";this._restockQty="";this._usedParts={};this.checklistPrefill={}}open(){this._open||(this._open=!0,this._notes="",this._cost="",this._duration="",this._error="",this._checklistState=Object.fromEntries(this.checklist.map((e,t)=>[String(t),!!this.checklistPrefill[e]]).filter(([,e])=>e)),this._feedback="needed",this._photoDocId="",this._photoPreview="",this._photoUploading=!1,this._readingValue="",this._restockQty=this.restockDefault!==null?String(this.restockDefault):"",this._usedParts=Object.fromEntries(this.consumesParts.map(e=>[D(e),{...e}])))}_toggleCheck(e){let t=String(e);this._checklistState={...this._checklistState,[t]:!this._checklistState[t]}}_setFeedback(e){this._feedback=e}async _onPhotoInput(e){let t=e.target,i=t.files?.[0];if(t.value="",!!i){this._photoUploading=!0,this._error="";try{let n=new FormData;n.append("entry_id",this.entryId),n.append("tags","photo"),n.append("file",i,i.name);let l=await fetch("/api/maintenance_supporter/document/upload",{method:"POST",headers:{Authorization:`Bearer ${this.hass.auth?.data?.access_token??""}`},body:n});if(!l.ok){this._error=l.status===413?r("doc_too_large",this.lang):r("doc_upload_failed",this.lang);return}let c=await l.json();c.id&&(this._photoDocId=c.id,this._photoPreview=URL.createObjectURL(i))}catch{this._error=r("doc_upload_failed",this.lang)}finally{this._photoUploading=!1}}}_removePhoto(){this._photoPreview&&URL.revokeObjectURL(this._photoPreview),this._photoDocId="",this._photoPreview=""}async _complete(){this._loading=!0,this._error="";try{let e={type:"maintenance_supporter/task/complete",entry_id:this.entryId,task_id:this.taskId};if(this._notes&&(e.notes=this._notes),this._cost){let t=parseFloat(this._cost);!isNaN(t)&&t>=0&&(e.cost=t)}if(this._duration){let t=parseInt(this._duration,10);!isNaN(t)&&t>=0&&(e.duration=t)}if(this.checklist.length>0&&(e.checklist_state=this._checklistState),this.adaptiveEnabled&&(e.feedback=this._feedback),this._photoDocId&&(e.photo_doc_id=this._photoDocId),this._readingValue!==""){let t=parseFloat(this._readingValue);isNaN(t)||(e.reading_value=t)}if(this.restockDefault!==null&&this._restockQty!==""){let t=parseFloat(this._restockQty);!isNaN(t)&&t>=1&&(e.restock_quantity=t)}this.parts.length>0&&(e.used_parts=Object.values(this._usedParts).filter(t=>Number.isFinite(t.quantity)&&t.quantity>0).map(t=>t.entry_id?{part_id:t.part_id,quantity:t.quantity,entry_id:t.entry_id}:{part_id:t.part_id,quantity:t.quantity})),await this.hass.connection.sendMessagePromise(e),this._open=!1,this.dispatchEvent(new CustomEvent("task-completed"))}catch(e){this._error=P(e,this.lang,r("save_error",this.lang))}finally{this._loading=!1}}get _missingRequired(){let e={notes:this._notes.trim()!=="",cost:this._cost.trim()!=="",duration:this._duration.trim()!=="",photo:this._photoDocId!=="",user:!!this.hass?.user};return this.requiredFields.filter(t=>!e[t])}_req(e){return this.requiredFields.includes(e)?o``:_}_partsCostSuggestion(){if(this.restockDefault!==null){let i=parseFloat(this._restockQty);return this.restockUnitCost==null||!Number.isFinite(i)||i<=0?null:Math.round(this.restockUnitCost*i*100)/100}if(!this.parts.length)return null;let e=0,t=!1;for(let i of Object.values(this._usedParts)){let n=this.parts.find(l=>D({part_id:l.id,entry_id:l.entry_id})===D(i));n?.cost!=null&&(e+=n.cost*(i.quantity||1),t=!0)}return t?Math.round(e*100)/100:null}_renderCostSuggestion(e){if(this._cost.trim()!=="")return _;let t=this._partsCostSuggestion();if(t==null||t<=0)return _;let i=`${t.toFixed(2)}${this.currencySymbol?` ${this.currencySymbol}`:""}`;return o` - `}};z.styles=E` + `}};F.styles=E` :host { display: contents; } .backdrop { position: fixed; inset: 0; @@ -2908,7 +2872,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .part-row-edit input[type="checkbox"] { width: auto; } .part-label { flex: 1; color: var(--primary-text-color); } .part-qty { width: 76px; } - `,d([b({attribute:!1})],z.prototype,"hass",2),d([h()],z.prototype,"_open",2),d([h()],z.prototype,"_saving",2),d([h()],z.prototype,"_error",2),d([h()],z.prototype,"_draft",2),d([h()],z.prototype,"_partOptions",2),d([h()],z.prototype,"_partQty",2);customElements.get("maintenance-history-edit-dialog")||customElements.define("maintenance-history-edit-dialog",z)});function ce(a){return a.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function pi(a){return!a.startsWith("data:image/svg+xml,")&&!a.startsWith("data:image/png;base64,")?"":ce(a)}function Es(a){return a.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var H,hi=$(()=>{"use strict";L();q();C();H=class extends k{constructor(){super(...arguments);this.lang="en";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}openForObject(e,t){this._entryId=e,this._taskId=null,this._objectName=t,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}openForTask(e,t,i,n){this._entryId=e,this._taskId=t,this._objectName=i,this._taskName=n,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}async _generate(){let e=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null;try{let t={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(t.task_id=this._taskId);let i=[this.hass.connection.sendMessagePromise({...t,action:"view"})];this._taskId&&i.push(this.hass.connection.sendMessagePromise({...t,action:"complete"}));let n=await Promise.all(i);if(e!==this._generateSeq)return;this._viewResult=n[0],n.length>1&&(this._completeResult=n[1])}catch(t){if(e!==this._generateSeq)return;let i=t?.code,n=t?.message;this._error=i==="no_url"||typeof n=="string"&&n.includes("No Home Assistant URL")?r("qr_error_no_url",this.lang):r("qr_error",this.lang)}finally{e===this._generateSeq&&(this._loading=!1)}}_setUrlMode(e){this._urlMode!==e&&(this._urlMode=e,this._generate())}_print(){if(!this._viewResult)return;let e=this._viewResult,t=e.label.task_name?`${e.label.object_name} \u2014 ${e.label.task_name}`:e.label.object_name,i=[e.label.manufacturer,e.label.model].filter(Boolean).join(" "),n=window.open("","_blank","width=600,height=500");if(!n)return;let l=this.lang||"en",c=ce(t),p=ce(i),u=!!this._completeResult,v=ce(r("qr_action_view",l)),f=ce(r("qr_action_complete",l));n.document.write(` + `,d([b({attribute:!1})],F.prototype,"hass",2),d([h()],F.prototype,"_open",2),d([h()],F.prototype,"_saving",2),d([h()],F.prototype,"_error",2),d([h()],F.prototype,"_draft",2),d([h()],F.prototype,"_partOptions",2),d([h()],F.prototype,"_partQty",2);customElements.get("maintenance-history-edit-dialog")||customElements.define("maintenance-history-edit-dialog",F)});function he(a){return a.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function gi(a){return!a.startsWith("data:image/svg+xml,")&&!a.startsWith("data:image/png;base64,")?"":he(a)}function Ls(a){return a.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var O,mi=$(()=>{"use strict";L();z();I();O=class extends k{constructor(){super(...arguments);this.lang="en";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}openForObject(e,t){this._entryId=e,this._taskId=null,this._objectName=t,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}openForTask(e,t,i,n){this._entryId=e,this._taskId=t,this._objectName=i,this._taskName=n,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}async _generate(){let e=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null;try{let t={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(t.task_id=this._taskId);let i=[this.hass.connection.sendMessagePromise({...t,action:"view"})];this._taskId&&i.push(this.hass.connection.sendMessagePromise({...t,action:"complete"}));let n=await Promise.all(i);if(e!==this._generateSeq)return;this._viewResult=n[0],n.length>1&&(this._completeResult=n[1])}catch(t){if(e!==this._generateSeq)return;let i=t?.code,n=t?.message;this._error=i==="no_url"||typeof n=="string"&&n.includes("No Home Assistant URL")?r("qr_error_no_url",this.lang):r("qr_error",this.lang)}finally{e===this._generateSeq&&(this._loading=!1)}}_setUrlMode(e){this._urlMode!==e&&(this._urlMode=e,this._generate())}_print(){if(!this._viewResult)return;let e=this._viewResult,t=e.label.task_name?`${e.label.object_name} \u2014 ${e.label.task_name}`:e.label.object_name,i=[e.label.manufacturer,e.label.model].filter(Boolean).join(" "),n=window.open("","_blank","width=600,height=500");if(!n)return;let l=this.lang||"en",c=he(t),p=he(i),u=!!this._completeResult,v=he(r("qr_action_view",l)),f=he(r("qr_action_complete",l));n.document.write(` ${c} -

${L(n.name)}

-

${L(t.title)} \xB7 ${L(t.generated)}: ${L(e(a))}

+

${L(r.name)}

+

${L(t.title)} \xB7 ${L(t.generated)}: ${L(e(s))}

${l.length?`
${l.map(([h,u])=>`
${L(h)}
${L(u)}
`).join("")}
`:""}

${L(t.tasksHeading)} (${o.length})

@@ -49,11 +49,11 @@ import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VNISEOIC.js";import{ ${p||``}
${L(t.none)}
${L(t.totalCost)}${c.toFixed(2)} ${L(i)}
- ${n.notes?`
${L(t.notes)}: -${L(n.notes)}
`:""} -`}function Gt(n,o=new Date){if(!n)return{kind:"none",days:null,date:null};let t=new Date(`${n}T00:00:00`);if(isNaN(t.getTime()))return{kind:"none",days:null,date:null};let e=Date.UTC(o.getFullYear(),o.getMonth(),o.getDate()),i=Date.UTC(t.getFullYear(),t.getMonth(),t.getDate()),a=Math.round((i-e)/864e5);return a<0?{kind:"expired",days:a,date:n}:a<=60?{kind:"expiring",days:a,date:n}:{kind:"valid",days:a,date:n}}var z=n=>String(n??"").replace(/[&<>"']/g,o=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[o]);function ue(n,o,t,e,i,a,l,p,c,h=[]){let u=[[t.object,z(o)],[t.type,z(t.typeLabel(n.type))],[t.interval,z(i(n))],[t.nextDue,n.next_due?z(e(n.next_due)):"\u2014"],[t.lastDone,n.last_performed?z(e(n.last_performed)):z(t.never)]];n.priority&&n.priority!=="normal"&&u.push([t.priority,z(n.priority)]);let m=(n.checklist||[]).map(b=>`
  • ${z(b)}
  • `).join(""),v=(b,x)=>b?`
    ${z(x)}
    `:"";return` + ${r.notes?`
    ${L(t.notes)}: +${L(r.notes)}
    `:""} +`}function te(r,o=new Date){if(!r)return{kind:"none",days:null,date:null};let t=new Date(`${r}T00:00:00`);if(isNaN(t.getTime()))return{kind:"none",days:null,date:null};let e=Date.UTC(o.getFullYear(),o.getMonth(),o.getDate()),i=Date.UTC(t.getFullYear(),t.getMonth(),t.getDate()),s=Math.round((i-e)/864e5);return s<0?{kind:"expired",days:s,date:r}:s<=60?{kind:"expiring",days:s,date:r}:{kind:"valid",days:s,date:r}}var I=r=>String(r??"").replace(/[&<>"']/g,o=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[o]);function fe(r,o,t,e,i,s,l,p,c,h=[]){let u=[[t.object,I(o)],[t.type,I(t.typeLabel(r.type))],[t.interval,I(i(r))],[t.nextDue,r.next_due?I(e(r.next_due)):"\u2014"],[t.lastDone,r.last_performed?I(e(r.last_performed)):I(t.never)]];r.priority&&r.priority!=="normal"&&u.push([t.priority,I(r.priority)]);let m=(r.checklist||[]).map(b=>`
  • ${I(b)}
  • `).join(""),v=(b,x)=>b?`
    ${I(x)}
    `:"";return` -${z(n.name)} \u2014 ${z(t.title)} +${I(r.name)} \u2014 ${I(t.title)} +

    ${d}

    +${u?`
    ${u}
    `:""} +
    +
    + QR Info +
    ${f}
    +
    + ${_?`
    + QR Complete +
    ${w}
    +
    `:""} +
    +
    ${p(this._viewResult.url)}
    +