// Note: avoid ESM imports so the card works when loaded as "javascript" resource in HA. // ---- thermostat-timeline i18n (namespaced) ---- const TT_I18N = { en: { // Card 'card.title_default': 'Thermostat Timeline', 'err.config_entities': "Config must have 'entities: [climate.xxx, ...]'.", // Modal / editor (inline) 'ui.edit_block': 'Edit block', 'ui.add_block': 'Add block', 'ui.from': 'From', 'ui.to': 'To', 'ui.temperature': 'Temperature', 'ui.delete': 'Delete', 'ui.cancel': 'Cancel', 'ui.close': 'Close', 'ui.unsaved_confirm': 'You have unsaved changes. Close anyway?', 'ui.discard_changes': 'Discard changes', 'ui.keep_editing': 'Keep editing', 'ui.save': 'Save', 'ui.error_prefix': 'Error: ', 'ui.overlap_msg': 'You selected overlapping times — overlap is {start} – {end}.', 'ui.auto_fix': 'Auto-fix', 'ui.fix_start_to': 'Set start to {time}', 'ui.fix_end_to': 'Set end to {time}', 'ui.current_temp': 'Current temperature:', 'ui.copy_plan': 'Copy plan', 'ui.paste_plan': 'Paste plan', 'ui.copy_day': 'Copy day', 'ui.paste_day': 'Paste day', 'editor.title_label': 'Title', 'editor.add_entity': 'Add room', 'editor.entity_placeholder': 'Select an entity', 'editor.drag_reorder': 'Drag to reorder', 'editor.remove': 'Remove', 'editor.default_c': 'Default °C', 'editor.row_height_px': 'Row height (px)', 'editor.max_c': 'Max °C', 'editor.min_c': 'Min °C', 'editor.auto_apply.title': 'Auto apply', 'editor.auto_apply.desc': 'When enabled, the card tries to set temperature on the selected climate entities to match the scheduled value for “now”.', 'editor.apply_edit.title': 'On edit', 'editor.apply_edit.desc': 'When you edit blocks and it affects the current time, apply the new temperature immediately.', 'editor.apply_default.title': 'On default temperature change', 'editor.apply_default.desc': 'When “Default °C” changes and it affects the current period, apply the new temperature immediately.', 'editor.perroom.title': 'Default °C per room', 'editor.perroom.desc': 'Shows a Default °C field on each row. When off, the global Default °C is used for all rooms.', 'editor.show_room_temp.title': 'Show room temperature', 'editor.show_room_temp.desc': 'Show the current room temperature bubble on each row in the timeline header.', 'editor.room_mode.title': 'Room entity type', 'editor.room_mode.desc': 'When enabled, select an input_number (target temperature). When disabled, select a climate entity.', 'editor.room_temp_sensor_override.title': 'Use room temperature sensor', 'editor.room_temp_sensor_override.desc': 'When enabled, the shown room temperature (and boiler logic) uses the selected temperature sensor instead of climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Temperature sensor', 'editor.turn_on.title': 'Send turn_on command', 'editor.turn_on.desc': 'Some thermostats require a turn_on command in addition to set_temperature.', 'editor.turn_on.order': 'Order', 'editor.turn_on.order.before': 'Before set_temperature', 'editor.turn_on.order.after': 'After set_temperature', 'editor.merge_label': 'Merge with (add extra thermostat)', 'editor.display_name_optional': 'Room name (optional)', 'editor.merged_with': 'Merged with', 'editor.store_enable.title': 'Shared storage + background control', 'editor.store_enable.short': 'Stores schedules in the selected sensor and enables background control.', 'editor.store_enable.desc': 'Stores schedules in the selected sensor and enables set_temperature in the background. Thermostats are also updated when the card is closed. Shares schedules and card settings across users/devices. When off: commands are only sent while the card is visible on a device and must remain open.', 'editor.store_missing': 'The integration “Thermostat Pro Timeline Sync” is not installed. Shared storage is unavailable.', 'editor.open_in_hacs': 'Open in HACS', 'editor.clear_store': 'Clear storage', 'editor.clear_store_confirm': 'This will delete all stored schedules. Continue?', 'editor.migrate_to_store': 'Transfer data to storage', 'editor.migrate_confirm': 'Transfer your browser-saved schedules to the selected sensor? This overwrites existing schedules in storage.', 'editor.migrate_pull_confirm': 'Import schedules from storage and overwrite local browser data?', 'editor.select_storage_first': 'Select a storage sensor first.', 'editor.no_local_data': 'No local browser data was found.', 'editor.test_tool': 'Test tool', 'editor.test_select_entity': 'Select entity', 'editor.test_empty': 'Select an entity to see available commands…', 'editor.test_export': 'Export .md', 'editor.test_no_fields': 'No fields', 'editor.test_select_placeholder': '— Select —', 'editor.error_prefix': 'Error: ', 'editor.clear_all': 'Clear all data', 'editor.clear_all_confirm': 'This deletes both storage and local/browser schedules. Continue?', 'editor.clear_storage_only': 'Clear storage data only', 'editor.clear_storage_only_confirm': 'This deletes schedules from the selected storage sensor. Continue?', 'editor.clear_local_only': 'Clear ALL local data (local only)', 'editor.clear_local_only_confirm': 'This deletes only schedules saved in your browser. Continue?', 'editor.reset.desc': 'What this does:\n\n• Clear all data: Resets backend storage (.storage) for Thermostat Timeline and removes ALL local browser caches/drafts.\n• Clear ALL local data (local only): Only clears data stored in this browser (localStorage).\n\nTip: After a full reset, reload the page to avoid old drafts reappearing.', 'editor.select_entities': 'Rooms', 'editor.colors.empty': 'No rooms added yet. Add a room to define colors.', 'editor.colors.go_add': 'Add room', 'week.enable': 'Enable weekdays', 'week.enable.desc': 'Enable per-weekday planning. When off, the same plan is used every day.', 'week.mode': 'Weekday mode', // removed same_all option from UI 'week.mode.weekday_weekend': 'Weekdays + Weekend', 'week.mode.weekday_sat_sun': 'Weekdays + Saturday + Sunday', 'week.mode.all_7': 'All days', 'week.button': 'Weekdays', 'week.title': 'Weekdays', 'week.group.weekdays': 'Weekdays', 'week.group.weekend': 'Weekend', 'week.days_full.mon': 'Monday', 'week.days_full.tue': 'Tuesday', 'week.days_full.wed': 'Wednesday', 'week.days_full.thu': 'Thursday', 'week.days_full.fri': 'Friday', 'week.days_full.sat': 'Saturday', 'week.days_full.sun': 'Sunday', 'week.days.mon': 'Mon', 'week.days.tue': 'Tue', 'week.days.wed': 'Wed', 'week.days.thu': 'Thu', 'week.days.fri': 'Fri', 'week.days.sat': 'Sat', 'week.days.sun': 'Sun', 'week.editor.title': 'Weekday schedule', 'week.editor.preview_note': 'Preview only. Changes are applied when you press Save.', 'week.open_editor.desc': 'Open the weekdays editor popup.', 'week.open_editor.btn': 'Open', 'week.view.title': 'Weekdays view', 'week.view.rooms_one_day': 'All rooms • one day', 'week.view.days_one_room': 'All days • one room', 'week.view_switch.title': 'Switch view from timeline', 'week.view_switch.desc': 'Show a toggle in the timeline header to switch between the two Weekdays view modes.', 'week.copy_from_main.title': 'Copy main schedule', 'week.copy_from_main.desc': 'You enabled weekdays. Do you want to copy the main schedule into weekdays? Choose the weekday mode to copy into.', 'week.copy_from_main.mode_label': 'Weekday mode', 'week.copy_from_main.target_label': 'Copy to', 'week.copy_from_main.copy_btn': 'Copy', 'week.copy_from_main.skip_btn': 'Don’t copy', 'week.select_room': 'Select room', 'week.copy_to_rooms': 'Copy weekdays to rooms', 'week.copy_to_rooms.title': 'Copy weekdays to rooms', 'week.copy_to_rooms.select': 'Select rooms', 'week.copy_to_rooms.apply': 'Apply to rooms', 'editor.timefmt.title': 'Time format', 'editor.timefmt.desc': 'Choose 24-hour or 12-hour (AM/PM) display for times.', 'editor.tempfmt.title': 'Temperature unit', 'editor.tempfmt.desc': 'Choose Celsius (°C) or Fahrenheit (°F) for display in the card and editors.', 'editor.timesrc.title': 'Time source', 'editor.timesrc.desc': 'Choose whether the card uses the browser time or the Home Assistant time zone for “now”.', 'editor.timesrc.browser': 'Browser', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'Configuration ID', 'editor.instance.desc': 'Keep multiple independent schedules/settings under different IDs.', 'editor.instance.new_id': 'New ID', 'editor.tabs.settings': 'Settings', 'editor.tabs.pause': 'Pause', 'editor.tabs.weekdays': 'Weekdays', 'editor.tabs.rooms': 'Rooms', 'editor.tabs.boiler': 'Boiler control', 'editor.tabs.colors': 'Colors', 'editor.tabs.presence_sensor': 'Presence sensor', 'editor.tabs.owd': 'Open Window Detection', 'editor.tabs.away': 'Away from home', 'editor.tabs.reset': 'Reset', 'editor.tabs.sync': 'Sync Engine', 'editor.tabs.backup': 'Backup', 'editor.presence_sensor.enable.title': 'Presence sensor', 'editor.presence_sensor.enable.desc': 'Enable per-room presence sensor configuration.', 'editor.presence_sensor.entity.title': 'Presence entity', 'editor.presence_sensor.entity.desc': 'Select the binary_sensor that indicates presence in this room.', 'editor.presence_sensor.on_temp.title': 'Temperature when presence is ON', 'editor.presence_sensor.on_temp.desc': 'If the sensor is ON, this room will use this temperature instead of the schedule.', 'editor.presence_sensor.on_delay.title': 'Delay before applying presence', 'editor.presence_sensor.on_delay.desc': 'How long the sensor must stay ON before the override temperature is used. Value uses the selected unit.', 'editor.presence_sensor.off_delay.title': 'Delay before resuming schedule', 'editor.presence_sensor.off_delay.desc': 'How long the sensor must stay OFF before the schedule is resumed. Value uses the selected unit.', 'owd.enable': 'Open Window Detection', 'owd.enable.desc': 'Turns off thermostats in a room when selected window/door sensors are open.', 'owd.sensors': 'Window/Door sensors', 'owd.delays.title': 'Delays', 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).', 'owd.open_delay': 'After open (min)', 'owd.close_delay': 'After closed (min)', 'boiler.switch': 'Boiler switch', 'boiler.switch_type': 'Entity type', 'boiler.switch_type.switch': 'Switch', 'boiler.switch_type.input_boolean': 'Input boolean', 'boiler.rooms': 'Rooms included', 'boiler.rooms.desc': 'Select which rooms should be included in boiler control.', 'boiler.multi_enable': 'Enable multiple boilers', 'boiler.multi_enable.desc': 'Assign rooms to different boilers and set per-room offsets.', 'boiler.multi_rooms': 'Boiler per room', 'boiler.multi_rooms.desc': 'Select boiler switch and offsets for each room.', 'boiler.room.enable': 'Include room', 'boiler.room.enable.desc': 'Include this room in boiler control.', 'boiler.enable': 'Enable boiler control', 'boiler.enable.desc': 'Show boiler settings in this card editor.', 'boiler.temp_sensor': 'Boiler temperature sensor', 'boiler.temp_sensor.desc': 'Temperature sensor that controls the boiler switch.', 'boiler.min_temp': 'Min boiler temp (°C)', 'boiler.max_temp': 'Max boiler temp (°C)', 'boiler.offsets': 'Offsets', 'boiler.offsets.desc': 'ON offset: how far below the scheduled target before turning ON. OFF offset: how far above the scheduled target before turning OFF.', 'boiler.on_offset': 'ON offset (°C)', 'boiler.off_offset': 'OFF offset (°C)', 'sync.running': 'Sync in progress…', 'sync.completed': 'Sync completed', 'backup.title': 'Backup', 'backup.desc': 'Copy the full content of your sync sensor to the backup sensor and restore it when needed.', 'backup.now': 'Backup now', 'backup.restore': 'Restore', 'backup.restore.confirm_overwrite': 'Restore from backup? This will overwrite current schedules and settings.', 'backup.delete': 'Delete backup', 'backup.delete.confirm': 'Delete selected backup?', 'backup.new': 'New backup', 'backup.auto': 'Auto backup', 'backup.auto.desc': 'Create backups automatically at a fixed interval.', 'backup.interval': 'Interval (days)', 'backup.interval.desc': 'How many days between automatic backups (min 1).', 'backup.sensor': 'Backup sensor', 'backup.last': 'Last backup', 'backup.slot.title': 'Select backup', 'backup.slot.desc': 'Select which backup slot to restore.', 'backup.slot.empty': 'No backups yet', 'backup.select': 'Select what to back up', 'backup.select.desc': 'Checked items are included in backup/restore', 'backup.opt.all': 'All', 'backup.opt.main': 'Main schedules', 'backup.opt.weekday': 'Weekday schedules', 'backup.opt.presence': 'Presence schedules', 'backup.opt.settings': 'Editor settings', 'backup.opt.holiday': 'Holiday schedules', 'backup.opt.colors': 'Color blocks', 'backup.import.title': 'Import thermostat_timeline.json', 'backup.import.desc': 'Upload a thermostat_timeline.json file and import it. A new backup is created first.', 'backup.import.btn': 'Import file', 'backup.export.title': 'Export backup file', 'backup.export.desc': 'Download a full thermostat_timeline_backup.json with all slots and data.', 'backup.export.btn': 'Download file', 'backup.imported_suffix': ' - imported', 'backup.importbk.title': 'Import backup file', 'backup.importbk.desc': 'Upload a thermostat_timeline_backup.json file to append its backups to the list.', 'backup.importbk.btn': 'Import backup', 'backup.importbk.err_invalid': 'Invalid backup file', 'backup.file.choose': 'Choose imported backup', 'backup.file.add_selected': 'Import backup', 'backup.file.add_all': 'Import all backups', 'backup.file.loaded_n': 'File loaded ({n} backups)', 'backup.file.imported_ok': 'Imported ({n})', 'backup.import.err_no_hass': 'Home Assistant not ready', 'backup.import.err_storage_off': 'Shared storage is OFF. Turn it on first.', 'backup.import.err_no_integration': 'Integration thermostat_timeline not available', 'backup.import.err_json': 'Invalid JSON', 'backup.import.err_invalid': 'Invalid file: missing data.schedules', 'away.enable': 'Enable Away mode', 'away.desc': 'When none of the selected persons are home, thermostats are automatically lowered to the specified temperature.', 'away.temp': 'Away temperature', 'away.persons': 'Persons', 'away.add_person': 'Add person', 'away.resume_schedule': 'Resume schedule', 'away.resume_away': 'Resume away temperature', 'editor.heat_colors.title': 'Colors for heat blocks', 'editor.heat_colors.add': 'Add color interval', 'editor.colors.col_from': 'From °C', 'editor.colors.col_to': 'To °C', 'editor.colors.col_color': 'Color', 'editor.colors.refresh': 'Refresh schedule', 'editor.colors.clear_local': 'Clear local color data', 'editor.colors.clear_local_confirm': 'This deletes color intervals stored in your browser for this card. Continue?', 'editor.colors.clear_shared': 'Clear shared color storage', 'editor.colors.clear_shared_confirm': 'This deletes color intervals in the selected storage sensor. Continue?', 'editor.colors.clear_all': 'Clear all color data', 'editor.colors.clear_all_confirm': 'This deletes all color intervals both locally and in the selected storage sensor. Continue?', 'editor.colors.mode': 'Color mode', 'editor.colors.mode.desc': 'Choose per-room colors or one set for all rooms.', 'editor.colors.mode_per_room': 'Per room', 'editor.colors.mode_global': 'All rooms', 'editor.colors.global_title': 'All rooms', 'editor.reload_editor': 'Reload editor', 'editor.reload_dashboard': 'Reload dashboard', 'onboard.title': 'Welcome to Thermostat Timeline', 'onboard.next': 'Next', 'onboard.prev': 'Back', 'onboard.skip': 'Skip', 'onboard.done': 'Done', 'onboard.dont_show': 'Don’t show again', 'onboard.video_not_found': 'Video not found', 'onboard.image_not_found': 'Image not found', 'onboard.step_main': 'This is the main timeline. It shows target temperatures over 24 hours for each room.', 'onboard.step_add': 'Add a heat block via + Add block or by double-clicking the timeline.', 'onboard.step_edit': 'Edit or delete a block by double-clicking it. Use Weekdays for different days.', 'onboard.reset': 'Show guide again', 'onboard.reset_ok': 'The guide will show next time.', 'pause.menu_title': 'Pause auto-apply', 'pause.off_1h': 'Pause for 1 hour', 'pause.off_5h': 'Pause for 5 hours', 'pause.off_custom': 'Custom duration…', 'pause.prompt_minutes': 'Enter duration (minutes):', 'pause.off_indef': 'Pause until I resume', 'pause.resume': 'Resume now', 'pause.close': 'Close', 'pause.active_until': 'Paused until {time}', 'pause.active_indef': 'Paused', 'editor.pausebtn.title': 'Pause button', 'editor.pausebtn.desc': 'Show a global pause button in the card header. When pause is active, all set_temperature commands are suppressed.', 'editor.pause_sensor.title': 'Pause via binary sensor', 'editor.pause_sensor.desc': 'Automatically pause while the selected binary sensor is on.', 'editor.pause_sensor.entity_title': 'Pause sensor', 'editor.pause_sensor.entity_desc': 'Binary sensor that controls pause (on = pause, off = resume).', 'editor.integration.connected': 'Connected to integration', 'editor.integration.disconnected': 'Not connected to integration', 'profiles.enable': 'Enable profiles (override)', 'profiles.enable.desc': 'Allow named day schedules that can be activated to override the main plan.', 'season.enable.title': 'Enable summer/winter schedules', 'season.enable.desc': 'When enabled, each schedule has separate Summer and Winter versions that you can switch between.', 'season.mode.title': 'Season', 'season.mode.winter': 'Winter', 'season.mode.summer': 'Summer', 'profiles.button': 'Profiles', 'profiles.editor.title': 'Profiles', 'profiles.add': 'Add profile', 'profiles.rename': 'Rename', 'profiles.delete': 'Delete', 'profiles.activate': 'Activate', 'profiles.deactivate': 'Deactivate', 'profiles.active': 'Active', 'profiles.none': 'None', 'profiles.name_placeholder': 'Profile name', 'profiles.name_exists': 'Name exists', 'profiles.edit': 'Edit', 'profiles.preview_note': 'Preview only. Profiles become active when you activate them.', 'profiles.active_label': 'Active profile:', 'profiles.save_new': 'Save new profile', 'profiles.save_existing': 'Save changes to selected profile', 'editor.tabs.holidays': 'Holidays', 'holidays.enable': 'Enable holidays', 'holidays.desc': 'Use a special day schedule on holidays.', 'holidays.source': 'Holiday source', 'holidays.source.calendar': 'Calendar', 'holidays.source.manual': 'Manual dates', 'holidays.entity': 'Holiday entity', 'holidays.entity_desc': 'Select a calendar that only provides on/off. It must be on on holidays and off on other days.', 'holidays.dates': 'Holiday dates', 'holidays.add_date': 'Add date', 'holidays.add_range': 'Add range', 'holidays.date_in_range': 'Date already covered by a range', 'holidays.ranges': 'Holiday ranges', 'holidays.single_dates': 'Single dates', 'holidays.editor.title': 'Holiday schedule', 'holidays.edit': 'Edit holiday schedule', 'holidays.button': 'Holidays', // Holiday copy-from popup 'holidays.copy_from': 'Copy from…', 'holidays.copy.title': 'Copy into holiday', 'holidays.copy.select': 'Select source', 'holidays.copy.apply': 'Insert', 'holidays.copy.replace_confirm': 'Replace existing holiday blocks?', 'holidays.copy.group.main': 'Main plan', 'holidays.copy.group.weekdays': 'Weekdays', 'holidays.copy.group.profiles': 'Profiles', 'holidays.copy.group.presence': 'Presence', 'presence.button': 'Presence schedules', 'presence.editor.title': 'Presence schedules', 'presence.enable_advanced': 'Enable advanced presence schedules', 'presence.enable_advanced.desc': 'Use combinations and schedules based on who is home/away.', 'presence.live_header': 'Show live presence in header', 'presence.live_header.desc': 'Chips are shown in the right corner.', // Away delay 'away.delay_enable': 'Delay Away actions', 'away.delay_desc': 'Wait before applying Away changes.', 'away.delay_value': 'Delay', 'away.delay_unit': 'Unit', 'away.delay_unit.seconds': 'Seconds', 'away.delay_unit.minutes': 'Minutes', 'presence.combos': 'Combinations', 'presence.enable_combo': 'Enable this combination', 'presence.all_home': 'All home', 'presence.none_home': 'Nobody home', 'presence.home': 'Home: {names}', 'presence.away': 'Away: {names}', 'presence.manage': 'Manage combinations', 'presence.who_home': 'Who is home', 'presence.who_away': 'Who is away' }, da: { // Card 'card.title_default': 'Thermostat Timeline', 'err.config_entities': "Config skal have 'entities: [climate.xxx, ...]'.", // Modal / editor (inline) 'ui.edit_block': 'Edit block', 'ui.add_block': 'Add block', 'ui.from': 'From', 'ui.to': 'To', 'ui.temperature': 'Temperatur', 'ui.delete': 'Slet', 'ui.cancel': 'Annuller', 'ui.close': 'Luk', 'ui.unsaved_confirm': 'Du har ændringer, der ikke er gemt. Vil du lukke alligevel?', 'ui.discard_changes': 'Forkast ændringer', 'ui.keep_editing': 'Fortsæt redigering', 'ui.save': 'Gem', 'ui.error_prefix': 'Fejl: ', 'ui.overlap_msg': 'Du har valgt overlappende tider — overlap er {start} – {end}.', 'ui.auto_fix': 'Ret automatisk', 'ui.fix_start_to': 'Sæt start til {time}', 'ui.fix_end_to': 'Sæt slut til {time}', 'ui.current_temp': 'Nuværende temperatur:', 'ui.copy_plan': 'Kopiér plan', 'ui.paste_plan': 'Indsæt plan', 'ui.copy_day': 'Kopiér dag', 'ui.paste_day': 'Indsæt dag', 'editor.title_label': 'Titel', 'editor.store_enable.title': 'Delt lager + baggrundsstyring', 'editor.store_enable.short': 'Gemmer tidsplaner i den valgte sensor og aktiverer baggrundsstyring.', 'editor.store_enable.desc': 'Gemmer tidsplaner i den valgte sensor og aktiverer set_temperature i baggrunden. Termostater opdateres også når kortet er lukket. Deler tidsplaner og kortindstillinger på tværs af brugere/enheder. Når slået fra: kommandoer sendes kun mens kortet er synligt på en enhed og skal forblive åbent.', 'editor.store_missing': 'Integrationen “Thermostat Pro Timeline Sync” er ikke installeret. Delt lager er ikke tilgængeligt.', 'editor.clear_store': 'Ryd lager', 'editor.clear_store_confirm': 'Dette vil slette alle gemte tidsplaner. Vil du fortsætte?', 'editor.migrate_to_store': 'Overfør data til lager', 'editor.migrate_confirm': 'Overfør dine browser-gemte tidsplaner til den valgte sensor? Dette overskriver eksisterende planer i lageret.', 'editor.migrate_pull_confirm': 'Importér tidsplaner fra lager og overskriv lokale browserdata?', 'editor.select_storage_first': 'Vælg først en lagersensor.', 'editor.no_local_data': 'Ingen lokale browserdata blev fundet.', 'editor.clear_all': 'Ryd alt data', 'editor.clear_all_confirm': 'Dette sletter både lager og lokale/browser tidsplaner. Vil du fortsætte?', 'editor.clear_storage_only': 'Ryd kun lagerdata', 'editor.clear_storage_only_confirm': 'Dette sletter tidsplaner fra den valgte lagersensor. Vil du fortsætte?', 'editor.clear_local_only': 'Ryd ALLE lokale data (kun lokalt)', 'editor.clear_local_only_confirm': 'Dette sletter kun tidsplaner gemt i din browser. Vil du fortsætte?', 'editor.reset.desc': 'Hvad gør det her:\n\n• Ryd alt data: Nulstiller backend storage (.storage) for Thermostat Timeline og fjerner ALLE lokale caches/drafts i denne browser.\n• Ryd ALLE lokale data (kun lokalt): Rydder kun data gemt i denne browser (localStorage).\n\nTip: Efter en fuld reset kan en genindlæsning forhindre at gamle drafts dukker op igen.', 'editor.temp_sensor': 'Temperatursensor', 'editor.test_tool': 'Testværktøj', 'editor.test_select_entity': 'Vælg entitet', 'editor.test_empty': 'Vælg en entitet for at se tilgængelige kommandoer…', 'editor.test_export': 'Export .md', 'editor.test_no_fields': 'Ingen felter', 'editor.test_select_placeholder': '— Vælg —', 'editor.error_prefix': 'Fejl: ', 'editor.open_in_hacs': 'Åbn i HACS', 'editor.add_entity': 'Tilføj rum', 'editor.entity_placeholder': 'Vælg en entitet', 'editor.drag_reorder': 'Træk for at ændre rækkefølge', 'editor.remove': 'Fjern', 'editor.default_c': 'Standard °C', 'editor.row_height_px': 'Rækkehøjde (px)', 'editor.min_c': 'Min °C', 'editor.max_c': 'Maks °C', 'editor.auto_apply.title': 'Auto apply', 'editor.auto_apply.desc': 'Når slået til forsøger kortet at sætte temperaturen på de valgte climate‑entiteter til den planlagte værdi for “nu”.', 'editor.apply_edit.title': 'Ved redigering', 'editor.apply_edit.desc': 'Når du redigerer blokke og det påvirker nuværende tidspunkt, så anvend den nye temperatur med det samme.', 'editor.apply_default.title': 'Ved ændring af Standard °C', 'editor.apply_default.desc': 'Når “Standard °C” ændres og det påvirker den aktuelle periode, så anvend den nye temperatur med det samme.', 'editor.perroom.title': 'Standard °C pr. rum', 'editor.perroom.desc': 'Vis et Standard °C‑felt på hver række. Når slået fra, bruges global Standard °C for alle rum.', 'editor.show_room_temp.title': 'Vis rumtemperatur', 'editor.show_room_temp.desc': 'Vis boblen med nuværende rumtemperatur på hver række i tidslinjens header.', 'editor.room_mode.title': 'Rumtype (entitet)', 'editor.room_mode.desc': 'Når slået til vælger du en input_number (måltemperatur). Når slået fra vælger du en climate‑entitet.', 'editor.room_temp_sensor_override.title': 'Brug temperatur-sensor til rumtemp', 'editor.room_temp_sensor_override.desc': 'Når slået til bruges den valgte temperatursensor til vist rumtemp (og kedel-logik) i stedet for climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Temperatursensor', 'editor.turn_on.title': 'Send turn_on kommando', 'editor.turn_on.desc': 'Nogle termostater kræver en turn_on kommando ud over set_temperature.', 'editor.turn_on.order': 'Rækkefølge', 'editor.turn_on.order.before': 'Før set_temperature', 'editor.turn_on.order.after': 'Efter set_temperature', 'editor.display_name_optional': 'Rum navn (valgfri)', 'editor.merge_label': 'Flet med (tilføj ekstra termostat)', 'editor.merged_with': 'Flettet med', 'editor.colors.empty': 'Der er endnu ikke oprettet rum. Tilføj et rum for at definere farver.', 'editor.colors.go_add': 'Tilføj rum', 'editor.select_entities': 'Rum', 'week.enable': 'Aktivér ugedage', 'week.mode': 'Ugedags-tilstand', 'week.mode.weekday_weekend': 'Hverdage + Weekend', 'week.mode.weekday_sat_sun': 'Hverdage + Lørdag + Søndag', 'week.mode.all_7': 'Alle dage', 'week.button': 'Ugedage', 'week.title': 'Ugedage', 'week.group.weekdays': 'Hverdage', 'week.group.weekend': 'Weekend', 'week.days_full.mon': 'Mandag', 'week.days_full.tue': 'Tirsdag', 'week.days_full.wed': 'Onsdag', 'week.days_full.thu': 'Torsdag', 'week.days_full.fri': 'Fredag', 'week.days_full.sat': 'Lørdag', 'week.days_full.sun': 'Søndag', 'week.days.mon': 'Man', 'week.days.tue': 'Tir', 'week.days.wed': 'Ons', 'week.days.thu': 'Tor', 'week.days.fri': 'Fre', 'week.days.sat': 'Lør', 'week.days.sun': 'Søn', 'week.editor.title': 'Ugedags-tidsplan', 'week.editor.preview_note': 'Kun forhåndsvisning. Ændringer gemmes først når du trykker Gem.', 'week.open_editor.desc': 'Åbn ugedags-editoren.', 'week.open_editor.btn': 'Åbn', 'week.view.title': 'Ugedage-visning', 'week.view.rooms_one_day': 'Alle rum • én dag', 'week.view.days_one_room': 'Alle dage • ét rum', 'week.view_switch.title': 'Skift visning fra tidslinjen', 'week.view_switch.desc': 'Vis en toggle i tidslinjens header, så du kan skifte ugedags-visning uden at åbne indstillinger.', 'week.copy_from_main.title': 'Kopiér main‑skema', 'week.copy_from_main.desc': 'Du har slået ugedage til. Vil du kopiere main‑skemaet over i ugedage? Vælg ugedags‑mode der kopieres til.', 'week.copy_from_main.mode_label': 'Ugedags‑mode', 'week.copy_from_main.target_label': 'Kopiér til', 'week.copy_from_main.copy_btn': 'Kopiér', 'week.copy_from_main.skip_btn': 'Kopiér ikke', 'week.select_room': 'Vælg rum', 'week.copy_to_rooms': 'Kopiér ugedage til rum', 'week.copy_to_rooms.title': 'Kopiér ugedage til rum', 'week.copy_to_rooms.select': 'Vælg rum', 'week.copy_to_rooms.apply': 'Indsæt i rum', 'week.enable.desc': 'Aktivér plan per ugedag. Når slået fra, bruges samme plan hver dag.', 'editor.timefmt.title': 'Tidsformat', 'editor.timefmt.desc': 'Vælg 24-timers eller 12-timers (AM/PM) visning for tider på kortet.', 'editor.tempfmt.title': 'Temperaturenhed', 'editor.tempfmt.desc': 'Vælg Celsius (°C) eller Fahrenheit (°F) til visning i kortet og editorer.', 'editor.timesrc.title': 'Tidskilde', 'editor.timesrc.desc': 'Vælg om kortet skal bruge browserens tid eller Home Assistant-tidszone til “nu”.', 'editor.timesrc.browser': 'Browser', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'Konfigurations-ID', 'editor.instance.desc': 'Hold flere uafhængige tidsplaner/indstillinger under forskellige ID’er.', 'editor.instance.new_id': 'Nyt ID', 'editor.tabs.settings': 'Indstillinger', 'editor.tabs.pause': 'Pause', 'editor.tabs.weekdays': 'Ugedage', 'editor.tabs.rooms': 'Rum', 'editor.tabs.boiler': 'Kedelstyring', 'editor.tabs.colors': 'Farver', 'editor.tabs.presence_sensor': 'Tilstedeværelsessensor', 'editor.presence_sensor.enable.title': 'Tilstedeværelsessensor', 'editor.presence_sensor.enable.desc': 'Aktivér konfiguration af tilstedeværelsessensor per rum.', 'editor.presence_sensor.entity.title': 'Tilstedeværelses-entity', 'editor.presence_sensor.entity.desc': 'Vælg den binary_sensor der indikerer tilstedeværelse i dette rum.', 'editor.presence_sensor.on_temp.title': 'Temperatur når der er tilstedeværelse', 'editor.presence_sensor.on_temp.desc': 'Hvis sensoren er ON, bruger rummet denne temperatur i stedet for skemaet.', 'editor.presence_sensor.on_delay.title': 'Forsinkelse før tilstedeværelse bruges', 'editor.presence_sensor.on_delay.desc': 'Hvor længe sensoren skal være ON før overstyringstemperaturen bruges. Værdien bruger valgt enhed.', 'editor.presence_sensor.off_delay.title': 'Forsinkelse før skema genoptages', 'editor.presence_sensor.off_delay.desc': 'Hvor længe sensoren skal være OFF før skemaet genoptages. Værdien bruger valgt enhed.', 'editor.tabs.owd': 'Open Window Detection', 'owd.enable': 'Open Window Detection', 'owd.enable.desc': 'Slår termostaterne fra i et rum når valgte vindue-/dørsensorer er åbne.', 'owd.sensors': 'Vindue-/dørsensorer', 'owd.delays.title': 'Forsinkelser', 'owd.delays.desc': 'Minutter før der slukkes (åben) og før skemaet genoptages (lukket).', 'owd.open_delay': 'Efter åbnet (min)', 'owd.close_delay': 'Efter lukket (min)', 'editor.tabs.away': 'Væk fra hjemme', 'editor.tabs.reset': 'Nulstil', 'editor.tabs.sync': 'Sync Engine', 'boiler.switch': 'Boiler switch', 'boiler.switch_type': 'Entitetstype', 'boiler.switch_type.switch': 'Switch', 'boiler.switch_type.input_boolean': 'Input boolean', 'boiler.rooms': 'Rum der er med', 'boiler.rooms.desc': 'Vælg hvilke rum der skal være med i kedelstyringen.', 'boiler.multi_enable': 'Slå flere boilers til', 'boiler.multi_enable.desc': 'Vælg hvilken kedel hvert rum skal bruge, og angiv offsets pr. rum.', 'boiler.multi_rooms': 'Kedel pr. rum', 'boiler.multi_rooms.desc': 'Vælg kedel‑switch og offsets for hvert rum.', 'boiler.room.enable': 'Medtag rum', 'boiler.room.enable.desc': 'Medtag dette rum i kedelstyringen.', 'boiler.enable': 'Aktivér kedelstyring', 'boiler.enable.desc': 'Vis kedelindstillinger i denne cards editor.', 'boiler.temp_sensor': 'Kedel temperatursensor', 'boiler.temp_sensor.desc': 'Temperatursensor som styrer kedel‑switchen.', 'boiler.min_temp': 'Min kedeltemp (°C)', 'boiler.max_temp': 'Maks kedeltemp (°C)', 'boiler.offsets': 'Offset', 'boiler.offsets.desc': 'ON offset: hvor langt under skema-temperaturen den må gå før den tænder. OFF offset: hvor langt over skema-temperaturen den må gå før den slukker.', 'boiler.on_offset': 'ON offset (°C)', 'boiler.off_offset': 'OFF offset (°C)', 'sync.running': 'Igangværende sync…', 'sync.completed': 'Sync gennemført', 'editor.tabs.backup': 'Backup', 'backup.title': 'Backup', 'backup.desc': 'Kopiér hele indholdet af din sync‑sensor til backup‑sensoren og gendan den igen ved behov.', 'backup.now': 'Backup nu', 'backup.restore': 'Gendan', 'backup.restore.confirm_overwrite': 'Gendan fra backup? Dette overskriver nuværende tidsplaner og indstillinger.', 'backup.delete': 'Slet backup', 'backup.delete.confirm': 'Slet valgt backup?', 'backup.new': 'Ny backup', 'backup.auto': 'Auto backup', 'backup.auto.desc': 'Lav automatisk backup med faste intervaller.', 'backup.interval': 'Interval (dage)', 'backup.interval.desc': 'Hvor mange dage der skal gå mellem automatiske backups (min. 1).', 'backup.sensor': 'Backup sensor', 'backup.last': 'Sidste backup', 'backup.slot.title': 'Vælg backup', 'backup.slot.desc': 'Vælg hvilken backup-slot der skal gendannes.', 'backup.slot.empty': 'Ingen backups endnu', 'backup.select': 'Vælg hvad der skal tages backup af', 'backup.select.desc': 'Markerede punkter tages med i backup/gendannelse', 'backup.opt.all': 'Alt', 'backup.opt.main': 'Hovedskemaer', 'backup.opt.weekday': 'Ugedagsskemaer', 'backup.opt.presence': 'Tilstede skemaer', 'backup.opt.settings': 'Editor-indstillinger', 'backup.opt.holiday': 'Helligdags-skemaer', 'backup.opt.colors': 'Farveblokke', 'backup.import.title': 'Importér thermostat_timeline.json', 'backup.import.desc': 'Upload en thermostat_timeline.json fil og importér den. Der laves først en ny backup.', 'backup.import.btn': 'Importér fil', 'backup.export.title': 'Eksportér backupfil', 'backup.export.desc': 'Download en komplet thermostat_timeline_backup.json med alle slots og data.', 'backup.export.btn': 'Download fil', 'backup.imported_suffix': ' - importeret', 'backup.importbk.title': 'Importér backupfil', 'backup.importbk.desc': 'Upload en thermostat_timeline_backup.json og tilføj dens backups til listen.', 'backup.importbk.btn': 'Importér backup', 'backup.importbk.err_invalid': 'Ugyldig backupfil', 'backup.file.choose': 'Vælg importeret backup', 'backup.file.add_selected': 'Importér backup', 'backup.file.add_all': 'Importér alle backups', 'backup.file.loaded_n': 'Fil indlæst ({n} backups)', 'backup.file.imported_ok': 'Importeret ({n})', 'backup.import.err_no_hass': 'Home Assistant er ikke klar', 'backup.import.err_storage_off': 'Delt lager er slået fra. Slå det til først.', 'backup.import.err_no_integration': 'Integrationen thermostat_timeline er ikke tilgængelig', 'backup.import.err_json': 'Ugyldigt JSON', 'backup.import.err_invalid': 'Ugyldig fil: mangler data.schedules', 'away.enable': 'Aktivér væk-tilstand', 'away.desc': 'Når ingen af de valgte personer er hjemme, sænkes termostaterne automatisk til den angivne temperatur.', 'away.temp': 'Væk-temperatur', 'away.persons': 'Personer', 'away.add_person': 'Tilføj person', 'away.resume_schedule': 'Genoptag skema', 'away.resume_away': 'Genoptag væk‑temperatur', 'editor.heat_colors.title': 'Farver for varmeblokke', 'editor.heat_colors.add': 'Tilføj farveinterval', 'editor.colors.col_from': 'Fra °C', 'editor.colors.col_to': 'Til °C', 'editor.colors.col_color': 'Farve', 'editor.colors.refresh': 'Opdater skema', 'editor.colors.clear_local': 'Ryd lokale farvedata', 'editor.colors.clear_local_confirm': 'Dette sletter farveintervaller gemt i din browser for dette kort. Vil du fortsætte?', 'editor.colors.clear_shared': 'Ryd delt farvelager', 'editor.colors.clear_shared_confirm': 'Dette sletter farveintervaller i den valgte lagersensor. Vil du fortsætte?', 'editor.colors.clear_all': 'Ryd alle farvedata', 'editor.colors.clear_all_confirm': 'Dette sletter alle farveintervaller både lokalt og i den valgte lagersensor. Vil du fortsætte?', 'editor.colors.mode': 'Farvetilstand', 'editor.colors.mode.desc': 'Vælg om farver defineres pr. rum eller ens for alle rum.', 'editor.colors.mode_per_room': 'Pr. rum', 'editor.colors.mode_global': 'Samme for alle', 'editor.colors.global_title': 'Alle rum', 'editor.reload_editor': 'Genindlæs editor', 'editor.reload_dashboard': 'Genindlæs dashboard', 'onboard.title': 'Velkommen til Termostat Tidslinje', 'onboard.next': 'Næste', 'onboard.prev': 'Tilbage', 'onboard.skip': 'Spring over', 'onboard.done': 'Færdig', 'onboard.dont_show': 'Vis ikke igen', 'onboard.step_main': 'Dette er hovedtidslinjen. Den viser ønskede temperaturer over 24 timer for hvert rum.', 'onboard.step_add': 'Tilføj en varmeblok via + Tilføj blok eller ved at dobbeltklikke på tidslinjen.', 'onboard.step_edit': 'Redigér eller slet en blok ved dobbeltklik. Brug Ugedage til forskellige dage.', 'onboard.reset': 'Vis guide igen', 'onboard.reset_ok': 'Guiden vises næste gang.', 'onboard.video_not_found': 'Video ikke fundet', 'onboard.image_not_found': 'Billede ikke fundet', 'pause.menu_title': 'Pause auto-apply', 'pause.off_1h': 'Sluk i 1 time', 'pause.off_5h': 'Sluk i 5 timer', 'pause.off_custom': 'Tilpasset varighed…', 'pause.prompt_minutes': 'Angiv varighed (minutter):', 'pause.off_indef': 'Sluk til jeg tænder', 'pause.resume': 'Tænd igen nu', 'pause.close': 'Luk', 'pause.active_until': 'Slukket til {time}', 'pause.active_indef': 'Slukket', 'editor.pausebtn.title': 'Pause‑knap', 'editor.pausebtn.desc': 'Vis en global pauseknap i kortets header. Når pause er aktiv, undertrykkes alle set_temperature‑kommandoer.', 'editor.pause_sensor.title': 'Pause via binary sensor', 'editor.pause_sensor.desc': 'Sæt automatisk pause mens den valgte binary sensor er on.', 'editor.pause_sensor.entity_title': 'Pause sensor', 'editor.pause_sensor.entity_desc': 'Binary sensor som styrer pause (on = pause, off = genoptag).', 'editor.integration.connected': 'Integration klar', 'editor.integration.disconnected': 'Integration ikke tilgængelig', 'profiles.enable': 'Aktivér skema overrule', 'profiles.enable.desc': 'Tillad navngivne dagsskemaer som kan aktiveres og overrule hovedplanen.', 'season.enable.title': 'Aktivér sommer-/vinterskema', 'season.enable.desc': 'Når aktiv, får hver tidsplan både en Sommer- og en Vinter-version, som du kan skifte mellem.', 'season.mode.title': 'Sæson', 'season.mode.winter': 'Vinter', 'season.mode.summer': 'Sommer', 'profiles.button': 'Manuelt skema', 'profiles.editor.title': 'Skemaer', 'profiles.add': 'Tilføj skema', 'profiles.rename': 'Omdøb', 'profiles.delete': 'Slet', 'profiles.activate': 'Aktivér', 'profiles.deactivate': 'Deaktivér', 'profiles.active': 'Aktiv', 'profiles.none': 'Ingen', 'profiles.name_placeholder': 'Skemanavn', 'profiles.name_exists': 'Navnet findes allerede', 'profiles.edit': 'Redigér', 'profiles.preview_note': 'Kun forhåndsvisning. Skemaerne bliver først aktive, når profilen aktiveres.', 'profiles.active_label': 'Manuelt skema aktivt:', 'profiles.save_new': 'Gem ny profil', 'profiles.save_existing': 'Gem ændringer til valgt profil', 'editor.tabs.holidays': 'Helligdage', 'holidays.enable': 'Aktivér helligdage', 'holidays.desc': 'Brug et særligt dagsskema på helligdage.', 'holidays.source': 'Kilde til helligdage', 'holidays.source.calendar': 'Kalender', 'holidays.source.manual': 'Manuelle datoer', 'holidays.entity': 'Helligdags‑entitet', 'holidays.entity_desc': 'Vælg en kalender, der kun giver on/off. Den skal være on på helligdage og off på andre dage.', 'holidays.dates': 'Helligdagsdatoer', 'holidays.add_date': 'Tilføj dato', 'holidays.add_range': 'Tilføj periode', 'holidays.date_in_range': 'Dato findes allerede i en periode', 'holidays.ranges': 'Ferieperioder', 'holidays.single_dates': 'Enkeltdatoer', 'holidays.editor.title': 'Helligdags‑skema', 'holidays.edit': 'Redigér helligdags‑skema', 'holidays.button': 'Helligdage', // Holiday copy-from popup 'holidays.copy_from': 'Kopiér fra…', 'holidays.copy.title': 'Kopiér til helligdags‑skema', 'holidays.copy.select': 'Vælg kilde', 'holidays.copy.apply': 'Indsæt', 'holidays.copy.replace_confirm': 'Erstat eksisterende helligdags‑blokke?', 'holidays.copy.group.main': 'Hovedskema', 'holidays.copy.group.weekdays': 'Ugedage', 'holidays.copy.group.profiles': 'Skemaer', 'holidays.copy.group.presence': 'Tilstede skemaer', 'presence.button': 'Tilstede skemaer', 'presence.editor.title': 'Tilstede skemaer', 'presence.enable_advanced': 'Aktivér avancerede tilstede‑skemaer', 'presence.enable_advanced.desc': 'Brug kombinationer og skemaer baseret på hvem der er hjemme/ude.', // Away delay 'away.delay_enable': 'Forsink Away‑handlinger', 'away.delay_desc': 'Vent før Away ændringer anvendes.', 'away.delay_value': 'Forsinkelse', 'away.delay_unit': 'Enhed', 'away.delay_unit.seconds': 'Sekunder', 'away.delay_unit.minutes': 'Minutter', 'presence.live_header': 'Vis live personstatus i header', 'presence.live_header.desc': 'Chips vises i højre hjørne.', 'presence.combos': 'Kombinationer', 'presence.enable_combo': 'Aktivér denne kombination', 'presence.all_home': 'Alle hjemme', 'presence.none_home': 'Ingen hjemme', 'presence.home': 'Hjemme: {names}', 'presence.away': 'Ude: {names}', 'presence.manage': 'Administrér kombinationer', 'presence.who_home': 'Hvem er hjemme', 'presence.who_away': 'Hvem er ikke hjemme' }, sv: { 'card.title_default': 'Termostat-tidslinje', 'ui.edit_block': 'Redigera block', 'ui.add_block': 'Lägg till block', 'ui.from': 'Från', 'ui.to': 'Till', 'ui.temperature': 'Temperatur', 'ui.delete': 'Ta bort', 'ui.cancel': 'Avbryt', 'ui.close': 'Stäng', 'ui.unsaved_confirm': 'Du har osparade ändringar. Vill du stänga ändå?', 'ui.discard_changes': 'Ignorera ändringar', 'ui.keep_editing': 'Fortsätt redigera', 'ui.save': 'Spara', 'ui.error_prefix': 'Fel: ', 'ui.overlap_msg': 'Du har valt överlappande tider — det överlappar under {start} – {end}.', 'ui.auto_fix': 'Åtgärda automatiskt', 'ui.fix_start_to': 'Justera start till {time}', 'ui.fix_end_to': 'Justera slut till {time}', // Inline meta 'ui.current_temp': 'Nuvarande temperatur:', 'editor.title_label': 'Titel', 'editor.add_entity': 'Lägg till rum', 'editor.entity_placeholder': 'Välj en entitet', 'editor.drag_reorder': 'Dra för att ändra ordning', 'editor.remove': 'Ta bort', 'editor.default_c': 'Standard °C', 'editor.row_height_px': 'Radhöjd (px)', 'editor.max_c': 'Max °C', 'editor.min_c': 'Min °C', 'editor.auto_apply.title': 'Automatisk tillämpning', 'editor.auto_apply.desc': 'När det är aktiverat försöker kortet automatiskt ställa in temperaturen på valda climate-entiteter så att den matchar det planerade värdet för ”just nu”.', 'editor.apply_edit.title': 'Vid redigering', 'editor.apply_edit.desc': 'När du ändrar tidslinjeblock och ändringen påverkar aktuell tid, tillämpas den nya temperaturen omedelbart.', 'editor.apply_default.title': 'Vid ändring av Standard °C', 'editor.apply_default.desc': 'När ”Standard °C” ändras och det påverkar den aktuella perioden, tillämpas den nya temperaturen omedelbart.', 'editor.show_room_temp.title': 'Visa rumstemperatur', 'editor.show_room_temp.desc': 'Visa bubblan med aktuell rumstemperatur på varje rad i tidslinjens header.', 'editor.room_mode.title': 'Typ av rumentitet', 'editor.room_mode.desc': 'När aktiverat, välj en input_number (måltemperatur). När av, välj en climate-entitet.', 'editor.room_temp_sensor_override.title': 'Använd rumstemperatursensor', 'editor.room_temp_sensor_override.desc': 'När den är aktiverad används den valda temperatursensorn för visad rumstemperatur (och pannlogik) i stället för climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Temperatursensor', 'ui.copy_plan': 'Kopiera schema', 'ui.paste_plan': 'Klistra in schema', 'ui.copy_day': 'Kopiera dag', 'ui.paste_day': 'Klistra in dag', 'editor.merge_label': 'Flet med (vælg en ekstra radiatortermostat)', 'editor.display_name_optional': 'Rumsnamn (valfritt)', 'editor.merged_with': 'Sammanslagen med', 'editor.store_enable.title': 'Delad lagring + bakgrundsstyrning', 'editor.store_enable.short': 'Sparar scheman i vald sensor och aktiverar bakgrundsstyrning.', 'editor.store_enable.desc': 'Sparar scheman i vald sensor och aktiverar set_temperature i bakgrunden. Termostater uppdateras även när kortet är stängt. Delar också scheman (värmeblock) och kortinställningar mellan användare/enheter. När av: kommandon skickas bara när kortet är aktivt synligt på en enhet (surfplatta/dator/mobil) och måste förbli öppet.', 'editor.store_missing': 'Integrationen "Thermostat Pro Timeline Sync" är inte installerad. Delad lagring är inte tillgänglig.', 'editor.clear_store': 'Rensa lagring', 'editor.clear_store_confirm': 'Detta tar bort alla sparade scheman. Vill du fortsätta?', 'editor.migrate_to_store': 'Överför data till lagring', 'editor.migrate_confirm': 'Överför dina webbläsar-sparade scheman till vald sensor? Detta skriver över befintliga scheman i lagringen.', 'editor.migrate_pull_confirm': 'Importera scheman från lagring och skriv över lokala webbläsardata?', 'editor.select_storage_first': 'Välj först en lagringssensor.', 'editor.no_local_data': 'Inga lokala webbläsardata hittades.', 'editor.clear_all': 'Rensa all data', 'editor.clear_all_confirm': 'Detta tar bort både lagring och lokala webblässarscheman. Vill du fortsätta?', 'editor.clear_storage_only': 'Rensa endast lagringsdata', 'editor.clear_storage_only_confirm': 'Detta tar bort scheman från vald lagringssensor. Vill du fortsätta?', 'editor.clear_local_only': 'Rensa endast lokala data', 'editor.clear_local_only_confirm': 'Detta tar endast bort scheman som sparats i din webbläsare. Vill du fortsätta?', 'editor.reset.desc': 'Det här gör:\n\n• Rensa all data: Återställer backend-lagring (.storage) för Thermostat Timeline och tar bort ALLA lokala cache/drafts i den här webbläsaren.\n• Rensa endast lokala data: Rensar bara data som är lagrad i den här webbläsaren (localStorage).\n\nTips: Efter en full reset kan en siduppdatering förhindra att gamla drafts kommer tillbaka.', 'week.enable': 'Aktivera veckodagar', 'week.mode': 'Veckoläge', 'week.mode.same_all': 'Samma alla dagar (1)', 'week.mode.weekday_weekend': 'Vardagar + Helg', 'week.mode.weekday_sat_sun': 'Vardagar + Lördag + Söndag', 'week.mode.all_7': 'Alla dagar', 'week.button': 'Veckodagar', 'week.days.mon': 'Mån', 'week.days.tue': 'Tis', 'week.days.wed': 'Ons', 'week.days.thu': 'Tor', 'week.days.fri': 'Fre', 'week.days.sat': 'Lör', 'week.days.sun': 'Sön', 'week.editor.title': 'Veckoschema', 'week.editor.preview_note': 'Endast förhandsvisning. Ändringar tillämpas när du trycker Spara.', 'week.open_editor.desc': 'Öppna veckodagsredigeraren.', 'week.open_editor.btn': 'Öppna', 'week.copy_to_rooms': 'Kopiera vecka till rum', 'week.copy_to_rooms.title': 'Kopiera vecka till rum', 'week.copy_to_rooms.select': 'Välj rum', 'week.copy_to_rooms.apply': 'Klistra in i rum', 'week.group.weekdays': 'Vardagar', 'week.group.weekend': 'Helg', 'week.days_full.mon': 'Måndag', 'week.days_full.tue': 'Tisdag', 'week.days_full.wed': 'Onsdag', 'week.days_full.thu': 'Torsdag', 'week.days_full.fri': 'Fredag', 'week.days_full.sat': 'Lördag', 'week.days_full.sun': 'Söndag', 'editor.timefmt.title': 'Tidsformat', 'editor.timefmt.desc': 'Välj 24-timmars eller 12-timmars (AM/PM) visning på kortet.', 'editor.tempfmt.title': 'Temperaturenhet', 'editor.tempfmt.desc': 'Välj Celsius (°C) eller Fahrenheit (°F) för visning i kortet och editorer.', 'editor.timesrc.title': 'Tidskälla', 'editor.timesrc.desc': 'Välj om kortet ska använda webbläsarens tid eller Home Assistant-tidszon för ”nu”.', 'editor.timesrc.browser': 'Webbläsare', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'Konfigurations-ID', 'editor.instance.desc': 'Ha flera oberoende scheman/inställningar under olika ID:n.', 'editor.instance.new_id': 'Nytt ID', 'editor.heat_colors.title': 'Färger för värmeblock', 'editor.heat_colors.add': 'Lägg till färgintervall', 'editor.colors.col_from': 'Från °C', 'editor.colors.col_to': 'Till °C', 'editor.colors.col_color': 'Färg', 'editor.colors.empty': 'Inga rum tillagda ännu. Lägg till ett rum för att definiera färger.', 'editor.colors.go_add': 'Lägg till rum', 'editor.colors.refresh': 'Uppdatera schema', 'editor.colors.clear_local': 'Rensa lokala färgdata', 'editor.colors.clear_local_confirm': 'Detta tar bort färgintervall som sparats i din webbläsare för detta kort. Vill du fortsätta?', 'editor.colors.clear_shared': 'Rensa delad färglagring', 'editor.colors.clear_shared_confirm': 'Detta tar bort färgintervall i den valda lagringssensorn. Vill du fortsätta?', 'editor.colors.mode': 'Färgläge', 'editor.colors.mode.desc': 'Välj färger per rum eller ett set för alla rum.', 'editor.colors.mode_per_room': 'Per rum', 'editor.colors.mode_global': 'Alla rum', 'editor.colors.global_title': 'Alla rum', 'editor.colors.clear_all': 'Rensa all färgdata', 'editor.colors.clear_all_confirm': 'Detta tar bort alla färgintervall både lokalt och i vald lagringssensor. Vill du fortsätta?', 'editor.reload_editor': 'Ladda om redigerare', 'editor.reload_dashboard': 'Ladda om instrumentpanel', 'onboard.title': 'Välkommen till Termostat‑tidslinje', 'onboard.next': 'Nästa', 'onboard.prev': 'Tillbaka', 'onboard.skip': 'Hoppa över', 'onboard.done': 'Klar', 'onboard.dont_show': 'Visa inte igen', 'onboard.step_main': 'Detta är huvudtidslinjen. Den visar önskad temperatur under dygnet för varje rum.', 'onboard.step_add': 'Lägg till ett värmeblock via + Lägg till block eller dubbelklicka på tidslinjen.', 'onboard.step_edit': 'Redigera eller ta bort ett block genom att dubbelklicka. Använd veckodagar för olika dagar.', 'onboard.reset': 'Visa guiden igen', 'onboard.reset_ok': 'Guiden visas nästa gång.', 'editor.select_entities': 'Rum', 'editor.tabs.rooms': 'Rum', 'editor.test_tool': 'Testverktyg', 'editor.test_select_entity': 'Välj enhet', 'editor.test_empty': 'Välj en enhet för att se tillgängliga kommandon…', 'editor.test_export': 'Exportera .md', 'editor.test_no_fields': 'Inga fält', 'editor.test_select_placeholder': '— Välj —', 'editor.error_prefix': 'Fel: ', 'editor.open_in_hacs': 'Öppna i HACS', 'week.enable.desc': 'Aktivera schema per veckodag. När det är av, används samma schema varje dag.', 'editor.tabs.sync': 'Sync Engine', 'sync.running': 'Synkronisering pågår…', 'sync.completed': 'Synkronisering klar', 'editor.tabs.backup': 'Backup', 'backup.title': 'Backup', 'backup.desc': 'Kopiera innehållet i din sync‑sensor till backup‑sensorn och återställ vid behov.', 'backup.now': 'Säkerhetskopiera nu', 'backup.restore': 'Återställ', 'backup.delete': 'Ta bort backup', 'backup.delete.confirm': 'Ta bort vald backup?', 'backup.new': 'Ny backup', 'backup.auto': 'Automatisk backup', 'backup.auto.desc': 'Gör automatiska säkerhetskopior med intervall.', 'backup.interval': 'Intervall (dagar)', 'backup.interval.desc': 'Hur många dagar mellan automatiska säkerhetskopior (min. 1).', 'backup.sensor': 'Backup‑sensor', 'backup.last': 'Senaste backup', 'backup.export.title': 'Export backup file', 'backup.export.desc': 'Download a full thermostat_timeline_backup.json with all slots and data.', 'backup.export.btn': 'Download file', 'backup.export.title': 'Exportera backupfil', 'backup.export.desc': 'Ladda ner en fullständig thermostat_timeline_backup.json med alla platser och data.', 'backup.export.btn': 'Ladda ner fil', 'backup.file.add_selected': 'Importera backup', 'backup.file.add_all': 'Importera alla backuper', 'backup.file.loaded_n': 'Fil laddad ({n} backuper)', 'backup.file.imported_ok': 'Importerad ({n})', 'backup.slot.title': 'Välj backup', 'backup.slot.desc': 'Välj vilken backupplats som ska återställas.', 'backup.slot.empty': 'Inga backups ännu', 'pause.menu_title': 'Pausa auto‑apply', 'pause.off_1h': 'Pausa 1 timme', 'pause.off_5h': 'Pausa 5 timmar', 'pause.off_custom': 'Anpassad varaktighet…', 'pause.off_indef': 'Pausa tills jag återupptar', 'pause.resume': 'Återuppta nu', 'pause.close': 'Stäng', 'pause.active_until': 'Pausad till {time}', 'pause.active_indef': 'Pausad', 'editor.pausebtn.title': 'Pausknapp', 'editor.pausebtn.desc': 'Visa en global pausknapp i kortets huvud. När paus är aktiv blockeras alla set_temperature‑kommandon.', 'editor.integration.connected': 'Integration klar', 'editor.integration.disconnected': 'Integration inte tillgänglig' , 'profiles.button': 'Manuellt schema' , 'profiles.editor.title': 'Profiler' , 'profiles.add': 'Lägg till profil' , 'profiles.rename': 'Byt namn' , 'profiles.delete': 'Ta bort' , 'profiles.activate': 'Aktivera' , 'profiles.deactivate': 'Inaktivera' , 'profiles.active': 'Aktiv' , 'profiles.none': 'Ingen' , 'profiles.name_placeholder': 'Profilnamn' , 'profiles.edit': 'Redigera' , 'profiles.preview_note': 'Endast förhandsvisning. Scheman blir aktiva när du aktiverar profilen.' , 'editor.tabs.holidays': 'Helgdagar' , 'holidays.enable': 'Aktivera helgdagar' , 'holidays.desc': 'Använd ett särskilt dagsschema på helgdagar.' , 'holidays.source': 'Källa för helgdagar' , 'holidays.source.calendar': 'Kalender' , 'holidays.source.manual': 'Manuella datum' , 'holidays.entity': 'Helgdags‑entitet' , 'holidays.entity_desc': 'Välj en kalender som bara ger on/off. Den ska vara on på helgdagar och off övriga dagar.' , 'holidays.dates': 'Helgdagsdatum' , 'holidays.add_date': 'Lägg till datum' , 'holidays.editor.title': 'Helgdags‑schema' , 'holidays.edit': 'Redigera helgdags‑schema' , 'holidays.button': 'Helgdagar' , 'editor.perroom.title': 'Standard °C per rum' , 'editor.perroom.desc': 'Visa ett Standard °C‑fält på varje rad. När av, används global Standard °C för alla rum.' , 'away.resume_schedule': 'Återuppta schema' , 'away.resume_away': 'Återuppta bortatemperatur' , 'presence.button': 'Närvaroscheman' , 'presence.editor.title': 'Närvaroscheman' , 'presence.enable_advanced': 'Aktivera avancerade närvaroscheman' , 'presence.enable_advanced.desc': 'Använd kombinationer och scheman baserade på vem som är hemma/borta.' , 'away.delay_enable': 'Fördröj borta‑åtgärder' , 'away.delay_desc': 'Vänta innan borta‑ändringar tillämpas.' , 'away.delay_value': 'Fördröjning' , 'away.delay_unit': 'Enhet' , 'away.delay_unit.seconds': 'Sekunder' , 'away.delay_unit.minutes': 'Minuter' , 'presence.combos': 'Kombinationer' , 'presence.enable_combo': 'Aktivera denna kombination' , 'presence.all_home': 'Alla hemma' , 'presence.none_home': 'Ingen hemma' , 'presence.home': 'Hemma: {names}' , 'presence.away': 'Borta: {names}' , 'presence.manage': 'Hantera kombinationer' , 'presence.who_home': 'Vem är hemma' , 'presence.who_away': 'Vem är inte hemma' , 'away.enable': 'Aktivera bortaläge' , 'away.desc': 'När ingen av de valda personerna är hemma, sänks termostaterna automatiskt till den angivna temperaturen.' , 'away.temp': 'Bortatemperatur' , 'away.persons': 'Personer' , 'away.add_person': 'Lägg till person' , 'backup.select': 'Välj vad som ska säkerhetskopieras' , 'backup.select.desc': 'Markerade objekt inkluderas i backup/återställning' , 'backup.opt.all': 'Alla' , 'backup.opt.main': 'Huvudscheman' , 'backup.opt.weekday': 'Veckodagsscheman' , 'backup.opt.presence': 'Närvaroscheman' , 'backup.opt.settings': 'Redigeringsinställningar' , 'backup.opt.holiday': 'Helgdags‑scheman' , 'backup.opt.colors': 'Färgblock' , 'editor.tabs.settings': 'Inställningar' , 'editor.tabs.pause': 'Paus' , 'editor.tabs.weekdays': 'Veckodagar' , 'editor.tabs.colors': 'Färger' , 'editor.tabs.presence_sensor': 'Närvarosensor' , 'editor.presence_sensor.enable.title': 'Närvarosensor' , 'editor.presence_sensor.enable.desc': 'Aktivera konfiguration av närvarosensor per rum.' , 'editor.presence_sensor.entity.title': 'Närvaroentitet' , 'editor.presence_sensor.entity.desc': 'Välj den binary_sensor som indikerar närvaro i detta rum.' , 'editor.presence_sensor.on_temp.title': 'Temperatur när närvaro är PÅ' , 'editor.presence_sensor.on_temp.desc': 'Om sensorn är PÅ använder rummet denna temperatur istället för schemat.' , 'editor.presence_sensor.on_delay.title': 'Fördröjning innan närvaro används' , 'editor.presence_sensor.on_delay.desc': 'Hur länge sensorn måste vara PÅ innan överstyrningstemperaturen används. Värdet använder vald enhet.' , 'editor.presence_sensor.off_delay.title': 'Fördröjning innan schema återupptas' , 'editor.presence_sensor.off_delay.desc': 'Hur länge sensorn måste vara AV innan schemat återupptas. Värdet använder vald enhet.' , 'editor.tabs.owd': 'Open Window Detection' , 'owd.enable': 'Open Window Detection' , 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.' , 'owd.sensors': 'Window/Door sensors' , 'owd.delays.title': 'Delays' , 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).' , 'owd.open_delay': 'After open (min)' , 'owd.close_delay': 'After closed (min)' , 'editor.tabs.away': 'Borta hemifrån' , 'editor.tabs.reset': 'Återställ' , 'editor.tabs.boiler': 'Pannstyrning' , 'boiler.switch': 'Pannbrytare' , 'boiler.switch_type': 'Entitetstyp' , 'boiler.switch_type.switch': 'Switch' , 'boiler.switch_type.input_boolean': 'Input boolean' , 'boiler.rooms': 'Rum som ingår' , 'boiler.rooms.desc': 'Välj vilka rum som ska ingå i pannstyrningen.' , 'boiler.multi_enable': 'Aktivera flera pannor' , 'boiler.multi_enable.desc': 'Tilldela rum till olika pannor och ange offsets per rum.' , 'boiler.multi_rooms': 'Panna per rum' , 'boiler.multi_rooms.desc': 'Välj pannbrytare och offsets för varje rum.' , 'boiler.room.enable': 'Inkludera rum' , 'boiler.room.enable.desc': 'Inkludera detta rum i pannstyrningen.' , 'boiler.enable': 'Aktivera pannstyrning' , 'boiler.enable.desc': 'Visa panninställningar i den här korteditorn.' , 'boiler.temp_sensor': 'Pannans temperatursensor' , 'boiler.temp_sensor.desc': 'Temperatursensor som styr pannbrytaren.' , 'boiler.min_temp': 'Min pann‑temp (°C)' , 'boiler.max_temp': 'Max pann‑temp (°C)' , 'boiler.offsets': 'Offset' , 'boiler.offsets.desc': 'ON‑offset: hur långt under schemamålet innan den slår PÅ. OFF‑offset: hur långt över schemamålet innan den slår AV.' , 'boiler.on_offset': 'ON offset (°C)' , 'boiler.off_offset': 'OFF offset (°C)' , 'editor.temp_sensor': 'Temperatursensor' , 'presence.live_header': 'Visa live‑närvaro i rubriken' , 'presence.live_header.desc': 'Chips visas i högra hörnet.' , 'profiles.enable': 'Aktivera profiler (åsidosättning)' , 'profiles.enable.desc': 'Tillåt namngivna dagsscheman som kan aktiveras för att åsidosätta huvudplanen.' , 'season.enable.title': 'Aktivera sommar-/vinterscheman' , 'season.enable.desc': 'När aktiverat får varje schema en Sommar- och en Vinter-version som du kan växla mellan.' , 'season.mode.title': 'Säsong' , 'season.mode.winter': 'Vinter' , 'season.mode.summer': 'Sommar' , 'profiles.active_label': 'Manuell profil aktiv:' , 'profiles.save_new': 'Spara ny profil' , 'profiles.save_existing': 'Spara ändringar i vald profil' , 'week.view.title': 'Veckovy' , 'week.view.rooms_one_day': 'Alla rum • en dag' , 'week.view.days_one_room': 'Alla dagar • ett rum' , 'week.view_switch.title': 'Byt vy från tidslinjen' , 'week.view_switch.desc': 'Visa en växlare i tidslinjens rubrik så att du kan byta mellan de två veckodagsvisningarna.' , 'week.copy_from_main.title': 'Kopiera huvudschema' , 'week.copy_from_main.desc': 'Du aktiverade veckodagar. Vill du kopiera huvudschemat till veckodagar? Välj veckodagsläge att kopiera till.' , 'week.copy_from_main.mode_label': 'Veckodagsläge' , 'week.copy_from_main.target_label': 'Kopiera till' , 'week.copy_from_main.copy_btn': 'Kopiera' , 'week.copy_from_main.skip_btn': 'Kopiera inte' , 'week.title': 'Veckodagar' , 'week.select_room': 'Välj rum' , 'err.config_entities': "Konfigurationen måste ha 'entities: [climate.xxx, ...]'." , 'onboard.video_not_found': 'Video hittades inte' , 'onboard.image_not_found': 'Bild hittades inte' , 'pause.prompt_minutes': 'Ange varaktighet (minuter):' , 'editor.test_tool': 'Testverktyg' , 'editor.test_select_entity': 'Välj en entitet' , 'editor.test_empty': 'Inget att exportera' , 'editor.test_export': 'Exportera' , 'editor.test_no_fields': 'Inga fält att visa' , 'editor.test_select_placeholder': 'Välj...' , 'editor.error_prefix': 'Fel: ' , 'backup.restore.confirm_overwrite': 'Återställ från säkerhetskopia? Detta kommer att skriva över nuvarande scheman och inställningar.' , 'backup.import.err_no_hass': 'Home Assistant är inte redo' , 'backup.import.err_storage_off': 'Delad lagring är AV. Slå på den först.' , 'backup.import.err_no_integration': 'Integrationen thermostat_timeline är inte tillgänglig' , 'backup.import.err_json': 'Ogiltig JSON' , 'profiles.name_exists': 'Namnet finns redan' }, nb: { 'card.title_default': 'Termostat tidslinje', 'ui.edit_block': 'Rediger blokk', 'ui.add_block': 'Legg til blokk', 'ui.from': 'Fra', 'ui.to': 'Til', 'ui.temperature': 'Temperatur', 'ui.delete': 'Slett', 'ui.cancel': 'Avbryt', 'ui.close': 'Lukk', 'ui.unsaved_confirm': 'Du har ulagrede endringer. Vil du lukke likevel?', 'ui.discard_changes': 'Forkast endringer', 'ui.keep_editing': 'Fortsett redigering', 'ui.save': 'Lagre', 'ui.error_prefix': 'Feil: ', 'ui.overlap_msg': 'Du har valgt overlappende tider — det overlapper i perioden {start} – {end}.', 'ui.auto_fix': 'Rett automatisk', 'ui.fix_start_to': 'Sett start til {time}', 'ui.fix_end_to': 'Sett slutt til {time}', // Inline meta 'ui.current_temp': 'Aktuell temperatur:', 'editor.title_label': 'Tittel', 'editor.add_entity': 'Legg til rom', 'editor.entity_placeholder': 'Velg en entitet', 'editor.drag_reorder': 'Dra for å endre rekkefølge', 'editor.remove': 'Fjern', 'editor.default_c': 'Standard °C', 'editor.row_height_px': 'Radhøyde (px)', 'editor.max_c': 'Maks °C', 'editor.min_c': 'Min °C', 'editor.auto_apply.title': 'Automatisk bruk', 'editor.auto_apply.desc': 'Når aktivert forsøker kortet å sette temperaturen på valgte climate-entiteter slik at den samsvarer med planlagt verdi for «nå».', 'editor.apply_edit.title': 'Ved redigering', 'editor.apply_edit.desc': 'Når du endrer blokker i tidslinjen og endringen påvirker aktuell tid, tilpasses den nye temperaturen umiddelbart.', 'editor.apply_default.title': 'Ved Standard °C-endring', 'editor.apply_default.desc': 'Når «Standard °C» endres og det påvirker gjeldende periode, brukes den nye temperaturen umiddelbart.', 'editor.show_room_temp.title': 'Vis romtemperatur', 'editor.show_room_temp.desc': 'Vis boblen med aktuell romtemperatur på hver rad i tidslinjens toppfelt.', 'editor.room_mode.title': 'Type rom-entitet', 'editor.room_mode.desc': 'Når aktivert, velg en input_number (måltemperatur). Når deaktivert, velg en climate-entitet.', 'editor.room_temp_sensor_override.title': 'Bruk romtemperatursensor', 'editor.room_temp_sensor_override.desc': 'Når aktivert brukes den valgte temperatursensoren for vist romtemperatur (og kjel-logikk) i stedet for climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Temperatursensor', 'ui.copy_plan': 'Kopier plan', 'ui.paste_plan': 'Lim inn plan', 'ui.copy_day': 'Kopier dag', 'ui.paste_day': 'Lim inn dag', 'editor.merge_label': 'Flet med (vælg en ekstra radiatortermostat)', 'editor.display_name_optional': 'Romnavn (valgfritt)', 'editor.merged_with': 'Flettet med', 'editor.store_enable.title': 'Delt lagring + bakgrunnsstyring', 'editor.store_enable.short': 'Lagrer tidsplaner i valgt sensor og aktiverer bakgrunnsstyring.', 'editor.store_enable.desc': 'Lagrer tidsplaner i valgt sensor og aktiverer set_temperature i bakgrunnen. Termostater oppdateres også når kortet er lukket. Deler også tidsplaner (varmeblokker) og kortinnstillinger på tvers av brukere/enheter. Når av: kommandoer sendes kun mens kortet er aktivt synlig på en enhet (nettbrett/PC/mobil) og må forbli åpent.', 'editor.store_missing': 'Integrasjonen "Thermostat Pro Timeline Sync" er ikke installert. Delt lagring er ikke tilgjengelig.', 'editor.clear_store': 'Tøm lagring', 'editor.clear_store_confirm': 'Dette vil slette alle lagrede tidsplaner. Fortsette?', 'editor.migrate_to_store': 'Overfør data til lagring', 'editor.migrate_confirm': 'Overfør nettleserlagrede tidsplaner til valgt sensor? Dette overskriver eksisterende tidsplaner i lagringen.', 'editor.migrate_pull_confirm': 'Importer tidsplaner fra lagring og overskriv lokale nettleserdata?', 'editor.select_storage_first': 'Velg først en lagringssensor.', 'editor.no_local_data': 'Ingen lokale nettleserdata ble funnet.', 'editor.clear_all': 'Tøm all data', 'editor.clear_all_confirm': 'Dette sletter både lagring og lokale nettleserplaner. Fortsette?', 'editor.clear_storage_only': 'Tøm kun lagringsdata', 'editor.clear_storage_only_confirm': 'Dette sletter tidsplaner fra valgt lagringssenso r. Fortsette?', 'editor.clear_local_only': 'Tøm kun lokale data', 'editor.clear_local_only_confirm': 'Dette sletter kun tidsplaner som er lagret i nettleseren. Fortsette?', 'editor.reset.desc': 'Dette gjør:\n\n• Tøm all data: Tilbakestiller backend-lagring (.storage) for Thermostat Timeline og sletter ALLE lokale cache/drafts i denne nettleseren.\n• Tøm kun lokale data: Tømmer kun data lagret i denne nettleseren (localStorage).\n\nTips: Etter en full reset kan en oppdatering av siden hindre at gamle drafts dukker opp igjen.', 'week.enable': 'Aktiver ukedager', 'week.mode': 'Ukedagsmodus', 'week.mode.same_all': 'Samme hver dag (1)', 'week.mode.weekday_weekend': 'Hverdager + Helg', 'week.mode.weekday_sat_sun': 'Hverdager + Lørdag + Søndag', 'week.mode.all_7': 'Alle dager', 'week.button': 'Ukedager', 'week.days.mon': 'Man', 'week.days.tue': 'Tir', 'week.days.wed': 'Ons', 'week.days.thu': 'Tor', 'week.days.fri': 'Fre', 'week.days.sat': 'Lør', 'week.days.sun': 'Søn', 'week.editor.title': 'Ukeplan', 'week.editor.preview_note': 'Kun forhåndsvisning. Endringer brukes når du trykker Lagre.', 'week.open_editor.desc': 'Åpne ukedagseditoren.', 'week.open_editor.btn': 'Åpne', 'week.copy_to_rooms': 'Kopier ukeplan til rom', 'week.copy_to_rooms.title': 'Kopier ukeplan til rom', 'week.copy_to_rooms.select': 'Velg rom', 'week.copy_to_rooms.apply': 'Sett inn i rom', 'week.group.weekdays': 'Hverdager', 'week.group.weekend': 'Helg', 'week.days_full.mon': 'Mandag', 'week.days_full.tue': 'Tirsdag', 'week.days_full.wed': 'Onsdag', 'week.days_full.thu': 'Torsdag', 'week.days_full.fri': 'Fredag', 'week.days_full.sat': 'Lørdag', 'week.days_full.sun': 'Søndag', 'editor.timefmt.title': 'Tidsformat', 'editor.timefmt.desc': 'Velg 24-timers eller 12-timers (AM/PM) visning for tider på kortet.', 'editor.tempfmt.title': 'Temperaturenhet', 'editor.tempfmt.desc': 'Velg Celsius (°C) eller Fahrenheit (°F) for visning i kortet og editorer.', 'editor.timesrc.title': 'Tidskilde', 'editor.timesrc.desc': 'Velg om kortet skal bruke nettleserens tid eller Home Assistant-tidssone for «nå».', 'editor.timesrc.browser': 'Nettleser', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'Konfigurasjons-ID', 'editor.instance.desc': 'Behold flere uavhengige tidsplaner/innstillinger under forskjellige ID-er.', 'editor.instance.new_id': 'Nytt ID', 'editor.heat_colors.title': 'Farger for varmeblokker', 'editor.heat_colors.add': 'Legg til fargeintervall', 'editor.colors.col_from': 'Fra °C', 'editor.colors.col_to': 'Til °C', 'editor.colors.col_color': 'Farge', 'editor.colors.empty': 'Ingen rom lagt til ennå. Legg til et rom for å definere farger.', 'editor.colors.go_add': 'Legg til rom', 'editor.colors.refresh': 'Oppdater tidsplan', 'editor.colors.clear_local': 'Tøm lokale fargedata', 'editor.colors.clear_local_confirm': 'Dette sletter fargeintervaller lagret i nettleseren for dette kortet. Fortsette?', 'editor.colors.clear_shared': 'Tøm delt fargelagring', 'editor.colors.clear_shared_confirm': 'Dette sletter fargeintervaller i den valgte lagringssensoren. Fortsette?', 'editor.colors.mode': 'Fargemodus', 'editor.colors.mode.desc': 'Velg farger per rom eller ett felles sett for alle rom.', 'editor.colors.mode_per_room': 'Per rom', 'editor.colors.mode_global': 'Alle rom', 'editor.colors.global_title': 'Alle rom', 'editor.colors.clear_all': 'Tøm alle fargedata', 'editor.colors.clear_all_confirm': 'Dette sletter alle fargeintervaller både lokalt og i valgt lagringssenso r. Fortsette?', 'editor.reload_editor': 'Last inn editor på nytt', 'editor.reload_dashboard': 'Last inn dashboard på nytt', 'onboard.title': 'Velkommen til Termostat tidslinje', 'onboard.next': 'Neste', 'onboard.prev': 'Tilbake', 'onboard.skip': 'Hopp over', 'onboard.done': 'Ferdig', 'onboard.dont_show': 'Ikke vis igjen', 'onboard.step_main': 'Dette er hovedtidslinjen. Den viser ønsket temperatur gjennom døgnet for hvert rom.', 'onboard.step_add': 'Legg til en varmeblokk via + Legg til blokk eller ved å dobbeltklikke på tidslinjen.', 'onboard.step_edit': 'Rediger eller slett en blokk ved å dobbeltklikke. Bruk ukedager for forskjellige dager.', 'onboard.reset': 'Vis guiden på nytt', 'onboard.reset_ok': 'Velkomst vises neste gang.', 'editor.select_entities': 'Rom', 'editor.tabs.rooms': 'Rum', 'editor.test_tool': 'Testverktøy', 'editor.test_select_entity': 'Velg enhet', 'editor.test_empty': 'Velg en enhet for å se tilgjengelige kommandoer…', 'editor.test_export': 'Eksporter .md', 'editor.test_no_fields': 'Ingen felt', 'editor.test_select_placeholder': '— Velg —', 'editor.error_prefix': 'Feil: ', 'editor.open_in_hacs': 'Åpne i HACS', 'week.enable.desc': 'Aktivera schema per veckodag. När det är av, används samma schema varje dag.', 'editor.tabs.sync': 'Sync Engine', 'sync.running': 'Synkronisering pågår…', 'sync.completed': 'Synkronisering fullført', 'pause.menu_title': 'Pause auto‑apply', 'pause.off_1h': 'Pause i 1 time', 'pause.off_5h': 'Pause i 5 timer', 'pause.off_custom': 'Egendefinert varighet…', 'pause.off_indef': 'Pause til jeg gjenopptar', 'pause.resume': 'Gjenoppta nå', 'pause.close': 'Lukk', 'pause.active_until': 'Pauset til {time}', 'pause.active_indef': 'Pauset', 'editor.pausebtn.title': 'Pauseknapp', 'editor.pausebtn.desc': 'Vis en global pauseknapp i kortets header. Når pause er aktiv, undertrykkes alle set_temperature‑kommandoer.', 'editor.tabs.sync': 'Synk‑motor', 'editor.tabs.backup': 'Backup', 'backup.title': 'Backup', 'backup.desc': 'Kopier innholdet i synk‑sensoren til backup‑sensoren og gjenopprett ved behov.', 'backup.now': 'Ta backup nå', 'backup.restore': 'Gjenopprett', 'backup.delete': 'Slett backup', 'backup.delete.confirm': 'Slett valgt backup?', 'backup.new': 'Ny backup', 'backup.auto': 'Automatisk backup', 'backup.auto.desc': 'Lag automatiske sikkerhetskopier med intervaller.', 'backup.interval': 'Intervall (dager)', 'backup.interval.desc': 'Antall dager mellom automatiske sikkerhetskopier (min. 1).', 'backup.sensor': 'Backup‑sensor', 'backup.last': 'Siste backup', 'backup.export.title': 'Eksporter sikkerhetskopi', 'backup.export.desc': 'Last ned en komplett thermostat_timeline_backup.json med alle slotter og data.', 'backup.export.btn': 'Last ned fil', 'backup.file.add_selected': 'Importer backup', 'backup.file.add_all': 'Importer alle backuper', 'backup.file.loaded_n': 'Fil lastet ({n} backuper)', 'backup.file.imported_ok': 'Importert ({n})', 'backup.slot.title': 'Velg sikkerhetskopi', 'backup.slot.desc': 'Velg hvilken backup-slot som skal gjenopprettes.', 'backup.slot.empty': 'Ingen sikkerhetskopier ennå', 'editor.integration.connected': 'Integrasjon klar', 'editor.integration.disconnected': 'Integrasjon utilgjengelig' , 'profiles.button': 'Manuelt skjema' , 'profiles.editor.title': 'Profiler' , 'profiles.add': 'Legg til profil' , 'profiles.rename': 'Gi nytt navn' , 'profiles.delete': 'Slett' , 'profiles.activate': 'Aktiver' , 'profiles.deactivate': 'Deaktiver' , 'profiles.active': 'Aktiv' , 'profiles.none': 'Ingen' , 'profiles.name_placeholder': 'Profilnavn' , 'profiles.edit': 'Rediger' , 'profiles.preview_note': 'Kun forhåndsvisning. Skjemaer blir aktive når profilen aktiveres.' , 'editor.tabs.holidays': 'Helligdager' , 'holidays.enable': 'Aktiver helligdager' , 'holidays.desc': 'Bruk en egen dagsplan på helligdager.' , 'holidays.source': 'Kilde for helligdager' , 'holidays.source.calendar': 'Kalender' , 'holidays.source.manual': 'Manuelle datoer' , 'holidays.entity': 'Helligdags‑entitet' , 'holidays.entity_desc': 'Velg en kalender som kun gir on/off. Den skal være on på helligdager og off ellers.' , 'holidays.dates': 'Helligdagsdatoer' , 'holidays.add_date': 'Legg til dato' , 'holidays.editor.title': 'Helligdags‑skjema' , 'holidays.edit': 'Rediger helligdags‑skjema' , 'holidays.button': 'Helligdager' , 'editor.perroom.title': 'Standard °C per rom' , 'editor.perroom.desc': 'Vis et Standard °C‑felt på hver rad. Når av, brukes global Standard °C for alle rom.' , 'away.resume_schedule': 'Gjenoppta skjema' , 'away.resume_away': 'Gjenoppta borte‑temperatur' , 'presence.button': 'Tilstede‑skjemaer' , 'presence.editor.title': 'Tilstede‑skjemaer' , 'presence.enable_advanced': 'Aktiver avanserte tilstede‑skjemaer' , 'presence.enable_advanced.desc': 'Bruk kombinasjoner og skjemaer basert på hvem som er hjemme/borte.' , 'away.delay_enable': 'Forsink borte‑handlinger' , 'away.delay_desc': 'Vent før borte‑endringer brukes.' , 'away.delay_value': 'Forsinkelse' , 'away.delay_unit': 'Enhet' , 'away.delay_unit.seconds': 'Sekunder' , 'away.delay_unit.minutes': 'Minutter' , 'presence.combos': 'Kombinasjoner' , 'presence.enable_combo': 'Aktiver denne kombinasjonen' , 'presence.all_home': 'Alle hjemme' , 'presence.none_home': 'Ingen hjemme' , 'presence.home': 'Hjemme: {names}' , 'presence.away': 'Borte: {names}' , 'presence.manage': 'Administrer kombinasjoner' , 'presence.who_home': 'Hvem er hjemme' , 'presence.who_away': 'Hvem er ikke hjemme' , 'away.enable': 'Aktiver borte‑modus' , 'away.desc': 'Når ingen av de valgte personene er hjemme, senkes termostatene automatisk til den angitte temperaturen.' , 'away.temp': 'Borte‑temperatur' , 'away.persons': 'Personer' , 'away.add_person': 'Legg til person' , 'backup.select': 'Velg hva som skal sikkerhetskopieres' , 'backup.select.desc': 'Avmerkede elementer tas med i sikkerhetskopi/gjenoppretting' , 'backup.opt.all': 'Alle' , 'backup.opt.main': 'Hovedskjemaer' , 'backup.opt.weekday': 'Ukedagsskjemaer' , 'backup.opt.presence': 'Tilstede‑skjemaer' , 'backup.opt.settings': 'Redigeringsinnstillinger' , 'backup.opt.holiday': 'Helligdags‑skjemaer' , 'backup.opt.colors': 'Fargeblokker' , 'editor.tabs.settings': 'Innstillinger' , 'editor.tabs.pause': 'Pause' , 'editor.tabs.weekdays': 'Ukedager' , 'editor.tabs.colors': 'Farger' , 'editor.tabs.presence_sensor': 'Tilstedeværelsessensor' , 'editor.presence_sensor.enable.title': 'Tilstedeværelsessensor' , 'editor.presence_sensor.enable.desc': 'Aktiver konfigurasjon av tilstedeværelsessensor per rom.' , 'editor.presence_sensor.entity.title': 'Tilstedeværelses‑entitet' , 'editor.presence_sensor.entity.desc': 'Velg binary_sensor som indikerer tilstedeværelse i dette rommet.' , 'editor.presence_sensor.on_temp.title': 'Temperatur når tilstedeværelse er PÅ' , 'editor.presence_sensor.on_temp.desc': 'Hvis sensoren er PÅ, bruker rommet denne temperaturen i stedet for skjemaet.' , 'editor.presence_sensor.on_delay.title': 'Forsinkelse før tilstedeværelse brukes' , 'editor.presence_sensor.on_delay.desc': 'Hvor lenge sensoren må være PÅ før overstyringstemperaturen brukes. Verdien bruker valgt enhet.' , 'editor.presence_sensor.off_delay.title': 'Forsinkelse før skjema gjenopptas' , 'editor.presence_sensor.off_delay.desc': 'Hvor lenge sensoren må være AV før skjemaet gjenopptas. Verdien bruker valgt enhet.' , 'editor.tabs.owd': 'Open Window Detection' , 'owd.enable': 'Open Window Detection' , 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.' , 'owd.sensors': 'Window/Door sensors' , 'owd.delays.title': 'Delays' , 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).' , 'owd.open_delay': 'After open (min)' , 'owd.close_delay': 'After closed (min)' , 'editor.tabs.away': 'Borte fra hjemmet' , 'editor.tabs.reset': 'Tilbakestill' , 'editor.tabs.boiler': 'Kjelstyring' , 'boiler.switch': 'Kjelbryter' , 'boiler.switch_type': 'Enhetstype' , 'boiler.switch_type.switch': 'Switch' , 'boiler.switch_type.input_boolean': 'Input boolean' , 'boiler.rooms': 'Rom som er med' , 'boiler.rooms.desc': 'Velg hvilke rom som skal være med i kjelstyringen.' , 'boiler.multi_enable': 'Aktiver flere kjeler' , 'boiler.multi_enable.desc': 'Tildel rom til ulike kjeler og sett forskyvninger per rom.' , 'boiler.multi_rooms': 'Kjel per rom' , 'boiler.multi_rooms.desc': 'Velg kjelbryter og forskyvninger for hvert rom.' , 'boiler.room.enable': 'Inkluder rom' , 'boiler.room.enable.desc': 'Inkluder dette rommet i kjelstyringen.' , 'boiler.enable': 'Aktiver kjelstyring' , 'boiler.enable.desc': 'Vis kjelinnstillinger i denne kort‑editoren.' , 'boiler.temp_sensor': 'Kjelens temperatursensor' , 'boiler.temp_sensor.desc': 'Temperatursensor som styrer kjelbryteren.' , 'boiler.min_temp': 'Min kjeltemperatur (°C)' , 'boiler.max_temp': 'Maks kjeltemperatur (°C)' , 'boiler.offsets': 'Offset' , 'boiler.offsets.desc': 'ON offset: hvor langt under planlagt temperatur før den slår PÅ. OFF offset: hvor langt over planlagt temperatur før den slår AV.' , 'boiler.on_offset': 'ON offset (°C)' , 'boiler.off_offset': 'OFF offset (°C)' , 'editor.temp_sensor': 'Temperatursensor' , 'presence.live_header': 'Vis live tilstedeværelse i headeren' , 'presence.live_header.desc': 'Chips vises i høyre hjørne.' , 'profiles.enable': 'Aktiver profiler (overstyring)' , 'profiles.enable.desc': 'Tillat navngitte dagsskjemaer som kan aktiveres for å overstyre hovedplanen.' , 'season.enable.title': 'Aktiver sommer-/vinterplaner' , 'season.enable.desc': 'Når aktivert får hver plan en Sommer- og en Vinter-versjon som du kan bytte mellom.' , 'season.mode.title': 'Sesong' , 'season.mode.winter': 'Vinter' , 'season.mode.summer': 'Sommer' , 'profiles.active_label': 'Manuell profil aktiv:' , 'profiles.save_new': 'Lagre ny profil' , 'profiles.save_existing': 'Lagre endringer i valgt profil' , 'week.view.title': 'Ukevisning' , 'week.view.rooms_one_day': 'Alle rom • én dag' , 'week.view.days_one_room': 'Alle dager • ett rom' , 'week.view_switch.title': 'Bytt visning fra tidslinjen' , 'week.view_switch.desc': 'Vis en bryter i tidslinjens toppfelt for å bytte mellom de to ukedagsvisningene.' , 'week.copy_from_main.title': 'Kopier hovedplan' , 'week.copy_from_main.desc': 'Du aktiverte ukedager. Vil du kopiere hovedplanen til ukedager? Velg ukedagsmodus å kopiere til.' , 'week.copy_from_main.mode_label': 'Ukedagsmodus' , 'week.copy_from_main.target_label': 'Kopier til' , 'week.copy_from_main.copy_btn': 'Kopier' , 'week.copy_from_main.skip_btn': 'Ikke kopier' , 'week.title': 'Ukedager' , 'week.select_room': 'Velg rom' , 'err.config_entities': "Konfig må ha 'entities: [climate.xxx, ...]'." , 'onboard.video_not_found': 'Video ble ikke funnet' , 'onboard.image_not_found': 'Bilde ble ikke funnet' , 'pause.prompt_minutes': 'Angi varighet (minutter):' , 'editor.test_tool': 'Testverktøy' , 'editor.test_select_entity': 'Velg en entitet' , 'editor.test_empty': 'Ingenting å eksportere' , 'editor.test_export': 'Eksporter' , 'editor.test_no_fields': 'Ingen felter å vise' , 'editor.test_select_placeholder': 'Velg...' , 'editor.error_prefix': 'Feil: ' , 'backup.restore.confirm_overwrite': 'Gjenopprett fra sikkerhetskopi? Dette vil overskrive nåværende tidsplaner og innstillinger.' , 'backup.import.err_no_hass': 'Home Assistant er ikke klar' , 'backup.import.err_storage_off': 'Delt lagring er AV. Slå den på først.' , 'backup.import.err_no_integration': 'Integrasjonen thermostat_timeline er ikke tilgjengelig' , 'backup.import.err_json': 'Ugyldig JSON' , 'profiles.name_exists': 'Navnet finnes allerede' }, de: { 'card.title_default': 'Thermostat-Zeitlinie', 'ui.edit_block': 'Block bearbeiten', 'ui.add_block': 'Block hinzufügen', 'ui.from': 'Von', 'ui.to': 'Bis', 'ui.temperature': 'Temperatur', 'ui.delete': 'Löschen', 'ui.cancel': 'Abbrechen', 'ui.close': 'Schließen', 'ui.unsaved_confirm': 'Es gibt ungespeicherte Änderungen. Trotzdem schließen?', 'ui.discard_changes': 'Änderungen verwerfen', 'ui.keep_editing': 'Weiter bearbeiten', 'ui.save': 'Speichern', 'ui.error_prefix': 'Fehler: ', 'ui.overlap_msg': 'Sie haben sich überschneidende Zeiten ausgewählt — Überschneidung zwischen {start} – {end}.', 'ui.auto_fix': 'Automatisch korrigieren', 'ui.fix_start_to': 'Beginn auf {time} korrigieren', 'ui.fix_end_to': 'Ende auf {time} korrigieren', // Inline meta 'ui.current_temp': 'Aktuelle Temperatur:', 'editor.title_label': 'Titel', 'editor.add_entity': 'Raum hinzufügen', 'editor.entity_placeholder': 'Entität auswählen', 'editor.drag_reorder': 'Zum Neuordnen ziehen', 'editor.remove': 'Entfernen', 'editor.default_c': 'Standard °C', 'editor.row_height_px': 'Zeilenhöhe (px)', 'editor.max_c': 'Max °C', 'editor.min_c': 'Min °C', 'editor.auto_apply.title': 'Automatisch anwenden', 'editor.auto_apply.desc': 'Wenn aktiviert, versucht die Karte, die Temperatur der ausgewählten climate-Entitäten automatisch so einzustellen, dass sie dem geplanten Wert für „jetzt“ entspricht.', 'editor.apply_edit.title': 'Beim Bearbeiten', 'editor.apply_edit.desc': 'Wenn Sie Blöcke der Zeitlinie ändern und die Änderung die aktuelle Zeit betrifft, wird die neue Temperatur sofort angewendet.', 'editor.apply_default.title': 'Bei Änderung von Standard °C', 'editor.apply_default.desc': 'Wenn „Standard °C“ geändert wird und dies den aktuellen Zeitraum betrifft, wird die neue Temperatur sofort angewendet.', 'editor.show_room_temp.title': 'Raumtemperatur anzeigen', 'editor.show_room_temp.desc': 'Zeigt die Blase mit der aktuellen Raumtemperatur pro Zeile in der Kopfzeile der Zeitlinie an.', 'editor.room_mode.title': 'Raum-Entitätstyp', 'editor.room_mode.desc': 'Wenn aktiviert, wählen Sie eine input_number (Zieltemperatur). Wenn deaktiviert, wählen Sie eine climate-Entität.', 'editor.room_temp_sensor_override.title': 'Raumtemperatursensor verwenden', 'editor.room_temp_sensor_override.desc': 'Wenn aktiviert, verwendet die angezeigte Raumtemperatur (und die Kessel-Logik) den ausgewählten Temperatursensor statt climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Temperatursensor', 'ui.copy_plan': 'Plan kopieren', 'ui.paste_plan': 'Plan einfügen', 'ui.copy_day': 'Tag kopieren', 'ui.paste_day': 'Tag einfügen', 'editor.merge_label': 'Mit zusammenführen (zusätzlichen Thermostat hinzufügen)', 'editor.display_name_optional': 'Raumname (optional)', 'editor.merged_with': 'Zusammengeführt mit', 'editor.store_enable.title': 'Geteilter Speicher + Hintergrundsteuerung', 'editor.store_enable.short': 'Speichert Pläne im ausgewählten Sensor und aktiviert die Hintergrundsteuerung.', 'editor.store_enable.desc': 'Speichert Pläne im ausgewählten Sensor und aktiviert set_temperature im Hintergrund. Thermostate werden auch aktualisiert, wenn die Karte geschlossen ist. Teilt zudem Zeitpläne (Heizblöcke) und Karten-Einstellungen zwischen Benutzern/Geräten. Wenn aus: Befehle werden nur gesendet, solange die Karte auf einem Gerät (Tablet/PC/Mobil) aktiv sichtbar ist und geöffnet bleibt.', 'editor.store_missing': 'Die Integration „Thermostat Pro Timeline Sync“ ist nicht installiert. Geteilter Speicher ist nicht verfügbar.', 'editor.clear_store': 'Speicher leeren', 'editor.clear_store_confirm': 'Dadurch werden alle gespeicherten Zeitpläne gelöscht. Fortfahren?', 'editor.migrate_to_store': 'Daten in Speicher übertragen', 'editor.migrate_confirm': 'Ihre im Browser gespeicherten Pläne in den ausgewählten Sensor übertragen? Vorhandene gespeicherte Pläne werden überschrieben.', 'editor.migrate_pull_confirm': 'Pläne aus dem Speicher importieren und lokale Browserdaten überschreiben?', 'editor.select_storage_first': 'Bitte wähle zuerst einen Speichersensor aus.', 'editor.no_local_data': 'Keine lokalen Browserdaten gefunden.', 'editor.clear_all': 'Alle Daten löschen', 'editor.clear_all_confirm': 'Dies löscht sowohl Speicher- als auch lokale Browser-Zeitpläne. Fortfahren?', 'editor.clear_storage_only': 'Nur Speicherdaten löschen', 'editor.clear_storage_only_confirm': 'Dies löscht Zeitpläne vom ausgewählten Speichersensor. Fortfahren?', 'editor.clear_local_only': 'Nur lokale Daten löschen', 'editor.clear_local_only_confirm': 'Dies löscht nur im Browser gespeicherte Zeitpläne. Fortfahren?', 'editor.reset.desc': 'Was das macht:\n\n• Alle Daten löschen: Setzt den Backend-Speicher (.storage) für Thermostat Timeline zurück und löscht ALLE lokalen Browser-Caches/Entwürfe.\n• Nur lokale Daten löschen: Löscht nur Daten, die in diesem Browser gespeichert sind (localStorage).\n\nTipp: Nach einem vollständigen Reset die Seite neu laden, damit alte Entwürfe nicht wieder auftauchen.', 'week.enable': 'Wochentage aktivieren', 'week.mode': 'Wochentagsmodus', 'week.mode.same_all': 'Jeden Tag gleich (1)', 'week.mode.weekday_weekend': 'Werktage + Wochenende', 'week.mode.weekday_sat_sun': 'Werktage + Samstag + Sonntag', 'week.mode.all_7': 'Alle Tage', 'week.button': 'Wochentage', 'week.days.mon': 'Mo', 'week.days.tue': 'Di', 'week.days.wed': 'Mi', 'week.days.thu': 'Do', 'week.days.fri': 'Fr', 'week.days.sat': 'Sa', 'week.days.sun': 'So', 'week.editor.title': 'Wochenschema', 'week.editor.preview_note': 'Nur Vorschau. Änderungen werden erst beim Speichern angewendet.', 'week.open_editor.desc': 'Wochentags-Editor öffnen.', 'week.open_editor.btn': 'Öffnen', 'week.copy_to_rooms': 'Wochenschema in Räume kopieren', 'week.copy_to_rooms.title': 'Wochenschema in Räume kopieren', 'week.copy_to_rooms.select': 'Räume auswählen', 'week.copy_to_rooms.apply': 'In Räume einfügen', 'week.group.weekdays': 'Werktage', 'week.group.weekend': 'Wochenende', 'week.days_full.mon': 'Montag', 'week.days_full.tue': 'Dienstag', 'week.days_full.wed': 'Mittwoch', 'week.days_full.thu': 'Donnerstag', 'week.days_full.fri': 'Freitag', 'week.days_full.sat': 'Samstag', 'week.days_full.sun': 'Sonntag', 'editor.timefmt.title': 'Zeitformat', 'editor.timefmt.desc': '24-Stunden- oder 12-Stunden-Anzeige (AM/PM) für Zeiten auf der Karte wählen.', 'editor.tempfmt.title': 'Temperatureinheit', 'editor.tempfmt.desc': 'Celsius (°C) oder Fahrenheit (°F) für Anzeige in Karte und Editoren wählen.', 'editor.timesrc.title': 'Zeitquelle', 'editor.timesrc.desc': 'Wählen, ob die Karte die Browser‑Zeit oder die Home‑Assistant‑Zeitzone für „jetzt“ verwendet.', 'editor.timesrc.browser': 'Browser', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'Konfigurations-ID', 'editor.instance.desc': 'Mehrere unabhängige Zeitpläne/Einstellungen unter verschiedenen IDs verwalten.', 'editor.instance.new_id': 'Neue ID', 'editor.heat_colors.title': 'Farben für Heizblöcke', 'editor.heat_colors.add': 'Farbintervall hinzufügen', 'editor.colors.col_from': 'Von °C', 'editor.colors.col_to': 'Bis °C', 'editor.colors.col_color': 'Farbe', 'editor.colors.empty': 'Noch keine Räume hinzugefügt. Fügen Sie einen Raum hinzu, um Farben zu definieren.', 'editor.colors.go_add': 'Raum hinzufügen', 'editor.colors.refresh': 'Zeitplan aktualisieren', 'editor.colors.clear_local': 'Lokale Farb-Daten löschen', 'editor.colors.clear_local_confirm': 'Dadurch werden in Ihrem Browser für diese Karte gespeicherte Farbbereiche gelöscht. Fortfahren?', 'editor.colors.clear_shared': 'Geteilten Farbspeicher löschen', 'editor.colors.clear_shared_confirm': 'Dadurch werden Farbbereiche im ausgewählten Speichersensor gelöscht. Fortfahren?', 'editor.colors.mode': 'Farbmodus', 'editor.colors.mode.desc': 'Farben pro Raum oder ein Satz für alle Räume wählen.', 'editor.colors.mode_per_room': 'Pro Raum', 'editor.colors.mode_global': 'Alle Räume', 'editor.colors.global_title': 'Alle Räume', 'editor.colors.clear_all': 'Alle Farbdaten löschen', 'editor.colors.clear_all_confirm': 'Dies löscht alle Farbbereiche sowohl lokal als auch im ausgewählten Speichersensor. Fortfahren?', 'editor.reload_editor': 'Editor neu laden', 'editor.reload_dashboard': 'Dashboard neu laden', 'onboard.title': 'Willkommen bei Thermostat‑Zeitlinie', 'onboard.next': 'Weiter', 'onboard.prev': 'Zurück', 'onboard.skip': 'Überspringen', 'onboard.done': 'Fertig', 'onboard.dont_show': 'Nicht erneut anzeigen', 'onboard.step_main': 'Dies ist die Hauptzeitlinie. Sie zeigt Solltemperaturen über 24 Stunden für jeden Raum.', 'onboard.step_add': 'Fügen Sie einen Heizblock über + Block hinzufügen oder per Doppelklick hinzu.', 'onboard.step_edit': 'Bearbeiten/Löschen per Doppelklick. Verwenden Sie Wochentage für verschiedene Tage.', 'onboard.reset': 'Anleitung erneut anzeigen', 'onboard.reset_ok': 'Willkommen wird beim nächsten Mal angezeigt.', 'editor.select_entities': 'Räume', 'editor.tabs.rooms': 'Räume', 'editor.test_tool': 'Testwerkzeug', 'editor.test_select_entity': 'Entität auswählen', 'editor.test_empty': 'Wählen Sie eine Entität, um verfügbare Befehle anzuzeigen…', 'editor.test_export': 'Exportieren .md', 'editor.test_no_fields': 'Keine Felder', 'editor.test_select_placeholder': '— Auswählen —', 'editor.error_prefix': 'Fehler: ', 'editor.open_in_hacs': 'In HACS öffnen', 'week.enable.desc': 'Pro-Wochentag-Plan aktivieren. Wenn aus, wird jeden Tag derselbe Plan verwendet.', 'editor.tabs.sync': 'Sync‑Engine', 'sync.running': 'Synchronisierung läuft…', 'sync.completed': 'Synchronisierung abgeschlossen', 'editor.tabs.backup': 'Backup', 'backup.title': 'Backup', 'backup.desc': 'Kopiere den Inhalt deines Sync‑Sensors in den Backup‑Sensor und bei Bedarf wiederherstellen.', 'backup.now': 'Jetzt sichern', 'backup.restore': 'Wiederherstellen', 'backup.delete': 'Backup löschen', 'backup.delete.confirm': 'Ausgewähltes Backup löschen?', 'backup.new': 'Neues Backup', 'backup.auto': 'Automatisches Backup', 'backup.auto.desc': 'Automatische Sicherungen in festen Intervallen erstellen.', 'backup.interval': 'Intervall (Tage)', 'backup.interval.desc': 'Anzahl der Tage zwischen automatischen Sicherungen (min. 1).', 'backup.sensor': 'Backup‑Sensor', 'backup.last': 'Letztes Backup', 'backup.export.title': 'Backup-Datei exportieren', 'backup.export.desc': 'Eine vollständige thermostat_timeline_backup.json mit allen Slots und Daten herunterladen.', 'backup.export.btn': 'Datei herunterladen', 'backup.file.add_selected': 'Backup importieren', 'backup.file.add_all': 'Alle Backups importieren', 'backup.file.loaded_n': 'Datei geladen ({n} Backups)', 'backup.file.imported_ok': 'Importiert ({n})', 'backup.slot.title': 'Backup auswählen', 'backup.slot.desc': 'Wählen Sie, welcher Backup-Slot wiederhergestellt werden soll.', 'backup.slot.empty': 'Noch keine Backups', 'pause.menu_title': 'Auto‑Anwendung pausieren', 'pause.off_1h': '1 Stunde pausieren', 'pause.off_5h': '5 Stunden pausieren', 'pause.off_custom': 'Benutzerdefinierte Dauer…', 'pause.off_indef': 'Pausieren bis ich fortsetze', 'pause.resume': 'Jetzt fortsetzen', 'pause.close': 'Schließen', 'pause.active_until': 'Pausiert bis {time}', 'pause.active_indef': 'Pausiert', 'editor.pausebtn.title': 'Pause‑Schaltfläche', 'editor.pausebtn.desc': 'Zeigt eine globale Pause‑Schaltfläche in der Kopfzeile. Während der Pause werden alle set_temperature‑Befehle unterdrückt.', 'editor.integration.connected': 'Integration bereit', 'editor.integration.disconnected': 'Integration nicht verfügbar' , 'profiles.button': 'Manueller Zeitplan' , 'profiles.editor.title': 'Profile' , 'profiles.add': 'Profil hinzufügen' , 'profiles.rename': 'Umbenennen' , 'profiles.delete': 'Löschen' , 'profiles.activate': 'Aktivieren' , 'profiles.deactivate': 'Deaktivieren' , 'profiles.active': 'Aktiv' , 'profiles.none': 'Keins' , 'profiles.name_placeholder': 'Profilname' , 'profiles.edit': 'Bearbeiten' , 'profiles.preview_note': 'Nur Vorschau. Pläne werden aktiv, wenn Sie das Profil aktivieren.' , 'editor.tabs.holidays': 'Feiertage' , 'holidays.enable': 'Feiertage aktivieren' , 'holidays.desc': 'An Feiertagen einen speziellen Tagesplan verwenden.' , 'holidays.source': 'Feiertagsquelle' , 'holidays.source.calendar': 'Kalender' , 'holidays.source.manual': 'Manuelle Daten' , 'holidays.entity': 'Feiertags‑Entität' , 'holidays.entity_desc': 'Wähle einen Kalender, der nur Ein/Aus meldet. An Feiertagen sollte er „on“ sein, an anderen Tagen „off“.' , 'holidays.dates': 'Feiertagsdaten' , 'holidays.add_date': 'Datum hinzufügen' , 'holidays.editor.title': 'Feiertags‑Zeitplan' , 'holidays.edit': 'Feiertags‑Zeitplan bearbeiten' , 'holidays.button': 'Feiertage' , 'editor.perroom.title': 'Standard‑°C pro Raum' , 'editor.perroom.desc': 'Zeigt ein Standard‑°C‑Feld in jeder Zeile. Wenn aus, wird die globale Standard‑°C für alle Räume verwendet.' , 'away.resume_schedule': 'Zeitplan fortsetzen' , 'away.resume_away': 'Abwesenheits‑Temperatur fortsetzen' , 'presence.button': 'Anwesenheitspläne' , 'presence.editor.title': 'Anwesenheitspläne' , 'presence.enable_advanced': 'Erweiterte Anwesenheitspläne aktivieren' , 'presence.enable_advanced.desc': 'Verwende Kombinationen und Zeitpläne basierend darauf, wer zu Hause/abwesend ist.' , 'away.delay_enable': 'Abwesenheits‑Aktionen verzögern' , 'away.delay_desc': 'Vor Anwendung der Abwesenheitsänderungen warten.' , 'away.delay_value': 'Verzögerung' , 'away.delay_unit': 'Einheit' , 'away.delay_unit.seconds': 'Sekunden' , 'away.delay_unit.minutes': 'Minuten' , 'presence.combos': 'Kombinationen' , 'presence.enable_combo': 'Diese Kombination aktivieren' , 'presence.all_home': 'Alle zu Hause' , 'presence.none_home': 'Niemand zu Hause' , 'presence.home': 'Zu Hause: {names}' , 'presence.away': 'Abwesend: {names}' , 'presence.manage': 'Kombinationen verwalten' , 'presence.who_home': 'Wer ist zu Hause' , 'presence.who_away': 'Wer ist nicht zu Hause' , 'away.enable': 'Abwesenheitsmodus aktivieren' , 'away.desc': 'Wenn keine der ausgewählten Personen zu Hause ist, werden die Thermostate automatisch auf die eingestellte Temperatur abgesenkt.' , 'away.temp': 'Abwesenheits‑Temperatur' , 'away.persons': 'Personen' , 'away.add_person': 'Person hinzufügen' , 'backup.select': 'Auswählen, was gesichert wird' , 'backup.select.desc': 'Markierte Elemente werden in Sicherung/Wiederherstellung einbezogen.' , 'backup.opt.all': 'Alle' , 'backup.opt.main': 'Hauptzeitpläne' , 'backup.opt.weekday': 'Wochentags‑Zeitpläne' , 'backup.opt.presence': 'Anwesenheitspläne' , 'backup.opt.settings': 'Editor‑Einstellungen' , 'backup.opt.holiday': 'Feiertags‑Zeitpläne' , 'backup.opt.colors': 'Farbblöcke' , 'editor.tabs.settings': 'Einstellungen' , 'editor.tabs.pause': 'Pause' , 'editor.tabs.weekdays': 'Wochentage' , 'editor.tabs.colors': 'Farben' , 'editor.tabs.presence_sensor': 'Anwesenheitssensor' , 'editor.presence_sensor.enable.title': 'Anwesenheitssensor' , 'editor.presence_sensor.enable.desc': 'Aktiviert die Anwesenheitssensor‑Konfiguration pro Raum.' , 'editor.presence_sensor.entity.title': 'Anwesenheitsentität' , 'editor.presence_sensor.entity.desc': 'Wähle den binary_sensor, der Anwesenheit in diesem Raum anzeigt.' , 'editor.presence_sensor.on_temp.title': 'Temperatur bei Anwesenheit (EIN)' , 'editor.presence_sensor.on_temp.desc': 'Wenn der Sensor EIN ist, nutzt dieser Raum diese Temperatur statt dem Zeitplan.' , 'editor.presence_sensor.on_delay.title': 'Verzögerung bis Anwesenheit gilt' , 'editor.presence_sensor.on_delay.desc': 'Wie lange der Sensor EIN sein muss, bevor die Override-Temperatur gilt. Wert verwendet die gewählte Einheit.' , 'editor.presence_sensor.off_delay.title': 'Verzögerung bis Zeitplan fortgesetzt wird' , 'editor.presence_sensor.off_delay.desc': 'Wie lange der Sensor AUS sein muss, bevor der Zeitplan fortgesetzt wird. Wert verwendet die gewählte Einheit.' , 'editor.tabs.owd': 'Open Window Detection' , 'owd.enable': 'Open Window Detection' , 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.' , 'owd.sensors': 'Window/Door sensors' , 'owd.delays.title': 'Delays' , 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).' , 'owd.open_delay': 'After open (min)' , 'owd.close_delay': 'After closed (min)' , 'editor.tabs.away': 'Abwesend von zu Hause' , 'editor.tabs.reset': 'Zurücksetzen' , 'editor.tabs.boiler': 'Kesselsteuerung' , 'boiler.switch': 'Kesselschalter' , 'boiler.switch_type': 'Entitätstyp' , 'boiler.switch_type.switch': 'Schalter' , 'boiler.switch_type.input_boolean': 'Input Boolean' , 'boiler.rooms': 'Einbezogene Räume' , 'boiler.rooms.desc': 'Wähle, welche Räume in der Kesselsteuerung berücksichtigt werden.' , 'boiler.multi_enable': 'Mehrere Kessel aktivieren' , 'boiler.multi_enable.desc': 'Räume verschiedenen Kesseln zuweisen und Offsets pro Raum setzen.' , 'boiler.multi_rooms': 'Kessel pro Raum' , 'boiler.multi_rooms.desc': 'Kesselschalter und Offsets pro Raum auswählen.' , 'boiler.room.enable': 'Raum einbeziehen' , 'boiler.room.enable.desc': 'Diesen Raum in die Kesselsteuerung einbeziehen.' , 'boiler.enable': 'Kesselsteuerung aktivieren' , 'boiler.enable.desc': 'Kesseleinstellungen in diesem Karten‑Editor anzeigen.' , 'boiler.temp_sensor': 'Kessel‑Temperatursensor' , 'boiler.temp_sensor.desc': 'Temperatursensor zur Steuerung des Kesselschalters.' , 'boiler.min_temp': 'Min. Kesseltemperatur (°C)' , 'boiler.max_temp': 'Max. Kesseltemperatur (°C)' , 'boiler.offsets': 'Offsets' , 'boiler.offsets.desc': 'ON‑Offset: wie weit unter dem Zeitplan‑Sollwert bevor EIN geschaltet wird. OFF‑Offset: wie weit über dem Zeitplan‑Sollwert bevor AUS geschaltet wird.' , 'boiler.on_offset': 'ON‑Offset (°C)' , 'boiler.off_offset': 'OFF‑Offset (°C)' , 'editor.temp_sensor': 'Temperatursensor' , 'presence.live_header': 'Live‑Anwesenheit in Kopfzeile anzeigen' , 'presence.live_header.desc': 'Chips werden in der rechten Ecke angezeigt.' , 'profiles.enable': 'Profile aktivieren (Überschreiben)' , 'profiles.enable.desc': 'Benannte Tagespläne erlauben, die den Hauptplan bei Aktivierung überschreiben.' , 'season.enable.title': 'Sommer-/Winterpläne aktivieren' , 'season.enable.desc': 'Wenn aktiviert, hat jeder Plan eine Sommer- und eine Winter-Version, zwischen denen du wechseln kannst.' , 'season.mode.title': 'Saison' , 'season.mode.winter': 'Winter' , 'season.mode.summer': 'Sommer' , 'profiles.active_label': 'Manuelles Profil aktiv:' , 'profiles.save_new': 'Neues Profil speichern' , 'profiles.save_existing': 'Änderungen im ausgewählten Profil speichern' , 'week.view.title': 'Wochenansicht' , 'week.view.rooms_one_day': 'Alle Räume • ein Tag' , 'week.view.days_one_room': 'Alle Tage • ein Raum' , 'week.view_switch.title': 'Ansicht in der Zeitleiste wechseln' , 'week.view_switch.desc': 'Zeigt einen Umschalter in der Kopfzeile der Zeitleiste, um zwischen den beiden Wochentagsansichten zu wechseln.' , 'week.copy_from_main.title': 'Hauptplan kopieren' , 'week.copy_from_main.desc': 'Du hast Wochentage aktiviert. Soll der Hauptplan in die Wochentage kopiert werden? Wähle den Wochentagsmodus zum Kopieren.' , 'week.copy_from_main.mode_label': 'Wochentagsmodus' , 'week.copy_from_main.target_label': 'Kopieren nach' , 'week.copy_from_main.copy_btn': 'Kopieren' , 'week.copy_from_main.skip_btn': 'Nicht kopieren' , 'week.title': 'Wochentage' , 'week.select_room': 'Raum auswählen' , 'err.config_entities': "Die Konfiguration muss 'entities: [climate.xxx, ...]' enthalten." , 'onboard.video_not_found': 'Video nicht gefunden' , 'onboard.image_not_found': 'Bild nicht gefunden' , 'pause.prompt_minutes': 'Dauer eingeben (Minuten):' , 'editor.test_tool': 'Testwerkzeug' , 'editor.test_select_entity': 'Entität auswählen' , 'editor.test_empty': 'Nichts zu exportieren' , 'editor.test_export': 'Exportieren' , 'editor.test_no_fields': 'Keine Felder zum Anzeigen' , 'editor.test_select_placeholder': 'Auswählen...' , 'editor.error_prefix': 'Fehler: ' , 'backup.restore.confirm_overwrite': 'Aus Backup wiederherstellen? Dadurch werden aktuelle Zeitpläne und Einstellungen überschrieben.' , 'backup.import.err_no_hass': 'Home Assistant ist nicht bereit' , 'backup.import.err_storage_off': 'Gemeinsamer Speicher ist AUS. Bitte zuerst aktivieren.' , 'backup.import.err_no_integration': 'Integration thermostat_timeline ist nicht verfügbar' , 'backup.import.err_json': 'Ungültiges JSON' , 'profiles.name_exists': 'Name existiert bereits' }, es: { 'card.title_default': 'Línea de tiempo del termostato', 'ui.edit_block': 'Editar bloque', 'ui.add_block': 'Agregar bloque', 'ui.from': 'Desde', 'ui.to': 'Hasta', 'ui.temperature': 'Temperatura', 'ui.delete': 'Eliminar', 'ui.cancel': 'Cancelar', 'ui.close': 'Cerrar', 'ui.unsaved_confirm': 'Tienes cambios sin guardar. ¿Cerrar igualmente?', 'ui.discard_changes': 'Descartar cambios', 'ui.keep_editing': 'Seguir editando', 'ui.save': 'Guardar', 'ui.error_prefix': 'Error: ', 'ui.overlap_msg': 'Has seleccionado horas superpuestas — se solapa durante {start} – {end}.', 'ui.auto_fix': 'Corregir automáticamente', 'ui.fix_start_to': 'Ajustar inicio a {time}', 'ui.fix_end_to': 'Ajustar fin a {time}', // Inline meta 'ui.current_temp': 'Temperatura actual:', 'editor.title_label': 'Título', 'editor.add_entity': 'Añadir habitación', 'editor.entity_placeholder': 'Selecciona una entidad', 'editor.drag_reorder': 'Arrastrar para reordenar', 'editor.remove': 'Eliminar', 'editor.default_c': 'Predeterminado °C', 'editor.row_height_px': 'Altura de fila (px)', 'editor.max_c': 'Máx °C', 'editor.min_c': 'Mín °C', 'editor.auto_apply.title': 'Aplicar automáticamente', 'editor.auto_apply.desc': 'Cuando está activado, la tarjeta intentará establecer la temperatura en las entidades climate seleccionadas para que coincida con el valor planificado para “ahora mismo”.', 'editor.apply_edit.title': 'Al editar', 'editor.apply_edit.desc': 'Cuando cambias bloques de la línea de tiempo y el cambio afecta a la hora actual, la nueva temperatura se aplica inmediatamente.', 'editor.apply_default.title': 'Al cambiar Predeterminado °C', 'editor.apply_default.desc': 'Cuando “Predeterminado °C” cambia y afecta al período actual, la nueva temperatura se aplica inmediatamente.', 'editor.show_room_temp.title': 'Mostrar temperatura de la habitación', 'editor.show_room_temp.desc': 'Muestra la burbuja de temperatura actual de la habitación en cada fila del encabezado de la línea de tiempo.', 'editor.room_mode.title': 'Tipo de entidad de habitación', 'editor.room_mode.desc': 'Cuando está activado, selecciona un input_number (temperatura objetivo). Cuando está desactivado, selecciona una entidad climate.', 'editor.room_temp_sensor_override.title': 'Usar sensor de temperatura de la habitación', 'editor.room_temp_sensor_override.desc': 'Cuando está activado, la temperatura mostrada de la habitación (y la lógica de la caldera) usa el sensor de temperatura seleccionado en lugar de climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Sensor de temperatura', 'ui.copy_plan': 'Copiar plan', 'ui.paste_plan': 'Pegar plan', 'ui.copy_day': 'Copiar día', 'ui.paste_day': 'Pegar día', 'editor.merge_label': 'Combinar con (añadir termostato extra)', 'editor.display_name_optional': 'Nombre de la habitación (opcional)', 'editor.merged_with': 'Combinado con', 'editor.store_enable.title': 'Almacenamiento compartido + control en segundo plano', 'editor.store_enable.short': 'Guarda horarios en el sensor seleccionado y habilita el control en segundo plano.', 'editor.store_enable.desc': 'Guarda horarios en el sensor seleccionado y habilita set_temperature en segundo plano. Los termostatos se actualizan incluso con la tarjeta cerrada. Además comparte horarios (bloques de calor) y la configuración de la tarjeta entre usuarios/dispositivos. Si está desactivado: los comandos se envían solo mientras la tarjeta está visible en un dispositivo (tableta/PC/móvil) y debe permanecer abierta.', 'editor.store_missing': 'La integración "Thermostat Pro Timeline Sync" no está instalada. El almacenamiento compartido no está disponible.', 'editor.clear_store': 'Vaciar almacenamiento', 'editor.clear_store_confirm': 'Esto eliminará todos los horarios guardados. ¿Continuar?', 'editor.migrate_to_store': 'Transferir datos al almacenamiento', 'editor.migrate_confirm': '¿Transferir los horarios guardados en tu navegador al sensor seleccionado? Esto sobrescribirá los horarios existentes en el almacenamiento.', 'editor.migrate_pull_confirm': '¿Importar horarios desde el almacenamiento y sobrescribir los datos locales del navegador?', 'editor.select_storage_first': 'Selecciona primero un sensor de almacenamiento.', 'editor.no_local_data': 'No se encontraron datos locales del navegador.', 'editor.clear_all': 'Borrar todos los datos', 'editor.clear_all_confirm': 'Esto eliminará los horarios tanto del almacenamiento como del navegador local. ¿Continuar?', 'editor.clear_storage_only': 'Borrar solo datos de almacenamiento', 'editor.clear_storage_only_confirm': 'Esto eliminará los horarios del sensor de almacenamiento seleccionado. ¿Continuar?', 'editor.clear_local_only': 'Borrar solo datos locales', 'editor.clear_local_only_confirm': 'Esto eliminará solo los horarios guardados en tu navegador. ¿Continuar?', 'editor.reset.desc': 'Qué hace esto:\n\n• Borrar todos los datos: Restablece el almacenamiento del backend (.storage) de Thermostat Timeline y borra TODA la caché/borradores locales del navegador.\n• Borrar solo datos locales: Borra solo los datos guardados en este navegador (localStorage).\n\nConsejo: Después de un reset completo, recarga la página para evitar que reaparezcan borradores antiguos.', 'week.enable': 'Habilitar días de la semana', 'week.mode': 'Modo por días', 'week.mode.same_all': 'Igual todos los días (1)', 'week.mode.weekday_weekend': 'Laborables + Fin de semana', 'week.mode.weekday_sat_sun': 'Laborables + Sábado + Domingo', 'week.mode.all_7': 'Todos los días', 'week.button': 'Días de la semana', 'week.days.mon': 'Lun', 'week.days.tue': 'Mar', 'week.days.wed': 'Mié', 'week.days.thu': 'Jue', 'week.days.fri': 'Vie', 'week.days.sat': 'Sáb', 'week.days.sun': 'Dom', 'week.editor.title': 'Programación por días', 'week.editor.preview_note': 'Solo vista previa. Los cambios se aplican al pulsar Guardar.', 'week.open_editor.desc': 'Abrir el editor de días de la semana.', 'week.open_editor.btn': 'Abrir', 'week.copy_to_rooms': 'Copiar semana a habitaciones', 'week.copy_to_rooms.title': 'Copiar semana a habitaciones', 'week.copy_to_rooms.select': 'Seleccionar habitaciones', 'week.copy_to_rooms.apply': 'Insertar en habitaciones', 'week.group.weekdays': 'Laborables', 'week.group.weekend': 'Fin de semana', 'week.days_full.mon': 'Lunes', 'week.days_full.tue': 'Martes', 'week.days_full.wed': 'Miércoles', 'week.days_full.thu': 'Jueves', 'week.days_full.fri': 'Viernes', 'week.days_full.sat': 'Sábado', 'week.days_full.sun': 'Domingo', 'editor.timefmt.title': 'Formato de hora', 'editor.timefmt.desc': 'Elige visualización de 24 horas o 12 horas (AM/PM) para la tarjeta.', 'editor.tempfmt.title': 'Unidad de temperatura', 'editor.tempfmt.desc': 'Elige Celsius (°C) o Fahrenheit (°F) para mostrar en la tarjeta y editores.', 'editor.timesrc.title': 'Fuente de tiempo', 'editor.timesrc.desc': 'Elige si la tarjeta usa la hora del navegador o la zona horaria de Home Assistant para “ahora”.', 'editor.timesrc.browser': 'Navegador', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'ID de configuración', 'editor.instance.desc': 'Mantén varios horarios/ajustes independientes bajo IDs diferentes.', 'editor.instance.new_id': 'Nuevo ID', 'editor.heat_colors.title': 'Colores de bloques de calor', 'editor.heat_colors.add': 'Añadir intervalo de color', 'editor.colors.col_from': 'Desde °C', 'editor.colors.col_to': 'Hasta °C', 'editor.colors.col_color': 'Color', 'editor.colors.empty': 'Aún no se han añadido habitaciones. Añade una para definir colores.', 'editor.colors.go_add': 'Añadir habitación', 'editor.colors.refresh': 'Actualizar programación', 'editor.colors.clear_local': 'Borrar datos de color locales', 'editor.colors.clear_local_confirm': 'Esto eliminará los intervalos de color guardados en tu navegador para esta tarjeta. ¿Continuar?', 'editor.colors.clear_shared': 'Borrar almacenamiento de color compartido', 'editor.colors.clear_shared_confirm': 'Esto eliminará los intervalos de color en el sensor de almacenamiento seleccionado. ¿Continuar?', 'editor.colors.mode': 'Modo de color', 'editor.colors.mode.desc': 'Elige colores por habitación o un conjunto para todas.', 'editor.colors.mode_per_room': 'Por habitación', 'editor.colors.mode_global': 'Todas las habitaciones', 'editor.colors.global_title': 'Todas las habitaciones', 'editor.colors.clear_all': 'Borrar todos los datos de color', 'editor.colors.clear_all_confirm': 'Esto borrará todos los intervalos de color tanto locales como del sensor de almacenamiento seleccionado. ¿Continuar?', 'editor.reload_editor': 'Recargar editor', 'editor.reload_dashboard': 'Recargar panel', 'onboard.title': 'Bienvenido a la línea de tiempo del termostato', 'onboard.next': 'Siguiente', 'onboard.prev': 'Atrás', 'onboard.skip': 'Omitir', 'onboard.done': 'Listo', 'onboard.dont_show': 'No mostrar de nuevo', 'onboard.step_main': 'Esta es la línea principal. Muestra las temperaturas deseadas a lo largo del día por habitación.', 'onboard.step_add': 'Añade un bloque con + Agregar bloque o con doble clic en la línea de tiempo.', 'onboard.step_edit': 'Edita o elimina un bloque con doble clic. Usa días de la semana para distintos días.', 'onboard.reset': 'Mostrar la guía otra vez', 'onboard.reset_ok': 'La bienvenida se mostrará la próxima vez.', 'editor.select_entities': 'Habitaciones', 'editor.tabs.rooms': 'Habitaciones', 'editor.test_tool': 'Herramienta de prueba', 'editor.test_select_entity': 'Seleccionar entidad', 'editor.test_empty': 'Seleccione una entidad para ver los comandos disponibles…', 'editor.test_export': 'Exportar .md', 'editor.test_no_fields': 'Sin campos', 'editor.test_select_placeholder': '— Seleccionar —', 'editor.error_prefix': 'Error: ', 'editor.open_in_hacs': 'Abrir en HACS', 'week.enable.desc': 'Habilitar programación por día. Si está desactivado, se usa el mismo plan todos los días.', 'editor.tabs.sync': 'Motor de sincronización', 'sync.running': 'Sincronización en curso…', 'sync.completed': 'Sincronización completada', 'editor.tabs.backup': 'Copia de seguridad', 'backup.title': 'Copia de seguridad', 'backup.desc': 'Copia el contenido de tu sensor de sincronización al sensor de copia de seguridad y restaura cuando sea necesario.', 'backup.now': 'Hacer copia ahora', 'backup.restore': 'Restaurar', 'backup.delete': 'Eliminar copia de seguridad', 'backup.delete.confirm': '¿Eliminar la copia seleccionada?', 'backup.new': 'Nueva copia de seguridad', 'backup.auto': 'Copia de seguridad automática', 'backup.auto.desc': 'Realiza copias de seguridad automáticas a intervalos.', 'backup.interval': 'Intervalo (días)', 'backup.interval.desc': 'Cuántos días entre copias de seguridad automáticas (mín. 1).', 'backup.sensor': 'Sensor de copia de seguridad', 'backup.last': 'Última copia de seguridad', 'backup.export.title': 'Exportar archivo de copia', 'backup.export.desc': 'Descarga un thermostat_timeline_backup.json completo con todos los slots y datos.', 'backup.export.btn': 'Descargar archivo', 'backup.file.add_selected': 'Importar copia de seguridad', 'backup.file.add_all': 'Importar todas las copias de seguridad', 'backup.file.loaded_n': 'Archivo cargado ({n} copias)', 'backup.file.imported_ok': 'Importado ({n})', 'backup.slot.title': 'Seleccionar copia de seguridad', 'backup.slot.desc': 'Selecciona qué ranura de copia de seguridad restaurar.', 'backup.slot.empty': 'Aún no hay copias de seguridad', 'pause.menu_title': 'Pausar auto‑aplicación', 'pause.off_1h': 'Pausar 1 hora', 'pause.off_5h': 'Pausar 5 horas', 'pause.off_custom': 'Duración personalizada…', 'pause.off_indef': 'Pausar hasta reanudar', 'pause.resume': 'Reanudar ahora', 'pause.close': 'Cerrar', 'pause.active_until': 'Pausado hasta {time}', 'pause.active_indef': 'Pausado', 'editor.pausebtn.title': 'Botón de pausa', 'editor.pausebtn.desc': 'Muestra un botón de pausa global en la cabecera. Mientras está pausado, se suprimen todos los comandos set_temperature.', 'editor.integration.connected': 'Integración lista', 'editor.integration.disconnected': 'Integración no disponible' , 'profiles.button': 'Horario manual' , 'profiles.editor.title': 'Perfiles' , 'profiles.add': 'Agregar perfil' , 'profiles.rename': 'Renombrar' , 'profiles.delete': 'Eliminar' , 'profiles.activate': 'Activar' , 'profiles.deactivate': 'Desactivar' , 'profiles.active': 'Activo' , 'profiles.none': 'Ninguno' , 'profiles.name_placeholder': 'Nombre del perfil' , 'profiles.edit': 'Editar' , 'profiles.preview_note': 'Solo vista previa. Los horarios se activan al activar el perfil.' , 'editor.tabs.holidays': 'Festivos' , 'holidays.enable': 'Habilitar festivos' , 'holidays.desc': 'Usa un horario especial en días festivos.' , 'holidays.source': 'Fuente de festivos' , 'holidays.source.calendar': 'Calendario' , 'holidays.source.manual': 'Fechas manuales' , 'holidays.entity': 'Entidad de festivos' , 'holidays.entity_desc': 'Selecciona un calendario que solo informe on/off. Debe estar en on en festivos y en off el resto de días.' , 'holidays.dates': 'Fechas de festivos' , 'holidays.add_date': 'Agregar fecha' , 'holidays.editor.title': 'Horario de festivos' , 'holidays.edit': 'Editar horario de festivos' , 'holidays.button': 'Festivos' , 'editor.perroom.title': '°C predeterminado por habitación' , 'editor.perroom.desc': 'Muestra un campo de °C predeterminado en cada fila. Si está desactivado, se usa el °C predeterminado global para todas las habitaciones.' , 'away.resume_schedule': 'Reanudar horario' , 'away.resume_away': 'Reanudar temperatura de ausencia' , 'presence.button': 'Horarios de presencia' , 'presence.editor.title': 'Horarios de presencia' , 'presence.enable_advanced': 'Habilitar horarios avanzados de presencia' , 'presence.enable_advanced.desc': 'Usa combinaciones y horarios según quién esté en casa/ausente.' , 'away.delay_enable': 'Retrasar acciones de ausencia' , 'away.delay_desc': 'Espera antes de aplicar cambios de ausencia.' , 'away.delay_value': 'Retraso' , 'away.delay_unit': 'Unidad' , 'away.delay_unit.seconds': 'Segundos' , 'away.delay_unit.minutes': 'Minutos' , 'presence.combos': 'Combinaciones' , 'presence.enable_combo': 'Habilitar esta combinación' , 'presence.all_home': 'Todos en casa' , 'presence.none_home': 'Nadie en casa' , 'presence.home': 'En casa: {names}' , 'presence.away': 'Fuera: {names}' , 'presence.manage': 'Gestionar combinaciones' , 'presence.who_home': 'Quién está en casa' , 'presence.who_away': 'Quién no está en casa' , 'away.enable': 'Habilitar modo ausente' , 'away.desc': 'Cuando ninguna de las personas seleccionadas está en casa, los termostatos bajan automáticamente a la temperatura establecida.' , 'away.temp': 'Temperatura de ausencia' , 'away.persons': 'Personas' , 'away.add_person': 'Agregar persona' , 'backup.select': 'Elegir qué copiar en la copia de seguridad' , 'backup.select.desc': 'Los elementos marcados se incluyen en la copia/restauración' , 'backup.opt.all': 'Todo' , 'backup.opt.main': 'Horarios principales' , 'backup.opt.weekday': 'Horarios por día' , 'backup.opt.presence': 'Horarios de presencia' , 'backup.opt.settings': 'Ajustes del editor' , 'backup.opt.holiday': 'Horarios de festivos' , 'backup.opt.colors': 'Bloques de color' , 'editor.tabs.settings': 'Ajustes' , 'editor.tabs.pause': 'Pausa' , 'editor.tabs.weekdays': 'Días de la semana' , 'editor.tabs.colors': 'Colores' , 'editor.tabs.presence_sensor': 'Sensor de presencia' , 'editor.presence_sensor.enable.title': 'Sensor de presencia' , 'editor.presence_sensor.enable.desc': 'Habilita la configuración de sensor de presencia por habitación.' , 'editor.presence_sensor.entity.title': 'Entidad de presencia' , 'editor.presence_sensor.entity.desc': 'Selecciona el binary_sensor que indica presencia en esta habitación.' , 'editor.presence_sensor.on_temp.title': 'Temperatura cuando hay presencia (ENCENDIDO)' , 'editor.presence_sensor.on_temp.desc': 'Si el sensor está ENCENDIDO, esta habitación usa esta temperatura en lugar del horario.' , 'editor.presence_sensor.on_delay.title': 'Retraso antes de aplicar presencia' , 'editor.presence_sensor.on_delay.desc': 'Tiempo que el sensor debe permanecer ENCENDIDO antes de usar la temperatura de presencia. El valor usa la unidad seleccionada.' , 'editor.presence_sensor.off_delay.title': 'Retraso antes de reanudar el horario' , 'editor.presence_sensor.off_delay.desc': 'Tiempo que el sensor debe permanecer APAGADO antes de reanudar el horario. El valor usa la unidad seleccionada.' , 'editor.tabs.owd': 'Open Window Detection' , 'owd.enable': 'Open Window Detection' , 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.' , 'owd.sensors': 'Window/Door sensors' , 'owd.delays.title': 'Delays' , 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).' , 'owd.open_delay': 'After open (min)' , 'owd.close_delay': 'After closed (min)' , 'editor.tabs.away': 'Fuera de casa' , 'editor.tabs.reset': 'Restablecer' , 'editor.tabs.boiler': 'Control de caldera' , 'boiler.switch': 'Interruptor de caldera' , 'boiler.switch_type': 'Tipo de entidad' , 'boiler.switch_type.switch': 'Interruptor' , 'boiler.switch_type.input_boolean': 'Booleano de entrada' , 'boiler.rooms': 'Habitaciones incluidas' , 'boiler.rooms.desc': 'Selecciona qué habitaciones deben incluirse en el control de la caldera.' , 'boiler.multi_enable': 'Habilitar varias calderas' , 'boiler.multi_enable.desc': 'Asigna habitaciones a distintas calderas y define offsets por habitación.' , 'boiler.multi_rooms': 'Caldera por habitación' , 'boiler.multi_rooms.desc': 'Selecciona el interruptor de caldera y los offsets para cada habitación.' , 'boiler.room.enable': 'Incluir habitación' , 'boiler.room.enable.desc': 'Incluir esta habitación en el control de la caldera.' , 'boiler.enable': 'Activar control de caldera' , 'boiler.enable.desc': 'Mostrar ajustes de caldera en el editor de esta tarjeta.' , 'boiler.temp_sensor': 'Sensor de temperatura de caldera' , 'boiler.temp_sensor.desc': 'Sensor de temperatura usado para controlar el interruptor de la caldera.' , 'boiler.min_temp': 'Temp. mínima de caldera (°C)' , 'boiler.max_temp': 'Temp. máxima de caldera (°C)' , 'boiler.offsets': 'Offsets' , 'boiler.offsets.desc': 'Offset ON: cuánto por debajo del objetivo programado antes de encender. Offset OFF: cuánto por encima del objetivo programado antes de apagar.' , 'boiler.on_offset': 'Offset ON (°C)' , 'boiler.off_offset': 'Offset OFF (°C)' , 'editor.temp_sensor': 'Sensor de temperatura' , 'presence.live_header': 'Mostrar presencia en vivo en la cabecera' , 'presence.live_header.desc': 'Las fichas se muestran en la esquina derecha.' , 'profiles.enable': 'Habilitar perfiles (sobrescribir)' , 'profiles.enable.desc': 'Permitir horarios diarios con nombre que puedas activar para sobrescribir el plan principal.' , 'season.enable.title': 'Habilitar horarios de verano/invierno' , 'season.enable.desc': 'Cuando está habilitado, cada horario tiene versiones de Verano e Invierno que puedes alternar.' , 'season.mode.title': 'Temporada' , 'season.mode.winter': 'Invierno' , 'season.mode.summer': 'Verano' , 'profiles.active_label': 'Perfil manual activo:' , 'profiles.save_new': 'Guardar perfil nuevo' , 'profiles.save_existing': 'Guardar cambios en el perfil seleccionado' , 'week.view.title': 'Vista semanal' , 'week.view.rooms_one_day': 'Todas las habitaciones • un día' , 'week.view.days_one_room': 'Todos los días • una habitación' , 'week.view_switch.title': 'Cambiar vista desde la línea de tiempo' , 'week.view_switch.desc': 'Muestra un selector en el encabezado de la línea de tiempo para alternar entre los dos modos de vista de los días de la semana.' , 'week.copy_from_main.title': 'Copiar plan principal' , 'week.copy_from_main.desc': 'Activaste los días de la semana. ¿Quieres copiar el plan principal a los días de la semana? Elige el modo para copiar.' , 'week.copy_from_main.mode_label': 'Modo de días de la semana' , 'week.copy_from_main.target_label': 'Copiar a' , 'week.copy_from_main.copy_btn': 'Copiar' , 'week.copy_from_main.skip_btn': 'No copiar' , 'week.title': 'Días de la semana' , 'week.select_room': 'Seleccionar habitación' , 'err.config_entities': "La configuración debe tener 'entities: [climate.xxx, ...]'." , 'onboard.video_not_found': 'Video no encontrado' , 'onboard.image_not_found': 'Imagen no encontrada' , 'pause.prompt_minutes': 'Introduce la duración (minutos):' , 'editor.test_tool': 'Herramienta de prueba' , 'editor.test_select_entity': 'Selecciona una entidad' , 'editor.test_empty': 'Nada que exportar' , 'editor.test_export': 'Exportar' , 'editor.test_no_fields': 'No hay campos para mostrar' , 'editor.test_select_placeholder': 'Seleccionar...' , 'editor.error_prefix': 'Error: ' , 'backup.restore.confirm_overwrite': '¿Restaurar desde una copia de seguridad? Esto sobrescribirá los horarios y ajustes actuales.' , 'backup.import.err_no_hass': 'Home Assistant no está listo' , 'backup.import.err_storage_off': 'El almacenamiento compartido está DESACTIVADO. Actívalo primero.' , 'backup.import.err_no_integration': 'La integración thermostat_timeline no está disponible' , 'backup.import.err_json': 'JSON no válido' , 'profiles.name_exists': 'El nombre ya existe' }, fr: { 'card.title_default': 'Chronologie du thermostat', 'ui.edit_block': 'Modifier le bloc', 'ui.add_block': 'Ajouter un bloc', 'ui.from': 'De', 'ui.to': 'À', 'ui.temperature': 'Température', 'ui.delete': 'Supprimer', 'ui.cancel': 'Annuler', 'ui.close': 'Fermer', 'ui.unsaved_confirm': 'Des modifications non enregistrées existent. Fermer quand même ?', 'ui.discard_changes': 'Ignorer les modifications', 'ui.keep_editing': 'Continuer l’édition', 'ui.save': 'Enregistrer', 'ui.error_prefix': 'Erreur : ', 'ui.overlap_msg': 'Vous avez sélectionné des heures qui se chevauchent — chevauchement entre {start} – {end}.', 'ui.auto_fix': 'Corriger automatiquement', 'ui.fix_start_to': 'Ajuster le début à {time}', 'ui.fix_end_to': 'Ajuster la fin à {time}', // Inline meta 'ui.current_temp': 'Température actuelle :', 'editor.title_label': 'Titre', 'editor.add_entity': 'Ajouter une pièce', 'editor.entity_placeholder': 'Sélectionnez une entité', 'editor.room_mode.title': 'Type d’entité de pièce', 'editor.room_mode.desc': 'Activé : sélectionnez un input_number (température cible). Désactivé : sélectionnez une entité climate.', 'editor.room_temp_sensor_override.title': 'Utiliser un capteur de température de la pièce', 'editor.room_temp_sensor_override.desc': 'Lorsque c’est activé, la température de la pièce affichée (et la logique de la chaudière) utilise le capteur de température sélectionné au lieu de climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Capteur de température', 'editor.drag_reorder': 'Glisser pour réorganiser', 'editor.remove': 'Supprimer', 'editor.default_c': 'Par défaut °C', 'editor.row_height_px': 'Hauteur de ligne (px)', 'editor.max_c': 'Max °C', 'editor.min_c': 'Min °C', 'editor.auto_apply.title': 'Application automatique', 'editor.auto_apply.desc': 'Lorsqu’il est activé, la carte essaie de régler la température des entités climate sélectionnées pour qu’elle corresponde à la valeur prévue pour « maintenant »', 'editor.apply_edit.title': 'Lors de l’édition', 'editor.apply_edit.desc': 'Quand vous modifiez des blocs de la chronologie et que la modification affecte l’heure actuelle, la nouvelle température est appliquée immédiatement.', 'editor.apply_default.title': 'Lors d’un changement de °C par défaut', 'editor.apply_default.desc': 'Lorsque « °C par défaut » change et que cela affecte la période en cours, la nouvelle température est appliquée immédiatement.', 'editor.show_room_temp.title': 'Afficher la température de la pièce', 'editor.show_room_temp.desc': 'Affiche la bulle de température actuelle de la pièce sur chaque ligne dans l’en-tête de la frise.', 'ui.copy_plan': 'Copier le plan', 'ui.paste_plan': 'Coller le plan', 'ui.copy_day': 'Copier le jour', 'ui.paste_day': 'Coller le jour', 'editor.merge_label': 'Fusionner avec (ajouter un thermostat supplémentaire)', 'editor.display_name_optional': 'Nom de la pièce (facultatif)', 'editor.merged_with': 'Fusionné avec', 'editor.store_enable.title': 'Stockage partagé + contrôle en arrière‑plan', 'editor.store_enable.short': 'Enregistre les plannings dans le capteur sélectionné et active le contrôle en arrière‑plan.', 'editor.store_enable.desc': 'Enregistre les plannings dans le capteur sélectionné et active set_temperature en arrière‑plan. Les thermostats se mettent à jour même lorsque la carte est fermée. Partage également les plannings (blocs de chauffe) et les paramètres de la carte entre utilisateurs/appareils. Désactivé : commandes envoyées uniquement lorsque la carte est visible sur un appareil (tablette/PC/mobile) et elle doit rester ouverte.', 'editor.store_missing': 'L’intégration « Thermostat Pro Timeline Sync » n’est pas installée. Le stockage partagé n’est pas disponible.', 'editor.clear_store': 'Vider le stockage', 'editor.clear_store_confirm': 'Cela supprimera tous les plannings enregistrés. Continuer ?', 'editor.migrate_to_store': 'Transférer les données vers le stockage', 'editor.migrate_confirm': 'Transférer vos plannings enregistrés dans le navigateur vers le capteur sélectionné ? Cela remplacera les plannings existants dans le stockage.', 'editor.migrate_pull_confirm': 'Importer les plannings depuis le stockage et remplacer les données locales du navigateur ?', 'editor.select_storage_first': 'Veuillez d’abord sélectionner un capteur de stockage.', 'editor.no_local_data': 'Aucune donnée locale du navigateur trouvée.', 'editor.clear_all': 'Effacer toutes les données', 'editor.clear_all_confirm': 'Cela supprimera les plannings du stockage et du navigateur local. Continuer ?', 'editor.clear_storage_only': 'Effacer uniquement les données du stockage', 'editor.clear_storage_only_confirm': 'Cela supprimera les plannings du capteur de stockage sélectionné. Continuer ?', 'editor.clear_local_only': 'Effacer uniquement les données locales', 'editor.clear_local_only_confirm': 'Cela supprimera uniquement les plannings enregistrés dans votre navigateur. Continuer ?', 'editor.reset.desc': 'Ce que cela fait :\n\n• Effacer toutes les données : Réinitialise le stockage backend (.storage) de Thermostat Timeline et supprime TOUS les caches/brouillons locaux du navigateur.\n• Effacer uniquement les données locales : Supprime uniquement les données enregistrées dans ce navigateur (localStorage).\n\nAstuce : Après une réinitialisation complète, rechargez la page pour éviter que d’anciens brouillons réapparaissent.', 'week.enable': 'Activer les jours de semaine', 'week.mode': 'Mode jours de semaine', 'week.mode.same_all': 'Identique chaque jour (1)', 'week.mode.weekday_weekend': 'Jours ouvrés + Week-end', 'week.mode.weekday_sat_sun': 'Jours ouvrés + Samedi + Dimanche', 'week.mode.all_7': 'Tous les jours', 'week.button': 'Jours de semaine', 'week.days.mon': 'Lun', 'week.days.tue': 'Mar', 'week.days.wed': 'Mer', 'week.days.thu': 'Jeu', 'week.days.fri': 'Ven', 'week.days.sat': 'Sam', 'week.days.sun': 'Dim', 'week.editor.title': 'Planification par jour', 'week.editor.preview_note': 'Aperçu uniquement. Les modifications s’appliquent lors de l’enregistrement.', 'week.open_editor.desc': 'Ouvrir l’éditeur des jours.', 'week.open_editor.btn': 'Ouvrir', 'week.copy_to_rooms': 'Copier la semaine vers des pièces', 'week.copy_to_rooms.title': 'Copier la semaine vers des pièces', 'week.copy_to_rooms.select': 'Sélectionner des pièces', 'week.copy_to_rooms.apply': 'Insérer dans les pièces', 'week.group.weekdays': 'Jours ouvrés', 'week.group.weekend': 'Week-end', 'week.days_full.mon': 'Lundi', 'week.days_full.tue': 'Mardi', 'week.days_full.wed': 'Mercredi', 'week.days_full.thu': 'Jeudi', 'week.days_full.fri': 'Vendredi', 'week.days_full.sat': 'Samedi', 'week.days_full.sun': 'Dimanche', 'editor.timefmt.title': 'Format de l’heure', 'editor.timefmt.desc': 'Choisir affichage 24 h ou 12 h (AM/PM) pour les heures sur la carte.', 'editor.tempfmt.title': 'Unité de température', 'editor.tempfmt.desc': 'Choisir Celsius (°C) ou Fahrenheit (°F) pour l’affichage dans la carte et les éditeurs.', 'editor.timesrc.title': 'Source de l’heure', 'editor.timesrc.desc': 'Choisir si la carte utilise l’heure du navigateur ou le fuseau horaire de Home Assistant pour « maintenant ».', 'editor.timesrc.browser': 'Navigateur', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'ID de configuration', 'editor.instance.desc': 'Conservez plusieurs plannings/paramètres indépendants sous différents identifiants.', 'editor.instance.new_id': 'Nouvel ID', 'editor.heat_colors.title': 'Couleurs des blocs de chauffe', 'editor.heat_colors.add': 'Ajouter un intervalle de couleur', 'editor.colors.col_from': 'De °C', 'editor.colors.col_to': 'À °C', 'editor.colors.col_color': 'Couleur', 'editor.colors.empty': 'Aucune pièce ajoutée. Ajoutez une pièce pour définir des couleurs.', 'editor.colors.go_add': 'Ajouter une pièce', 'editor.colors.refresh': 'Mettre à jour le planning', 'editor.colors.clear_local': 'Effacer les données de couleur locales', 'editor.colors.clear_local_confirm': 'Cela supprimera les intervalles de couleur enregistrés dans votre navigateur pour cette carte. Continuer ?', 'editor.colors.clear_shared': 'Effacer le stockage de couleur partagé', 'editor.colors.clear_shared_confirm': 'Cela supprimera les intervalles de couleur dans le capteur de stockage sélectionné. Continuer ?', 'editor.colors.mode': 'Mode de couleur', 'editor.colors.mode.desc': 'Choisir des couleurs par pièce ou un jeu pour toutes les pièces.', 'editor.colors.mode_per_room': 'Par pièce', 'editor.colors.mode_global': 'Toutes les pièces', 'editor.colors.global_title': 'Toutes les pièces', 'editor.colors.clear_all': 'Effacer toutes les données de couleur', 'editor.colors.clear_all_confirm': 'Ceci supprimera tous les intervalles de couleur à la fois localement et sur le capteur de stockage sélectionné. Continuer ?', 'editor.reload_editor': 'Recharger l’éditeur', 'editor.reload_dashboard': 'Recharger le tableau de bord', 'onboard.title': 'Bienvenue dans la Chronologie du thermostat', 'onboard.next': 'Suivant', 'onboard.prev': 'Précédent', 'onboard.skip': 'Ignorer', 'onboard.done': 'Terminer', 'onboard.dont_show': 'Ne plus afficher', 'onboard.step_main': 'Voici la chronologie principale. Elle affiche les températures souhaitées sur 24 h pour chaque pièce.', 'onboard.step_add': 'Ajoutez un bloc via + Ajouter un bloc ou double‑cliquez sur la chronologie.', 'onboard.step_edit': 'Modifiez/Supprimez un bloc par double‑clic. Utilisez les jours de semaine pour des jours différents.', 'onboard.reset': 'Afficher le guide à nouveau', 'onboard.reset_ok': 'La présentation s’affichera la prochaine fois.', 'editor.select_entities': 'Pièces', 'editor.tabs.rooms': 'Pièces', 'week.enable.desc': 'Activer la planification par jour de semaine. Désactivé : même plan chaque jour.', 'editor.tabs.sync': 'Moteur de synchro', 'sync.running': 'Synchronisation en cours…', 'sync.completed': 'Synchronisation terminée', 'editor.tabs.backup': 'Sauvegarde', 'backup.title': 'Sauvegarde', 'backup.desc': 'Copie le contenu de votre capteur de synchro vers le capteur de sauvegarde et restaure si besoin.', 'backup.now': 'Sauvegarder maintenant', 'backup.restore': 'Restaurer', 'backup.delete': 'Supprimer la sauvegarde', 'backup.delete.confirm': 'Supprimer la sauvegarde sélectionnée ?', 'backup.new': 'Nouvelle sauvegarde', 'backup.auto': 'Sauvegarde automatique', 'backup.auto.desc': 'Effectuer des sauvegardes automatiques à intervalle régulier.', 'backup.interval': 'Intervalle (jours)', 'backup.interval.desc': 'Nombre de jours entre les sauvegardes automatiques (min. 1).', 'backup.sensor': 'Capteur de sauvegarde', 'backup.last': 'Dernière sauvegarde', 'backup.export.title': 'Exporter le fichier de sauvegarde', 'backup.export.desc': 'Télécharger un thermostat_timeline_backup.json complet avec tous les emplacements et données.', 'backup.export.btn': 'Télécharger le fichier', 'backup.file.add_selected': 'Importer la sauvegarde', 'backup.file.add_all': 'Importer toutes les sauvegardes', 'backup.file.loaded_n': 'Fichier chargé ({n} sauvegardes)', 'backup.file.imported_ok': 'Importé ({n})', 'backup.slot.title': 'Sélectionner une sauvegarde', 'backup.slot.desc': 'Sélectionnez l’emplacement de sauvegarde à restaurer.', 'backup.slot.empty': 'Aucune sauvegarde pour l’instant', 'pause.menu_title': 'Suspendre l’application automatique', 'pause.off_1h': 'Suspendre 1 heure', 'pause.off_5h': 'Suspendre 5 heures', 'pause.off_custom': 'Durée personnalisée…', 'pause.off_indef': 'Suspendre jusqu’à reprise', 'pause.resume': 'Reprendre maintenant', 'pause.close': 'Fermer', 'pause.active_until': 'Suspendu jusqu’à {time}', 'pause.active_indef': 'Suspendu', 'editor.pausebtn.title': 'Bouton Pause', 'editor.pausebtn.desc': 'Affiche un bouton de pause global dans l’en‑tête de la carte. En pause, toutes les commandes set_temperature sont bloquées.', 'editor.integration.connected': 'Intégration prête', 'editor.integration.disconnected': 'Intégration indisponible' , 'editor.open_in_hacs': 'Ouvrir dans HACS' , 'profiles.button': 'Programme manuel' , 'profiles.editor.title': 'Profils' , 'profiles.add': 'Ajouter un profil' , 'profiles.rename': 'Renommer' , 'profiles.delete': 'Supprimer' , 'profiles.activate': 'Activer' , 'profiles.deactivate': 'Désactiver' , 'profiles.active': 'Actif' , 'profiles.none': 'Aucun' , 'profiles.name_placeholder': 'Nom du profil' , 'profiles.edit': 'Modifier' , 'profiles.preview_note': 'Aperçu uniquement. Les plannings deviennent actifs lorsque vous activez le profil.' , 'editor.tabs.holidays': 'Jours fériés' , 'holidays.enable': 'Activer les jours fériés' , 'holidays.desc': 'Utiliser un planning spécifique les jours fériés.' , 'holidays.source': 'Source des jours fériés' , 'holidays.source.calendar': 'Calendrier' , 'holidays.source.manual': 'Dates manuelles' , 'holidays.entity': 'Entité jours fériés' , 'holidays.entity_desc': 'Sélectionnez un calendrier ne fournissant que on/off. Il doit être « on » les jours fériés et « off » les autres jours.' , 'holidays.dates': 'Dates des jours fériés' , 'holidays.add_date': 'Ajouter une date' , 'holidays.editor.title': 'Planning jours fériés' , 'holidays.edit': 'Modifier le planning jours fériés' , 'holidays.button': 'Jours fériés' , 'editor.perroom.title': '°C par défaut par pièce' , 'editor.perroom.desc': 'Affiche un champ °C par défaut sur chaque ligne. Désactivé : utilise le °C par défaut global pour toutes les pièces.' , 'away.resume_schedule': 'Reprendre le programme' , 'away.resume_away': 'Reprendre la température d’absence' , 'presence.button': 'Programmes de présence' , 'presence.editor.title': 'Programmes de présence' , 'presence.enable_advanced': 'Activer les plannings de présence avancés' , 'presence.enable_advanced.desc': 'Utilisez des combinaisons et des plannings selon qui est présent/absent.' , 'away.delay_enable': 'Retarder les actions d’absence' , 'away.delay_desc': 'Attendre avant d’appliquer les changements d’absence.' , 'away.delay_value': 'Délai' , 'away.delay_unit': 'Unité' , 'away.delay_unit.seconds': 'Secondes' , 'away.delay_unit.minutes': 'Minutes' , 'presence.combos': 'Combinaisons' , 'presence.enable_combo': 'Activer cette combinaison' , 'presence.all_home': 'Tous à la maison' , 'presence.none_home': 'Personne à la maison' , 'presence.home': 'À la maison : {names}' , 'presence.away': 'Absents : {names}' , 'presence.manage': 'Gérer les combinaisons' , 'presence.who_home': 'Qui est à la maison' , 'presence.who_away': 'Qui n’est pas à la maison' , 'away.enable': 'Activer le mode absence' , 'away.desc': 'Lorsque aucune des personnes sélectionnées n’est à la maison, les thermostats sont automatiquement abaissés à la température définie.' , 'away.temp': 'Température d’absence' , 'away.persons': 'Personnes' , 'away.add_person': 'Ajouter une personne' , 'backup.select': 'Choisir quoi sauvegarder' , 'backup.select.desc': 'Les éléments cochés sont inclus dans la sauvegarde/restauration' , 'backup.opt.all': 'Tout' , 'backup.opt.main': 'Plannings principaux' , 'backup.opt.weekday': 'Plannings par jour' , 'backup.opt.presence': 'Plannings de présence' , 'backup.opt.settings': 'Paramètres de l’éditeur' , 'backup.opt.holiday': 'Plannings jours fériés' , 'backup.opt.colors': 'Blocs de couleur' , 'editor.tabs.settings': 'Paramètres' , 'editor.tabs.pause': 'Pause' , 'editor.tabs.weekdays': 'Jours de semaine' , 'editor.tabs.colors': 'Couleurs' , 'editor.tabs.presence_sensor': 'Capteur de présence' , 'editor.presence_sensor.enable.title': 'Capteur de présence' , 'editor.presence_sensor.enable.desc': 'Activer la configuration des capteurs de présence par pièce.' , 'editor.presence_sensor.entity.title': 'Entité de présence' , 'editor.presence_sensor.entity.desc': 'Sélectionnez le binary_sensor qui indique la présence dans cette pièce.' , 'editor.presence_sensor.on_temp.title': 'Température quand présence est activée' , 'editor.presence_sensor.on_temp.desc': 'Si le capteur est activé, cette pièce utilise cette température au lieu du planning.' , 'editor.presence_sensor.on_delay.title': 'Délai avant d’appliquer la présence' , 'editor.presence_sensor.on_delay.desc': 'Durée pendant laquelle le capteur doit rester activé avant d’utiliser la température de présence. La valeur utilise l’unité sélectionnée.' , 'editor.presence_sensor.off_delay.title': 'Délai avant de reprendre le planning' , 'editor.presence_sensor.off_delay.desc': 'Durée pendant laquelle le capteur doit rester désactivé avant de reprendre le planning. La valeur utilise l’unité sélectionnée.' , 'editor.tabs.owd': 'Open Window Detection' , 'owd.enable': 'Open Window Detection' , 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.' , 'owd.sensors': 'Window/Door sensors' , 'owd.delays.title': 'Delays' , 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).' , 'owd.open_delay': 'After open (min)' , 'owd.close_delay': 'After closed (min)' , 'editor.tabs.away': 'Absence du domicile' , 'editor.tabs.reset': 'Réinitialiser' , 'editor.tabs.boiler': 'Contrôle chaudière' , 'boiler.switch': 'Interrupteur de chaudière' , 'boiler.switch_type': "Type d’entité" , 'boiler.switch_type.switch': 'Interrupteur' , 'boiler.switch_type.input_boolean': "Booléen d’entrée" , 'boiler.rooms': 'Pièces incluses' , 'boiler.rooms.desc': 'Sélectionnez les pièces à inclure dans le contrôle de la chaudière.' , 'boiler.multi_enable': 'Activer plusieurs chaudières' , 'boiler.multi_enable.desc': 'Assignez des pièces à différentes chaudières et définissez des offsets par pièce.' , 'boiler.multi_rooms': 'Chaudière par pièce' , 'boiler.multi_rooms.desc': 'Sélectionnez l’interrupteur de chaudière et les offsets pour chaque pièce.' , 'boiler.room.enable': 'Inclure la pièce' , 'boiler.room.enable.desc': 'Inclure cette pièce dans le contrôle de la chaudière.' , 'boiler.enable': 'Activer le contrôle chaudière' , 'boiler.enable.desc': 'Afficher les réglages de chaudière dans l’éditeur de cette carte.' , 'boiler.temp_sensor': 'Capteur de température chaudière' , 'boiler.temp_sensor.desc': 'Capteur de température utilisé pour piloter l’interrupteur de la chaudière.' , 'boiler.min_temp': 'Temp. min. chaudière (°C)' , 'boiler.max_temp': 'Temp. max. chaudière (°C)' , 'boiler.offsets': 'Décalages' , 'boiler.offsets.desc': 'Décalage ON : à combien en dessous de la consigne planifiée avant d’allumer. Décalage OFF : à combien au‑dessus de la consigne planifiée avant d’éteindre.' , 'boiler.on_offset': 'Décalage ON (°C)' , 'boiler.off_offset': 'Décalage OFF (°C)' , 'editor.temp_sensor': 'Capteur de température' , 'presence.live_header': 'Afficher la présence en direct dans l’en‑tête' , 'presence.live_header.desc': 'Les puces sont affichées dans le coin droit.' , 'profiles.enable': 'Activer les profils (remplacement)' , 'profiles.enable.desc': 'Autoriser des plannings journaliers nommés pouvant remplacer le plan principal lorsque vous les activez.' , 'season.enable.title': 'Activer les plannings été/hiver' , 'season.enable.desc': 'Lorsque activé, chaque planning possède une version Été et Hiver que vous pouvez alterner.' , 'season.mode.title': 'Saison' , 'season.mode.winter': 'Hiver' , 'season.mode.summer': 'Été' , 'profiles.active_label': 'Profil manuel actif :' , 'profiles.save_new': 'Enregistrer un nouveau profil' , 'profiles.save_existing': 'Enregistrer les modifications du profil sélectionné' , 'week.view.title': 'Vue semaine' , 'week.view.rooms_one_day': 'Toutes les pièces • un jour' , 'week.view.days_one_room': 'Tous les jours • une pièce' , 'week.view_switch.title': 'Changer la vue depuis la frise' , 'week.view_switch.desc': 'Affiche un sélecteur dans l’en-tête de la frise pour basculer entre les deux modes de vue des jours de semaine.' , 'week.copy_from_main.title': 'Copier le planning principal' , 'week.copy_from_main.desc': 'Vous avez activé les jours de semaine. Voulez-vous copier le planning principal vers les jours de semaine ? Choisissez le mode à copier.' , 'week.copy_from_main.mode_label': 'Mode jours de semaine' , 'week.copy_from_main.target_label': 'Copier vers' , 'week.copy_from_main.copy_btn': 'Copier' , 'week.copy_from_main.skip_btn': 'Ne pas copier' , 'week.title': 'Jours de semaine' , 'week.select_room': 'Sélectionner une pièce' , 'err.config_entities': "La configuration doit contenir 'entities: [climate.xxx, ...]'." , 'onboard.video_not_found': 'Vidéo introuvable' , 'onboard.image_not_found': 'Image introuvable' , 'pause.prompt_minutes': 'Saisir la durée (minutes) :' , 'editor.test_tool': 'Outil de test' , 'editor.test_select_entity': 'Sélectionnez une entité' , 'editor.test_empty': 'Rien à exporter' , 'editor.test_export': 'Exporter' , 'editor.test_no_fields': 'Aucun champ à afficher' , 'editor.test_select_placeholder': 'Sélectionner...' , 'editor.error_prefix': 'Erreur : ' , 'backup.restore.confirm_overwrite': 'Restaurer depuis une sauvegarde ? Cela écrasera les plannings et paramètres actuels.' , 'backup.import.err_no_hass': "Home Assistant n'est pas prêt" , 'backup.import.err_storage_off': 'Le stockage partagé est DÉSACTIVÉ. Activez-le d’abord.' , 'backup.import.err_no_integration': "L'intégration thermostat_timeline n'est pas disponible" , 'backup.import.err_json': 'JSON invalide' , 'profiles.name_exists': 'Le nom existe déjà' }, it: { 'card.title_default': 'Cronologia del termostato', 'ui.edit_block': 'Modifica blocco', 'ui.add_block': 'Aggiungi blocco', 'ui.from': 'Da', 'ui.to': 'A', 'ui.temperature': 'Temperatura', 'ui.delete': 'Elimina', 'ui.cancel': 'Annulla', 'ui.close': 'Chiudi', 'ui.unsaved_confirm': 'Hai modifiche non salvate. Chiudere comunque?', 'ui.discard_changes': 'Scarta modifiche', 'ui.keep_editing': 'Continua a modificare', 'ui.save': 'Salva', 'ui.error_prefix': 'Errore: ', 'ui.overlap_msg': 'Hai selezionato orari sovrapposti — si sovrappongono tra {start} e {end}.', 'ui.auto_fix': 'Correzione automatica', 'ui.fix_start_to': 'Allinea inizio a {time}', 'ui.fix_end_to': 'Allinea fine a {time}', // Inline meta 'ui.current_temp': 'Temperatura attuale:', 'editor.title_label': 'Titolo', 'editor.add_entity': 'Aggiungi stanza', 'editor.entity_placeholder': 'Seleziona un\'entità', 'editor.room_mode.title': 'Tipo di entità stanza', 'editor.room_mode.desc': 'Quando è attivo, seleziona un input_number (temperatura obiettivo). Quando è disattivo, seleziona un\'entità climate.', 'editor.room_temp_sensor_override.title': 'Usa sensore temperatura stanza', 'editor.room_temp_sensor_override.desc': 'Quando abilitato, la temperatura stanza mostrata (e la logica della caldaia) usa il sensore di temperatura selezionato invece di climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Sensore di temperatura', 'editor.drag_reorder': 'Trascina per riordinare', 'editor.remove': 'Rimuovi', 'editor.default_c': '°C predefinita', 'editor.perroom.title': '°C predefinita per stanza', 'editor.perroom.desc': 'Mostra un campo °C predefinita su ogni riga. Se disattivato, viene usata la °C predefinita globale per tutte le stanze.', 'editor.row_height_px': 'Altezza riga (px)', 'editor.max_c': '°C max', 'editor.min_c': '°C min', 'editor.auto_apply.title': 'Applicazione automatica', 'editor.auto_apply.desc': 'Quando è attivo, la scheda prova a impostare la temperatura sulle entità climate selezionate così da corrispondere al valore pianificato per "adesso".', 'editor.apply_edit.title': 'Durante la modifica', 'editor.apply_edit.desc': 'Quando modifichi i blocchi della timeline e la modifica influisce sull\'ora corrente, la nuova temperatura viene applicata immediatamente.', 'editor.apply_default.title': 'Al cambio di °C predefinita', 'editor.apply_default.desc': 'Quando la "°C predefinita" cambia e interessa il periodo corrente, la nuova temperatura viene applicata subito.', 'editor.show_room_temp.title': 'Mostra temperatura stanza', 'editor.show_room_temp.desc': 'Mostra la bolla della temperatura attuale della stanza su ogni riga nell’intestazione della timeline.', 'ui.copy_plan': 'Copia pianificazione', 'ui.paste_plan': 'Incolla pianificazione', 'ui.copy_day': 'Copia giorno', 'ui.paste_day': 'Incolla giorno', 'editor.merge_label': 'Unisci con (aggiungi un altro termostato)', 'editor.display_name_optional': 'Nome stanza (facoltativo)', 'editor.merged_with': 'Unito con', 'editor.store_enable.title': 'Archiviazione condivisa + controllo in background', 'editor.store_enable.short': 'Salva i programmi nel sensore selezionato e abilita il controllo in background.', 'editor.store_enable.desc': 'Salva i programmi nel sensore selezionato e abilita i comandi set_temperature in background tramite l\'integrazione Thermostat Timeline. I termostati si aggiornano anche quando la scheda è chiusa. Condivide anche programmi (blocchi di calore) e impostazioni tra utenti/dispositivi. Quando è disattivo, i comandi vengono inviati solo mentre la scheda è visibile su un dispositivo (tablet/PC/mobile) e deve restare aperta.', 'editor.store_missing': 'L\'integrazione "Thermostat Pro Timeline Sync" non è installata. L\'archiviazione condivisa non è disponibile.', 'editor.clear_store': 'Svuota archiviazione', 'editor.clear_store_confirm': 'Questo eliminerà tutti i programmi salvati. Continuare?', 'editor.migrate_to_store': 'Trasferisci dati nell\'archiviazione', 'editor.migrate_confirm': 'Trasferire i programmi salvati nel browser al sensore selezionato? Eventuali programmi esistenti verranno sovrascritti.', 'editor.migrate_pull_confirm': 'Importare i programmi dall\'archiviazione e sovrascrivere i dati locali del browser?', 'editor.select_storage_first': 'Seleziona prima un sensore di archiviazione.', 'editor.no_local_data': 'Nessun dato locale del browser trovato.', 'editor.clear_all': 'Cancella tutti i dati', 'editor.clear_all_confirm': 'Questo eliminerà sia l\'archiviazione sia i programmi salvati nel browser locale. Continuare?', 'editor.clear_storage_only': 'Cancella solo i dati dell\'archiviazione', 'editor.clear_storage_only_confirm': 'Questo eliminerà i programmi dal sensore di archiviazione selezionato. Continuare?', 'editor.clear_local_only': 'Cancella solo i dati locali', 'editor.clear_local_only_confirm': 'Questo eliminerà solo i programmi salvati nel tuo browser. Continuare?', 'editor.reset.desc': 'Cosa fa:\n\n• Cancella tutti i dati: Reimposta lo storage backend (.storage) di Thermostat Timeline ed elimina TUTTE le cache/bozze locali del browser.\n• Cancella solo i dati locali: Elimina solo i dati salvati in questo browser (localStorage).\n\nSuggerimento: Dopo un reset completo, ricarica la pagina per evitare che vecchie bozze ricompaiano.', 'editor.temp_sensor': 'Sensore di temperatura', 'editor.test_tool': 'Strumento di test', 'editor.test_select_entity': 'Seleziona entità', 'editor.test_empty': 'Seleziona un\'entità per vedere i comandi disponibili…', 'editor.test_export': 'Esporta .md', 'editor.test_no_fields': 'Nessun campo', 'editor.test_select_placeholder': '— Seleziona —', 'editor.error_prefix': 'Errore: ', 'editor.open_in_hacs': 'Apri in HACS' , 'week.enable': 'Abilita giorni della settimana' , 'week.mode': 'Modalità giorni' , 'week.mode.weekday_weekend': 'Feriali + Weekend' , 'week.mode.weekday_sat_sun': 'Feriali + Sabato + Domenica' , 'week.mode.all_7': 'Tutti i giorni' , 'week.button': 'Giorni feriali' , 'week.group.weekdays': 'Feriali' , 'week.group.weekend': 'Weekend' , 'week.days_full.mon': 'Lunedì' , 'week.days_full.tue': 'Martedì' , 'week.days_full.wed': 'Mercoledì' , 'week.days_full.thu': 'Giovedì' , 'week.days_full.fri': 'Venerdì' , 'week.days_full.sat': 'Sabato' , 'week.days_full.sun': 'Domenica' , 'week.days.mon': 'Lun' , 'week.days.tue': 'Mar' , 'week.days.wed': 'Mer' , 'week.days.thu': 'Gio' , 'week.days.fri': 'Ven' , 'week.days.sat': 'Sab' , 'week.days.sun': 'Dom' , 'week.editor.title': 'Programma settimanale' , 'week.editor.preview_note': 'Solo anteprima. Le modifiche si applicano quando premi Salva.' , 'week.open_editor.desc': 'Apri l’editor dei giorni della settimana.' , 'week.open_editor.btn': 'Apri' , 'week.view.title': 'Vista settimanale' , 'week.view.rooms_one_day': 'Tutte le stanze • un giorno' , 'week.view.days_one_room': 'Tutti i giorni • una stanza' , 'week.view_switch.title': 'Cambia vista dalla timeline' , 'week.view_switch.desc': 'Mostra un selettore nell’intestazione della timeline per passare tra le due modalità di visualizzazione dei giorni feriali.' , 'week.copy_from_main.title': 'Copia programma principale' , 'week.copy_from_main.desc': 'Hai attivato i giorni feriali. Vuoi copiare il programma principale nei giorni feriali? Scegli la modalità da copiare.' , 'week.copy_from_main.mode_label': 'Modalità giorni feriali' , 'week.copy_from_main.target_label': 'Copia in' , 'week.copy_from_main.copy_btn': 'Copia' , 'week.copy_from_main.skip_btn': 'Non copiare' , 'week.title': 'Giorni feriali' , 'week.select_room': 'Seleziona stanza' , 'week.copy_to_rooms': 'Copia settimana nelle stanze' , 'week.copy_to_rooms.title': 'Copia settimana nelle stanze' , 'week.copy_to_rooms.select': 'Seleziona stanze' , 'week.copy_to_rooms.apply': 'Inserisci nelle stanze' , 'week.enable.desc': 'Abilita la pianificazione per giorno. Quando è disattivo viene usato lo stesso piano ogni giorno.' , 'editor.timefmt.title': 'Formato orario' , 'editor.timefmt.desc': 'Scegli 24 ore o 12 ore (AM/PM) per gli orari mostrati sulla scheda.' , 'editor.tempfmt.title': 'Unità di temperatura' , 'editor.tempfmt.desc': 'Scegli Celsius (°C) o Fahrenheit (°F) per la scheda e gli editor.' , 'editor.timesrc.title': 'Origine orario' , 'editor.timesrc.desc': 'Scegli se la scheda usa l\'ora del browser o il fuso di Home Assistant per "adesso".' , 'editor.timesrc.browser': 'Browser' , 'editor.timesrc.ha': 'Home Assistant' , 'editor.instance.title': 'ID configurazione' , 'editor.instance.desc': 'Mantieni più pianificazioni/impostazioni indipendenti con ID diversi.' , 'editor.instance.new_id': 'Nuovo ID' , 'editor.tabs.settings': 'Impostazioni' , 'editor.tabs.pause': 'Pausa' , 'editor.tabs.weekdays': 'Giorni feriali' , 'editor.tabs.rooms': 'Stanze' , 'editor.tabs.colors': 'Colori' , 'editor.tabs.presence_sensor': 'Sensore di presenza' , 'editor.presence_sensor.enable.title': 'Sensore di presenza' , 'editor.presence_sensor.enable.desc': 'Abilita la configurazione del sensore di presenza per stanza.' , 'editor.presence_sensor.entity.title': 'Entità di presenza' , 'editor.presence_sensor.entity.desc': 'Seleziona il binary_sensor che indica la presenza in questa stanza.' , 'editor.presence_sensor.on_temp.title': 'Temperatura quando presenza è ON' , 'editor.presence_sensor.on_temp.desc': 'Se il sensore è ON, questa stanza usa questa temperatura invece del programma.' , 'editor.presence_sensor.on_delay.title': 'Ritardo prima di applicare presenza' , 'editor.presence_sensor.on_delay.desc': 'Quanto tempo il sensore deve restare ON prima di usare la temperatura di presenza. Il valore usa l’unità selezionata.' , 'editor.presence_sensor.off_delay.title': 'Ritardo prima di riprendere il programma' , 'editor.presence_sensor.off_delay.desc': 'Quanto tempo il sensore deve restare OFF prima di riprendere il programma. Il valore usa l’unità selezionata.' , 'editor.tabs.owd': 'Open Window Detection' , 'owd.enable': 'Open Window Detection' , 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.' , 'owd.sensors': 'Window/Door sensors' , 'owd.delays.title': 'Delays' , 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).' , 'owd.open_delay': 'After open (min)' , 'owd.close_delay': 'After closed (min)' , 'editor.tabs.away': 'Fuori casa' , 'editor.tabs.reset': 'Reimposta' , 'editor.tabs.boiler': 'Controllo caldaia' , 'boiler.switch': 'Interruttore caldaia' , 'boiler.switch_type': 'Tipo di entità' , 'boiler.switch_type.switch': 'Interruttore' , 'boiler.switch_type.input_boolean': 'Booleano di input' , 'boiler.rooms': 'Stanze incluse' , 'boiler.rooms.desc': 'Seleziona quali stanze includere nel controllo della caldaia.' , 'boiler.multi_enable': 'Abilita più caldaie' , 'boiler.multi_enable.desc': 'Assegna le stanze a caldaie diverse e imposta offset per stanza.' , 'boiler.multi_rooms': 'Caldaia per stanza' , 'boiler.multi_rooms.desc': 'Seleziona l’interruttore della caldaia e gli offset per ogni stanza.' , 'boiler.room.enable': 'Includi stanza' , 'boiler.room.enable.desc': 'Includi questa stanza nel controllo caldaia.' , 'boiler.enable': 'Attiva controllo caldaia' , 'boiler.enable.desc': 'Mostra le impostazioni caldaia nell’editor di questa scheda.' , 'boiler.temp_sensor': 'Sensore temperatura caldaia' , 'boiler.temp_sensor.desc': 'Sensore di temperatura usato per controllare l’interruttore della caldaia.' , 'boiler.min_temp': 'Temperatura min. caldaia (°C)' , 'boiler.max_temp': 'Temperatura max. caldaia (°C)' , 'boiler.offsets': 'Offset' , 'boiler.offsets.desc': 'Offset ON: quanto sotto l’obiettivo programmato prima di accendere. Offset OFF: quanto sopra l’obiettivo programmato prima di spegnere.' , 'boiler.on_offset': 'Offset ON (°C)' , 'boiler.off_offset': 'Offset OFF (°C)' , 'editor.tabs.sync': 'Motore di sincronizzazione' , 'sync.running': 'Sincronizzazione in corso…' , 'sync.completed': 'Sincronizzazione completata' , 'away.enable': 'Abilita modalità assenza' , 'away.desc': 'Quando nessuna delle persone selezionate è in casa, abbassa automaticamente i termostati alla temperatura impostata.' , 'away.temp': 'Temperatura assenza' , 'away.persons': 'Persone' , 'away.add_person': 'Aggiungi persona' , 'away.resume_schedule': 'Riprendi programma' , 'away.resume_away': 'Ripristina temperatura assenza' , 'editor.heat_colors.title': 'Colori dei blocchi di calore' , 'editor.heat_colors.add': 'Aggiungi intervallo colore' , 'editor.colors.col_from': 'Da °C' , 'editor.colors.col_to': 'A °C' , 'editor.colors.col_color': 'Colore' , 'editor.colors.empty': 'Nessuna stanza aggiunta. Aggiungi una stanza per definire i colori.' , 'editor.colors.go_add': 'Aggiungi stanza' , 'editor.select_entities': 'Stanze' , 'editor.colors.refresh': 'Aggiorna programma' , 'editor.colors.clear_local': 'Pulisci dati colore locali' , 'editor.colors.clear_local_confirm': 'Questo eliminerà gli intervalli colore salvati nel tuo browser per questa scheda. Continuare?' , 'editor.colors.clear_shared': 'Pulisci archivio colori condiviso' , 'editor.colors.clear_shared_confirm': 'Questo eliminerà gli intervalli colore nel sensore di archiviazione selezionato. Continuare?' , 'editor.colors.clear_all': 'Pulisci tutti i dati colore' , 'editor.colors.clear_all_confirm': 'Questo eliminerà tutti gli intervalli colore sia locali sia nel sensore selezionato. Continuare?' , 'editor.colors.mode': 'Modalità colore' , 'editor.colors.mode.desc': 'Scegli colori per stanza o un set per tutte le stanze.' , 'editor.colors.mode_per_room': 'Per stanza' , 'editor.colors.mode_global': 'Tutte le stanze' , 'editor.colors.global_title': 'Tutte le stanze' , 'editor.reload_editor': 'Ricarica editor' , 'editor.reload_dashboard': 'Ricarica dashboard' , 'onboard.title': 'Benvenuto in Thermostat Timeline' , 'onboard.next': 'Avanti' , 'onboard.prev': 'Indietro' , 'onboard.skip': 'Salta' , 'onboard.done': 'Fine' , 'onboard.dont_show': 'Non mostrare più' , 'onboard.step_main': 'Questa è la timeline principale. Mostra le temperature desiderate sulle 24 ore per ogni stanza.' , 'onboard.step_add': 'Aggiungi un blocco di calore con + Aggiungi blocco o facendo doppio clic sulla timeline.' , 'onboard.step_edit': 'Modifica o elimina un blocco con un doppio clic. Usa Giorni feriali per pianificare giorni diversi.' , 'onboard.reset': 'Mostra di nuovo la guida' , 'onboard.reset_ok': 'La schermata di benvenuto verrà mostrata la prossima volta.' , 'pause.menu_title': 'Pausa applicazione automatica' , 'pause.off_1h': 'Pausa 1 ora' , 'pause.off_5h': 'Pausa 5 ore' , 'pause.off_custom': 'Durata personalizzata…' , 'pause.off_indef': 'Pausa finché non riprendo' , 'pause.resume': 'Riprendi ora' , 'pause.close': 'Chiudi' , 'pause.active_until': 'In pausa fino alle {time}' , 'pause.active_indef': 'In pausa' , 'editor.pausebtn.title': 'Pulsante Pausa' , 'editor.pausebtn.desc': 'Mostra un pulsante di pausa globale nell\'intestazione della scheda. Durante la pausa, tutti i comandi set_temperature sono sospesi.' , 'editor.integration.connected': 'Integrazione pronta' , 'editor.integration.disconnected': 'Integrazione non disponibile' , 'profiles.enable': 'Abilita profili (override)' , 'profiles.enable.desc': 'Consenti programmi giornalieri nominati che possono sostituire il piano principale.' , 'season.enable.title': 'Abilita programmi estivi/invernali' , 'season.enable.desc': 'Quando attivo, ogni programma ha una versione Estate e Inverno tra cui puoi passare.' , 'season.mode.title': 'Stagione' , 'season.mode.winter': 'Inverno' , 'season.mode.summer': 'Estate' , 'profiles.button': 'Programma manuale' , 'profiles.editor.title': 'Profili' , 'profiles.add': 'Aggiungi profilo' , 'profiles.rename': 'Rinomina' , 'profiles.delete': 'Elimina' , 'profiles.activate': 'Attiva' , 'profiles.deactivate': 'Disattiva' , 'profiles.active': 'Attivo' , 'profiles.none': 'Nessuno' , 'profiles.name_placeholder': 'Nome profilo' , 'profiles.edit': 'Modifica' , 'profiles.preview_note': 'Solo anteprima. I programmi diventano attivi quando attivi il profilo.' , 'profiles.active_label': 'Profilo manuale attivo:' , 'profiles.save_new': 'Salva nuovo profilo' , 'profiles.save_existing': 'Salva modifiche al profilo selezionato' , 'editor.tabs.holidays': 'Festività' , 'editor.tabs.backup': 'Backup' , 'backup.title': 'Backup' , 'backup.desc': 'Copia programmi e impostazioni nel backup e ripristinali al bisogno.' , 'backup.now': 'Esegui backup ora' , 'backup.restore': 'Ripristina' , 'backup.delete': 'Elimina backup' , 'backup.delete.confirm': 'Eliminare il backup selezionato?' , 'backup.new': 'Nuovo backup' , 'backup.auto': 'Backup automatico' , 'backup.auto.desc': 'Esegui backup automatici pianificati.' , 'backup.interval': 'Intervallo (giorni)' , 'backup.interval.desc': 'Numero di giorni tra i backup automatici (min. 1).' , 'backup.sensor': 'Sensore di backup' , 'backup.last': 'Ultimo backup' , 'backup.export.title': 'Esporta file di backup' , 'backup.export.desc': 'Scarica un file thermostat_timeline_backup.json completo con tutti gli slot e i dati.' , 'backup.export.btn': 'Scarica file' , 'backup.file.add_selected': 'Importa backup' , 'backup.file.add_all': 'Importa tutti i backup' , 'backup.file.loaded_n': 'File caricato ({n} backup)' , 'backup.file.imported_ok': 'Importato ({n})' , 'backup.slot.title': 'Seleziona backup' , 'backup.slot.desc': 'Seleziona quale slot di backup ripristinare.' , 'backup.slot.empty': 'Nessun backup ancora' , 'backup.select': 'Scegli cosa includere nel backup' , 'backup.select.desc': 'Gli elementi selezionati sono inclusi nel backup/ripristino' , 'backup.opt.all': 'Tutto' , 'backup.opt.main': 'Programmi principali' , 'backup.opt.weekday': 'Programmi feriali' , 'backup.opt.presence': 'Programmi presenza' , 'backup.opt.settings': 'Impostazioni editor' , 'backup.opt.holiday': 'Programmi festività' , 'backup.opt.colors': 'Blocchi colore' , 'holidays.enable': 'Abilita festività' , 'holidays.desc': 'Usa un programma giornaliero speciale nei giorni festivi.' , 'holidays.source': 'Fonte festività' , 'holidays.source.calendar': 'Calendario' , 'holidays.source.manual': 'Date manuali' , 'holidays.entity': 'Entità festività' , 'holidays.entity_desc': 'Seleziona un calendario che segnala solo acceso/spento. Deve essere acceso nei giorni festivi e spento negli altri.' , 'holidays.dates': 'Date festività' , 'holidays.add_date': 'Aggiungi data' , 'holidays.editor.title': 'Programma festività' , 'holidays.edit': 'Modifica programma festività' , 'holidays.button': 'Festività' , 'presence.button': 'Programmi presenza' , 'presence.editor.title': 'Programmi presenza' , 'presence.enable_advanced': 'Abilita pianificazioni di presenza avanzate' , 'presence.enable_advanced.desc': 'Usa combinazioni e pianificazioni in base a chi è in casa/fuori.' , 'away.delay_enable': 'Ritarda azioni di assenza' , 'away.delay_desc': 'Attendi prima di applicare i cambi di assenza.' , 'away.delay_value': 'Ritardo' , 'away.delay_unit': 'Unità' , 'away.delay_unit.seconds': 'Secondi' , 'away.delay_unit.minutes': 'Minuti' , 'presence.live_header': 'Mostra presenza live nell\'intestazione' , 'presence.live_header.desc': 'Le chip vengono visualizzate nell’angolo destro.' , 'presence.combos': 'Combinazioni' , 'presence.enable_combo': 'Abilita questa combinazione' , 'presence.all_home': 'Tutti a casa' , 'presence.none_home': 'Nessuno a casa' , 'presence.home': 'A casa: {names}' , 'presence.away': 'Fuori: {names}' , 'presence.manage': 'Gestisci combinazioni' , 'presence.who_home': 'Chi è a casa' , 'presence.who_away': 'Chi non è a casa' , 'err.config_entities': "La configurazione deve avere 'entities: [climate.xxx, ...]'." , 'onboard.video_not_found': 'Video non trovato' , 'onboard.image_not_found': 'Immagine non trovata' , 'pause.prompt_minutes': 'Inserisci la durata (minuti):' , 'editor.test_tool': 'Strumento di test' , 'editor.test_select_entity': "Seleziona un'entità" , 'editor.test_empty': 'Niente da esportare' , 'editor.test_export': 'Esporta' , 'editor.test_no_fields': 'Nessun campo da mostrare' , 'editor.test_select_placeholder': 'Seleziona...' , 'editor.error_prefix': 'Errore: ' , 'backup.restore.confirm_overwrite': 'Ripristinare da backup? Questo sovrascriverà pianificazioni e impostazioni correnti.' , 'backup.import.err_no_hass': 'Home Assistant non è pronto' , 'backup.import.err_storage_off': "L'archiviazione condivisa è DISATTIVATA. Attivala prima." , 'backup.import.err_no_integration': "L'integrazione thermostat_timeline non è disponibile" , 'backup.import.err_json': 'JSON non valido' , 'profiles.name_exists': 'Il nome esiste già' }, fi: { 'card.title_default': 'Termostaatin aikajana', 'ui.edit_block': 'Muokkaa lohkoa', 'ui.add_block': 'Lisää lohko', 'ui.from': 'Alkaen', 'ui.to': 'Asti', 'ui.temperature': 'Lämpötila', 'ui.delete': 'Poista', 'ui.cancel': 'Peruuta', 'ui.close': 'Sulje', 'ui.unsaved_confirm': 'Sinulla on tallentamattomia muutoksia. Suljetaanko silti?', 'ui.discard_changes': 'Hylkää muutokset', 'ui.keep_editing': 'Jatka muokkausta', 'ui.save': 'Tallenna', 'ui.error_prefix': 'Virhe: ', 'ui.overlap_msg': 'Valitsit päällekkäisiä aikoja — ne menevät päällekkäin välillä {start} – {end}.', 'ui.auto_fix': 'Korjaa automaattisesti', 'ui.fix_start_to': 'Korjaa alku ajaksi {time}', 'ui.fix_end_to': 'Korjaa loppu ajaksi {time}', // Inline meta 'ui.current_temp': 'Nykyinen lämpötila:', 'editor.title_label': 'Otsikko', 'editor.add_entity': 'Lisää huone', 'editor.entity_placeholder': 'Valitse entiteetti', 'editor.drag_reorder': 'Vedä järjestelläksesi', 'editor.remove': 'Poista', 'editor.default_c': 'Oletus °C', 'editor.row_height_px': 'Rivin korkeus (px)', 'editor.max_c': 'Maksi °C', 'editor.min_c': 'Min °C', 'editor.auto_apply.title': 'Automaattinen käyttö', 'editor.auto_apply.desc': 'Kun käytössä, kortti yrittää asettaa valittujen climate-entiteettien lämpötilan vastaamaan suunniteltua arvoa tälle hetkelle.', 'editor.apply_edit.title': 'Muokkauksen yhteydessä', 'editor.apply_edit.desc': 'Kun muutat aikajanan lohkoja ja muutos koskee nykyhetkeä, uusi lämpötila otetaan käyttöön heti.', 'editor.apply_default.title': 'Kun Oletus °C muuttuu', 'editor.apply_default.desc': 'Kun ”Oletus °C” muuttuu ja se vaikuttaa meneillään olevaan ajanjaksoon, uusi lämpötila otetaan käyttöön heti.', 'editor.show_room_temp.title': 'Näytä huonelämpötila', 'editor.show_room_temp.desc': 'Näyttää nykyisen huonelämpötilan kuplan jokaisella rivillä aikajanan otsikossa.', 'editor.room_mode.title': 'Huoneen entiteettityyppi', 'editor.room_mode.desc': 'Kun käytössä, valitse input_number (tavoitelämpötila). Kun pois päältä, valitse climate-entiteetti.', 'editor.room_temp_sensor_override.title': 'Käytä huonelämpötila-anturia', 'editor.room_temp_sensor_override.desc': 'Kun käytössä, näytetty huonelämpötila (ja kattilalogiikka) käyttää valittua lämpötila-anturia climate current_temperature -arvon sijaan.', 'editor.room_temp_sensor_override.entity': 'Lämpötila-anturi', 'ui.copy_plan': 'Kopioi aikataulu', 'ui.paste_plan': 'Liitä aikataulu', 'ui.copy_day': 'Kopioi päivä', 'ui.paste_day': 'Liitä päivä', 'editor.merge_label': 'Yhdistä (lisää toinen termostaatti)', 'editor.display_name_optional': 'Huoneen nimi (valinnainen)', 'editor.merged_with': 'Yhdistetty kohteeseen', 'editor.store_enable.title': 'Jaettu tallennus + taustasäätö', 'editor.store_enable.short': 'Tallentaa aikataulut valittuun sensoriin ja ottaa käyttöön taustasäädön.', 'editor.store_enable.desc': 'Tallentaa aikataulut valittuun sensoriin ja ottaa käyttöön taustalla tapahtuvan set_temperature‑ohjauksen. Termostaatit päivittyvät myös, kun kortti ei ole auki. Jakaa myös aikataulut (lämpöblokit) ja kortin asetukset käyttäjien/laitteiden välillä. Kun pois päältä: komennot lähetetään vain, kun kortti on näkyvissä laitteella (tabletti/tietokone/puhelin) ja sen on pysyttävä auki.', 'editor.store_missing': 'Integraatiota "Thermostat Pro Timeline Sync" ei ole asennettu. Jaettu tallennus ei ole käytettävissä.', 'editor.clear_store': 'Tyhjennä tallennus', 'editor.clear_store_confirm': 'Tämä poistaa kaikki tallennetut aikataulut. Jatketaanko?', 'editor.migrate_to_store': 'Siirrä tiedot tallennukseen', 'editor.migrate_confirm': 'Siirretäänkö selaimeen tallennetut aikataulut valittuun sensoriin? Tämä korvaa nykyiset aikataulut tallennuksessa.', 'editor.migrate_pull_confirm': 'Tuo aikataulut tallennuksesta ja korvaa paikalliset selaintiedot?', 'editor.select_storage_first': 'Valitse ensin tallennussensori.', 'editor.no_local_data': 'Paikallisia selaintietoja ei löytynyt.', 'editor.clear_all': 'Tyhjennä kaikki tiedot', 'editor.clear_all_confirm': 'Tämä poistaa sekä tallennuksesta että selaimesta aikataulut. Jatketaanko?', 'editor.clear_storage_only': 'Tyhjennä vain tallennusdata', 'editor.clear_storage_only_confirm': 'Tämä poistaa aikataulut valitusta sensorista. Jatketaanko?', 'editor.clear_local_only': 'Tyhjennä vain paikalliset tiedot', 'editor.clear_local_only_confirm': 'Tämä poistaa vain selaimeen tallennetut aikataulut. Jatketaanko?', 'editor.reset.desc': 'Mitä tämä tekee:\n\n• Tyhjennä kaikki tiedot: Nollaa Thermostat Timeline -integraation backend-tallennuksen (.storage) ja poistaa KAIKKI paikalliset välimuistit/luonnokset tältä selaimelta.\n• Tyhjennä vain paikalliset tiedot: Tyhjentää vain tässä selaimessa tallennetut tiedot (localStorage).\n\nVinkki: Täyden resetoinnin jälkeen lataa sivu uudelleen, jotta vanhat luonnokset eivät ilmesty takaisin.', 'week.enable': 'Ota viikonpäivät käyttöön', 'week.mode': 'Viikkotila', 'week.mode.same_all': 'Sama joka päivä (1)', 'week.mode.weekday_weekend': 'Arkipäivät + Viikonloppu', 'week.mode.weekday_sat_sun': 'Arkipäivät + Lauantai + Sunnuntai', 'week.mode.all_7': 'Kaikki päivät', 'week.button': 'Viikonpäivät', 'week.days.mon': 'Ma', 'week.days.tue': 'Ti', 'week.days.wed': 'Ke', 'week.days.thu': 'To', 'week.days.fri': 'Pe', 'week.days.sat': 'La', 'week.days.sun': 'Su', 'week.editor.title': 'Viikkosuunnitelma', 'week.editor.preview_note': 'Vain esikatselu. Muutokset otetaan käyttöön, kun painat Tallenna.', 'week.open_editor.desc': 'Avaa viikonpäiväeditori.', 'week.open_editor.btn': 'Avaa', 'week.copy_to_rooms': 'Kopioi viikkosuunnitelma huoneisiin', 'week.copy_to_rooms.title': 'Kopioi viikkosuunnitelma huoneisiin', 'week.copy_to_rooms.select': 'Valitse huoneet', 'week.copy_to_rooms.apply': 'Lisää huoneisiin', 'week.group.weekdays': 'Arkipäivät', 'week.group.weekend': 'Viikonloppu', 'week.days_full.mon': 'Maanantai', 'week.days_full.tue': 'Tiistai', 'week.days_full.wed': 'Keskiviikko', 'week.days_full.thu': 'Torstai', 'week.days_full.fri': 'Perjantai', 'week.days_full.sat': 'Lauantai', 'week.days_full.sun': 'Sunnuntai', 'editor.timefmt.title': 'Aikamuoto', 'editor.timefmt.desc': 'Valitse 24 tunnin tai 12 tunnin (AM/PM) näyttö kortin ajoille.', 'editor.tempfmt.title': 'Lämpötilayksikkö', 'editor.tempfmt.desc': 'Valitse Celsius (°C) tai Fahrenheit (°F) kortin ja editorien näyttöön.', 'editor.timesrc.title': 'Ajan lähde', 'editor.timesrc.desc': 'Valitse käytetäänkö “nyt”‑hetkeen selaimen aikaa vai Home Assistantin aikavyöhykettä.', 'editor.timesrc.browser': 'Selain', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'Kokoonpano-ID', 'editor.instance.desc': 'Pidä useita erillisiä aikatauluja/asetuksia eri tunnisteilla.', 'editor.instance.new_id': 'Uusi ID', 'editor.heat_colors.title': 'Lämmityslohkojen värit', 'editor.heat_colors.add': 'Lisää värihaarukka', 'editor.colors.col_from': 'Alkaen °C', 'editor.colors.col_to': 'Asti °C', 'editor.colors.col_color': 'Väri', 'editor.colors.empty': 'Huoneita ei ole lisätty vielä. Lisää huone määrittääksesi värit.', 'editor.colors.go_add': 'Lisää huone', 'editor.colors.refresh': 'Päivitä aikataulu', 'editor.colors.clear_local': 'Tyhjennä paikalliset väritiedot', 'editor.colors.clear_local_confirm': 'Tämä poistaa tähän korttiin selaimeen tallennetut värihaarukat. Jatketaanko?', 'editor.colors.clear_shared': 'Tyhjennä jaettu värivarasto', 'editor.colors.clear_shared_confirm': 'Tämä poistaa värihaarukat valitusta tallennussensorista. Jatketaanko?', 'editor.colors.mode': 'Väritila', 'editor.colors.mode.desc': 'Valitse värit huoneittain tai yksi yhteinen kaikille.', 'editor.colors.mode_per_room': 'Huoneittain', 'editor.colors.mode_global': 'Kaikille', 'editor.colors.global_title': 'Kaikki huoneet', 'editor.colors.clear_all': 'Tyhjennä kaikki väritiedot', 'editor.colors.clear_all_confirm': 'Tämä poistaa kaikki värihaarukat sekä paikallisesti että valitusta tallennussensorista. Jatketaanko?', 'editor.reload_editor': 'Lataa editori uudelleen', 'editor.reload_dashboard': 'Lataa koontinäyttö uudelleen', 'onboard.title': 'Tervetuloa Termostaatin aikajanaan', 'onboard.next': 'Seuraava', 'onboard.prev': 'Edellinen', 'onboard.skip': 'Ohita', 'onboard.done': 'Valmis', 'onboard.dont_show': 'Älä näytä uudelleen', 'onboard.step_main': 'Tämä on pääaikajana. Se näyttää toivotut lämpötilat 24 tunnin aikana huoneittain.', 'onboard.step_add': 'Lisää lämpölohko painamalla + Lisää lohko tai kaksoisnapsauttamalla aikajanaa.', 'onboard.step_edit': 'Muokkaa tai poista lohko kaksoisnapsauttamalla. Käytä viikonpäiviä eri päiville.', 'onboard.reset': 'Näytä opas uudelleen', 'onboard.reset_ok': 'Tervetuloa-näyttö avautuu seuraavalla kerralla.', 'editor.select_entities': 'Huoneet', 'editor.tabs.rooms': 'Huoneet', 'editor.test_tool': 'Testityökalu', 'editor.test_select_entity': 'Valitse entiteetti', 'editor.test_empty': 'Valitse entiteetti nähdäksesi käytettävissä olevat komennot…', 'editor.test_export': 'Vie .md', 'editor.test_no_fields': 'Ei kenttiä', 'editor.test_select_placeholder': '— Valitse —', 'editor.error_prefix': 'Virhe: ', 'editor.open_in_hacs': 'Avaa HACSissa', 'week.enable.desc': 'Ota viikonpäiväkohtainen aikataulu käyttöön. Kun pois päältä, käytetään samaa aikataulua joka päivä.', 'editor.tabs.sync': 'Synkronointi‑moottori', 'sync.running': 'Synkronointi käynnissä…', 'sync.completed': 'Synkronointi valmis', 'editor.tabs.backup': 'Varmuuskopio', 'backup.title': 'Varmuuskopio', 'backup.desc': 'Kopioi sync‑anturin sisältö varmuuskopioanturiin ja palauta tarvittaessa.', 'backup.now': 'Varmuuskopioi nyt', 'backup.restore': 'Palauta', 'backup.delete': 'Poista varmuuskopio', 'backup.delete.confirm': 'Poistetaanko valittu varmuuskopio?', 'backup.new': 'Uusi varmuuskopio', 'backup.auto': 'Automaattinen varmuuskopiointi', 'backup.auto.desc': 'Tee varmuuskopioita automaattisesti tietyin välein.', 'backup.interval': 'Väli (päivää)', 'backup.interval.desc': 'Kuinka monta päivää automaattisten varmuuskopioiden välillä (min. 1).', 'backup.sensor': 'Varmuuskopioanturi', 'backup.last': 'Viimeisin varmuuskopio', 'backup.export.title': 'Vie varmuuskopiotiedosto', 'backup.export.desc': 'Lataa täydellinen thermostat_timeline_backup.json, jossa on kaikki slotit ja tiedot.', 'backup.export.btn': 'Lataa tiedosto', 'backup.file.add_selected': 'Tuo varmuuskopio', 'backup.file.add_all': 'Tuo kaikki varmuuskopiot', 'backup.file.loaded_n': 'Tiedosto ladattu ({n} varmuuskopiota)', 'backup.file.imported_ok': 'Tuotu ({n})', 'backup.slot.title': 'Valitse varmuuskopio', 'backup.slot.desc': 'Valitse mikä varmuuskopiointipaikka palautetaan.', 'backup.slot.empty': 'Ei varmuuskopioita vielä', 'pause.menu_title': 'Keskeytä automaattinen asetus', 'pause.off_1h': 'Keskeytä 1 tunniksi', 'pause.off_5h': 'Keskeytä 5 tunniksi', 'pause.off_custom': 'Mukautettu kesto…', 'pause.off_indef': 'Keskeytä kunnes jatkan', 'pause.resume': 'Jatka nyt', 'pause.close': 'Sulje', 'pause.active_until': 'Keskeytetty asti {time}', 'pause.active_indef': 'Keskeytetty', 'editor.pausebtn.title': 'Taukopainike', 'editor.pausebtn.desc': 'Näytä globaali taukopainike kortin yläreunassa. Tauon aikana kaikki set_temperature‑komennot estetään.', 'editor.integration.connected': 'Integraatio valmis', 'editor.integration.disconnected': 'Integraatio ei käytettävissä' , 'profiles.button': 'Manuaalinen aikataulu' , 'profiles.editor.title': 'Profiilit' , 'profiles.add': 'Lisää profiili' , 'profiles.rename': 'Nimeä uudelleen' , 'profiles.delete': 'Poista' , 'profiles.activate': 'Aktivoi' , 'profiles.deactivate': 'Poista käytöstä' , 'profiles.active': 'Aktiivinen' , 'profiles.none': 'Ei mikään' , 'profiles.name_placeholder': 'Profiilin nimi' , 'profiles.edit': 'Muokkaa' , 'profiles.preview_note': 'Vain esikatselu. Aikataulut aktivoituvat, kun aktivoit profiilin.' , 'editor.tabs.holidays': 'Pyhäpäivät' , 'holidays.enable': 'Ota pyhäpäivät käyttöön' , 'holidays.desc': 'Käytä erityistä päiväaikataulua pyhäpäivinä.' , 'holidays.source': 'Pyhäpäivien lähde' , 'holidays.source.calendar': 'Kalenteri' , 'holidays.source.manual': 'Manuaaliset päivämäärät' , 'holidays.entity': 'Pyhäpäivä‑entiteetti' , 'holidays.entity_desc': 'Valitse kalenteri, joka ilmoittaa vain on/off. Sen tulee olla on pyhäpäivinä ja off muina päivinä.' , 'holidays.dates': 'Pyhäpäivien päivämäärät' , 'holidays.add_date': 'Lisää päivämäärä' , 'holidays.editor.title': 'Pyhäpäivä‑aikataulu' , 'holidays.edit': 'Muokkaa pyhäpäivä‑aikataulua' , 'holidays.button': 'Pyhäpäivät' , 'editor.perroom.title': 'Oletus °C huoneittain' , 'editor.perroom.desc': 'Näytä oletus °C -kenttä jokaisella rivillä. Kun pois päältä, käytetään globaalia oletus °C:ta kaikissa huoneissa.' , 'away.resume_schedule': 'Jatka aikataulua' , 'away.resume_away': 'Jatka poissaololämpötilaa' , 'presence.button': 'Läsnäoloaikataulut' , 'presence.editor.title': 'Läsnäoloaikataulut' , 'presence.enable_advanced': 'Ota käyttöön kehittyneet läsnäoloaikataulut' , 'presence.enable_advanced.desc': 'Käytä yhdistelmiä ja aikatauluja sen mukaan kuka on kotona/poissa.' , 'away.delay_enable': 'Viivästytä poissaolo‑toimia' , 'away.delay_desc': 'Odotetaan ennen poissaolomuutosten soveltamista.' , 'away.delay_value': 'Viive' , 'away.delay_unit': 'Yksikkö' , 'away.delay_unit.seconds': 'Sekunnit' , 'away.delay_unit.minutes': 'Minuutit' , 'presence.combos': 'Yhdistelmät' , 'presence.enable_combo': 'Ota tämä yhdistelmä käyttöön' , 'presence.all_home': 'Kaikki kotona' , 'presence.none_home': 'Ei ketään kotona' , 'presence.home': 'Kotona: {names}' , 'presence.away': 'Poissa: {names}' , 'presence.manage': 'Hallinnoi yhdistelmiä' , 'presence.who_home': 'Kuka on kotona' , 'presence.who_away': 'Kuka ei ole kotona' , 'away.enable': 'Ota poissaolotila käyttöön' , 'away.desc': 'Kun yksikään valituista henkilöistä ei ole kotona, termostaatit lasketaan automaattisesti asetettuun lämpötilaan.' , 'away.temp': 'Poissaololämpötila' , 'away.persons': 'Henkilöt' , 'away.add_person': 'Lisää henkilö' , 'backup.select': 'Valitse, mitä varmuuskopioidaan' , 'backup.select.desc': 'Valitut kohteet sisällytetään varmuuskopiointiin/palautukseen' , 'backup.opt.all': 'Kaikki' , 'backup.opt.main': 'Pääaikataulut' , 'backup.opt.weekday': 'Viikonpäiväaikataulut' , 'backup.opt.presence': 'Läsnäoloaikataulut' , 'backup.opt.settings': 'Editorin asetukset' , 'backup.opt.holiday': 'Pyhäpäiväaikataulut' , 'backup.opt.colors': 'Värilohkot' , 'editor.tabs.settings': 'Asetukset' , 'editor.tabs.pause': 'Tauko' , 'editor.tabs.weekdays': 'Viikonpäivät' , 'editor.tabs.colors': 'Värit' , 'editor.tabs.presence_sensor': 'Läsnäoloanturi' , 'editor.presence_sensor.enable.title': 'Läsnäoloanturi' , 'editor.presence_sensor.enable.desc': 'Ota käyttöön läsnäoloanturin määritys huoneittain.' , 'editor.presence_sensor.entity.title': 'Läsnäoloentiteetti' , 'editor.presence_sensor.entity.desc': 'Valitse binary_sensor, joka ilmaisee läsnäolon tässä huoneessa.' , 'editor.presence_sensor.on_temp.title': 'Lämpötila, kun läsnäolo on PÄÄLLÄ' , 'editor.presence_sensor.on_temp.desc': 'Jos sensori on PÄÄLLÄ, tämä huone käyttää tätä lämpötilaa aikataulun sijaan.' , 'editor.presence_sensor.on_delay.title': 'Viive ennen läsnäolon käyttöä' , 'editor.presence_sensor.on_delay.desc': 'Kuinka kauan sensorin täytyy olla PÄÄLLÄ ennen kuin läsnäololämpötila otetaan käyttöön. Arvo käyttää valittua yksikköä.' , 'editor.presence_sensor.off_delay.title': 'Viive ennen aikatauluun palaamista' , 'editor.presence_sensor.off_delay.desc': 'Kuinka kauan sensorin täytyy olla POIS PÄÄLTÄ ennen kuin aikataulu palautuu. Arvo käyttää valittua yksikköä.' , 'editor.tabs.owd': 'Open Window Detection' , 'owd.enable': 'Open Window Detection' , 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.' , 'owd.sensors': 'Window/Door sensors' , 'owd.delays.title': 'Delays' , 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).' , 'owd.open_delay': 'After open (min)' , 'owd.close_delay': 'After closed (min)' , 'editor.tabs.away': 'Poissa kotoa' , 'editor.tabs.reset': 'Palauta' , 'editor.tabs.boiler': 'Kattilan ohjaus' , 'boiler.switch': 'Kattilakytkin' , 'boiler.switch_type': 'Entiteettityyppi' , 'boiler.switch_type.switch': 'Kytkin' , 'boiler.switch_type.input_boolean': 'Input boolean' , 'boiler.rooms': 'Mukana olevat huoneet' , 'boiler.rooms.desc': 'Valitse, mitkä huoneet otetaan mukaan kattilan ohjaukseen.' , 'boiler.multi_enable': 'Ota käyttöön useita kattiloita' , 'boiler.multi_enable.desc': 'Määritä huoneet eri kattiloille ja aseta offsetit huonekohtaisesti.' , 'boiler.multi_rooms': 'Kattila per huone' , 'boiler.multi_rooms.desc': 'Valitse kattilakytkin ja offsetit jokaiselle huoneelle.' , 'boiler.room.enable': 'Sisällytä huone' , 'boiler.room.enable.desc': 'Sisällytä tämä huone kattilan ohjaukseen.' , 'boiler.enable': 'Ota kattilan ohjaus käyttöön' , 'boiler.enable.desc': 'Näytä kattila-asetukset tämän kortin editorissa.' , 'boiler.temp_sensor': 'Kattilan lämpötila‑anturi' , 'boiler.temp_sensor.desc': 'Lämpötila‑anturi, joka ohjaa kattilakytkintä.' , 'boiler.min_temp': 'Kattilan minimi (°C)' , 'boiler.max_temp': 'Kattilan maksimi (°C)' , 'boiler.offsets': 'Offsetit' , 'boiler.offsets.desc': 'ON‑offset: kuinka paljon alle aikataulun tavoitteen ennen kuin kytkeytyy päälle. OFF‑offset: kuinka paljon yli aikataulun tavoitteen ennen kuin kytkeytyy pois.' , 'boiler.on_offset': 'ON offset (°C)' , 'boiler.off_offset': 'OFF offset (°C)' , 'editor.temp_sensor': 'Lämpötila‑anturi' , 'presence.live_header': 'Näytä live‑läsnäolo yläreunassa' , 'presence.live_header.desc': 'Siruja näytetään oikeassa kulmassa.' , 'profiles.enable': 'Ota profiilit käyttöön (ohitus)' , 'profiles.enable.desc': 'Salli nimettyjä päiväaikatauluja, jotka voit aktivoida ohittaaksesi pääsuunnitelman.' , 'season.enable.title': 'Ota käyttöön kesä-/talviaikataulut' , 'season.enable.desc': 'Kun käytössä, jokaisella aikataululla on erillinen Kesä- ja Talvi-versio, joiden välillä voit vaihtaa.' , 'season.mode.title': 'Kausi' , 'season.mode.winter': 'Talvi' , 'season.mode.summer': 'Kesä' , 'profiles.active_label': 'Manuaalinen profiili aktiivinen:' , 'profiles.save_new': 'Tallenna uusi profiili' , 'profiles.save_existing': 'Tallenna muutokset valittuun profiiliin' , 'week.view.title': 'Viikkonäkymä' , 'week.view.rooms_one_day': 'Kaikki huoneet • yksi päivä' , 'week.view.days_one_room': 'Kaikki päivät • yksi huone' , 'week.view_switch.title': 'Vaihda näkymää aikajanasta' , 'week.view_switch.desc': 'Näyttää valitsimen aikajanan otsikossa, jolla voi vaihtaa kahden viikonpäivänäkymän välillä.' , 'week.copy_from_main.title': 'Kopioi pääaikataulu' , 'week.copy_from_main.desc': 'Otit viikonpäivät käyttöön. Haluatko kopioida pääaikataulun viikonpäiviin? Valitse tila, johon kopioidaan.' , 'week.copy_from_main.mode_label': 'Viikonpäivätila' , 'week.copy_from_main.target_label': 'Kopioi kohteeseen' , 'week.copy_from_main.copy_btn': 'Kopioi' , 'week.copy_from_main.skip_btn': 'Älä kopioi' , 'week.title': 'Viikonpäivät' , 'week.select_room': 'Valitse huone' , 'err.config_entities': "Määrityksessä täytyy olla 'entities: [climate.xxx, ...]'." , 'onboard.video_not_found': 'Videota ei löytynyt' , 'onboard.image_not_found': 'Kuvaa ei löytynyt' , 'pause.prompt_minutes': 'Anna kesto (minuuttia):' , 'editor.test_tool': 'Testityökalu' , 'editor.test_select_entity': 'Valitse entiteetti' , 'editor.test_empty': 'Ei mitään vietävää' , 'editor.test_export': 'Vie' , 'editor.test_no_fields': 'Ei kenttiä näytettäväksi' , 'editor.test_select_placeholder': 'Valitse...' , 'editor.error_prefix': 'Virhe: ' , 'backup.restore.confirm_overwrite': 'Palautetaanko varmuuskopiosta? Tämä korvaa nykyiset aikataulut ja asetukset.' , 'backup.import.err_no_hass': 'Home Assistant ei ole valmis' , 'backup.import.err_storage_off': 'Jaettu tallennus on POIS PÄÄLTÄ. Ota se ensin käyttöön.' , 'backup.import.err_no_integration': 'Integraatio thermostat_timeline ei ole käytettävissä' , 'backup.import.err_json': 'Virheellinen JSON' , 'profiles.name_exists': 'Nimi on jo olemassa' }, cs: { // Karta 'card.title_default': 'Časová osa termostatu', // Modal / editor (inline) 'ui.edit_block': 'Upravit blok', 'ui.add_block': 'Přidat blok', 'ui.from': 'Od', 'ui.to': 'Do', 'ui.temperature': 'Teplota', 'ui.delete': 'Smazat', 'ui.cancel': 'Zrušit', 'ui.close': 'Zavřít', 'ui.unsaved_confirm': 'Máte neuložené změny. Opravdu zavřít?', 'ui.discard_changes': 'Zahodit změny', 'ui.keep_editing': 'Pokračovat v úpravách', 'ui.save': 'Uložit', 'ui.error_prefix': 'Chyba: ', 'ui.overlap_msg': 'Vybrali jste překrývající se časy — překrývá se mezi {start} – {end}.', 'ui.auto_fix': 'Opravit automaticky', 'ui.fix_start_to': 'Upravit začátek na {time}', 'ui.fix_end_to': 'Upravit konec na {time}', // Inline meta 'ui.current_temp': 'Aktuální teplota:', // GUI editor panel 'editor.title_label': 'Nadpis', 'editor.add_entity': 'Přidat místnost', 'editor.entity_placeholder': 'Vyberte entitu', 'editor.room_mode.title': 'Typ entity místnosti', 'editor.room_mode.desc': 'Když je zapnuto, vyberte input_number (cílová teplota). Když je vypnuto, vyberte entitu climate.', 'editor.room_temp_sensor_override.title': 'Použít senzor teploty v místnosti', 'editor.room_temp_sensor_override.desc': 'Je-li zapnuto, zobrazená teplota v místnosti (a logika kotle) používá vybraný teplotní senzor místo climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Senzor teploty', 'editor.drag_reorder': 'Přetažením změňte pořadí', 'editor.remove': 'Odebrat', 'editor.default_c': 'Výchozí °C', 'editor.row_height_px': 'Výška řádku (px)', 'editor.max_c': 'Max °C', 'editor.min_c': 'Min °C', 'editor.auto_apply.title': 'Automatické použití', 'editor.auto_apply.desc': 'Je-li zapnuto, karta se pokusí nastavit teplotu vybraných climate entit tak, aby odpovídala plánované hodnotě pro „právě teď“.', 'editor.apply_edit.title': 'Při úpravě', 'editor.apply_edit.desc': 'Když změníte bloky v časové ose a změna ovlivní aktuální čas, nová teplota se použije okamžitě.', 'editor.apply_default.title': 'Při změně Výchozí °C', 'editor.apply_default.desc': 'Když se změní „Výchozí °C“ a ovlivní aktuální období, nová teplota se použije okamžitě.', 'editor.show_room_temp.title': 'Zobrazit teplotu v místnosti', 'editor.show_room_temp.desc': 'Zobrazí bublinu s aktuální teplotou v místnosti na každém řádku v záhlaví časové osy.', 'ui.copy_plan': 'Kopírovat plán', 'ui.paste_plan': 'Vložit plán', 'ui.copy_day': 'Kopírovat den', 'ui.paste_day': 'Vložit den', 'editor.merge_label': 'Sloučit s (přidat další termostat)', 'editor.display_name_optional': 'Název místnosti (volitelné)', 'editor.merged_with': 'Sloučeno s', 'editor.store_enable.title': 'Sdílené úložiště + řízení na pozadí', 'editor.store_enable.short': 'Ukládá rozvrhy do zvoleného senzoru a umožňuje řízení na pozadí.', 'editor.store_enable.desc': 'Ukládá rozvrhy do zvoleného senzoru a zapíná set_temperature na pozadí přes integraci Thermostat Timeline. Termostaty se aktualizují i když karta není otevřená. Také sdílí rozvrhy (tepelné bloky) a nastavení karty mezi uživateli/zařízeními. Když je vypnuto, příkazy se posílají jen když je karta aktivně viditelná na zařízení (tablet/PC/mobil) — musí zůstat otevřená na obrazovce.', 'editor.store_missing': 'Integrace „Thermostat Pro Timeline Sync“ není nainstalována. Sdílené úložiště není k dispozici.', 'editor.clear_store': 'Vymazat úložiště', 'editor.clear_store_confirm': 'Tímto smažete všechny uložené rozvrhy. Pokračovat?', 'editor.migrate_to_store': 'Převést data do úložiště', 'editor.migrate_confirm': 'Převést vaše rozvrhy uložené v prohlížeči do zvoleného senzoru? Tím se přepíší existující rozvrhy v úložišti.', 'editor.migrate_pull_confirm': 'Importovat rozvrhy z úložiště a přepsat lokální data v prohlížeči?', 'editor.select_storage_first': 'Nejprve vyberte senzor úložiště.', 'editor.no_local_data': 'Nebyla nalezena žádná lokální data v prohlížeči.', 'editor.clear_all': 'Smazat všechna data', 'editor.clear_all_confirm': 'Tímto smažete rozvrhy jak v úložišti, tak v lokálním prohlížeči. Pokračovat?', 'editor.clear_storage_only': 'Smazat jen data v úložišti', 'editor.clear_storage_only_confirm': 'Tímto smažete rozvrhy z vybraného senzoru úložiště. Pokračovat?', 'editor.clear_local_only': 'Smazat jen lokální data', 'editor.clear_local_only_confirm': 'Tímto smažete pouze rozvrhy uložené ve vašem prohlížeči. Pokračovat?', 'editor.reset.desc': 'Co to dělá:\n\n• Smazat všechna data: Resetuje backend úložiště (.storage) pro Thermostat Timeline a smaže VŠECHNY lokální cache/návrhy v tomto prohlížeči.\n• Smazat jen lokální data: Smaže pouze data uložená v tomto prohlížeči (localStorage).\n\nTip: Po plném resetu stránku obnovte, aby se znovu neobjevily staré návrhy.', 'editor.temp_sensor': 'Teplotní senzor', // Týdny 'week.enable': 'Povolit pracovní dny', 'week.mode': 'Režim dní v týdnu', // removed same_all option from UI 'week.mode.weekday_weekend': 'Pracovní dny + Víkend', 'week.mode.weekday_sat_sun': 'Pracovní dny + Sobota + Neděle', 'week.mode.all_7': 'Všechny dny', 'week.button': 'Dny v týdnu', 'week.group.weekdays': 'Pracovní dny', 'week.group.weekend': 'Víkend', 'week.days_full.mon': 'Pondělí', 'week.days_full.tue': 'Úterý', 'week.days_full.wed': 'Středa', 'week.days_full.thu': 'Čtvrtek', 'week.days_full.fri': 'Pátek', 'week.days_full.sat': 'Sobota', 'week.days_full.sun': 'Neděle', 'week.days.mon': 'Po', 'week.days.tue': 'Út', 'week.days.wed': 'St', 'week.days.thu': 'Čt', 'week.days.fri': 'Pá', 'week.days.sat': 'So', 'week.days.sun': 'Ne', 'week.editor.title': 'Týdenní rozvrh', 'week.editor.preview_note': 'Pouze náhled. Změny se použijí po stisknutí Uložit.', 'week.open_editor.desc': 'Otevřít editor dnů v týdnu.', 'week.open_editor.btn': 'Otevřít', 'week.copy_to_rooms': 'Kopírovat týden do místností', 'week.copy_to_rooms.title': 'Kopírovat týden do místností', 'week.copy_to_rooms.select': 'Vyberte místnosti', 'week.copy_to_rooms.apply': 'Vložit do místností', 'week.enable.desc': 'Povolit plán podle dní v týdnu. Když je vypnuto, používá se stejný plán každý den.', // Formáty 'editor.timefmt.title': 'Formát času', 'editor.timefmt.desc': 'Zvolte 24hodinový nebo 12hodinový (AM/PM) formát pro časy na kartě.', 'editor.tempfmt.title': 'Jednotka teploty', 'editor.tempfmt.desc': 'Zvolte Celsia (°C) nebo Fahrenheit (°F) pro zobrazení na kartě a v editorech.', 'editor.timesrc.title': 'Zdroj času', 'editor.timesrc.desc': 'Zvolte, zda má karta používat čas prohlížeče nebo časové pásmo Home Assistantu pro „nyní“.', 'editor.timesrc.browser': 'Prohlížeč', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'ID konfigurace', 'editor.instance.desc': 'Udržujte více nezávislých plánů/nastavení pod různými ID.', 'editor.instance.new_id': 'Nové ID', // Záložky 'editor.tabs.settings': 'Nastavení', 'editor.tabs.pause': 'Pauza', 'editor.tabs.rooms': 'Místnosti', 'editor.tabs.boiler': 'Ovládání kotle', 'boiler.switch': 'Spínač kotle', 'boiler.switch_type': 'Typ entity', 'boiler.switch_type.switch': 'Spínač', 'boiler.switch_type.input_boolean': 'Vstupní boolean', 'boiler.rooms': 'Zahrnuté místnosti', 'boiler.rooms.desc': 'Vyberte, které místnosti mají být zahrnuty do ovládání kotle.', 'boiler.multi_enable': 'Povolit více kotlů', 'boiler.multi_enable.desc': 'Přiřaďte místnosti různým kotlům a nastavte odchylky pro každou místnost.', 'boiler.multi_rooms': 'Kotel pro místnost', 'boiler.multi_rooms.desc': 'Vyberte spínač kotle a odchylky pro každou místnost.', 'boiler.room.enable': 'Zahrnout místnost', 'boiler.room.enable.desc': 'Zahrnout tuto místnost do řízení kotle.', 'editor.tabs.presence_sensor': 'Senzor přítomnosti', 'boiler.enable': 'Povolit ovládání kotle', 'boiler.enable.desc': 'Zobrazit nastavení kotle v editoru této karty.', 'boiler.temp_sensor': 'Teplotní senzor kotle', 'boiler.temp_sensor.desc': 'Teplotní senzor používaný k ovládání spínače kotle.', 'editor.presence_sensor.enable.title': 'Senzor přítomnosti', 'editor.presence_sensor.enable.desc': 'Povolit konfiguraci senzoru přítomnosti pro jednotlivé místnosti.', 'editor.presence_sensor.entity.title': 'Entita přítomnosti', 'editor.presence_sensor.entity.desc': 'Vyberte binary_sensor, který indikuje přítomnost v této místnosti.', 'editor.presence_sensor.on_temp.title': 'Teplota při přítomnosti (ZAP)', 'editor.presence_sensor.on_temp.desc': 'Pokud je senzor ZAP, tato místnost použije tuto teplotu místo rozvrhu.', 'editor.presence_sensor.on_delay.title': 'Zpoždění před použitím přítomnosti', 'editor.presence_sensor.on_delay.desc': 'Jak dlouho musí být senzor ZAP, než se použije teplota přítomnosti. Hodnota používá vybranou jednotku.', 'editor.presence_sensor.off_delay.title': 'Zpoždění před návratem na rozvrh', 'editor.presence_sensor.off_delay.desc': 'Jak dlouho musí být senzor VYP, než se obnoví rozvrh. Hodnota používá vybranou jednotku.', 'boiler.min_temp': 'Min. teplota kotle (°C)', 'boiler.max_temp': 'Max. teplota kotle (°C)', 'boiler.offsets': 'Offsety', 'boiler.offsets.desc': 'ON offset: o kolik pod plánovanou teplotu před zapnutím. OFF offset: o kolik nad plánovanou teplotu před vypnutím.', 'boiler.on_offset': 'ON offset (°C)', 'boiler.off_offset': 'OFF offset (°C)', 'editor.test_tool': 'Testovací nástroj', 'editor.test_select_entity': 'Vybrat entitu', 'editor.test_empty': 'Vyberte entitu pro zobrazení dostupných příkazů…', 'editor.test_export': 'Exportovat .md', 'editor.test_no_fields': 'Žádná pole', 'editor.test_select_placeholder': '— Vybrat —', 'editor.error_prefix': 'Chyba: ', 'editor.open_in_hacs': 'Otevřít v HACS', 'editor.tabs.colors': 'Barvy', 'editor.tabs.owd': 'Open Window Detection', 'owd.enable': 'Open Window Detection', 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.', 'owd.sensors': 'Window/Door sensors', 'owd.delays.title': 'Delays', 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).', 'owd.open_delay': 'After open (min)', 'owd.close_delay': 'After closed (min)', 'editor.tabs.away': 'Mimo domov', 'editor.tabs.reset': 'Obnovit', 'editor.tabs.sync': 'Sync engine', 'sync.running': 'Probíhá synchronizace…', 'sync.completed': 'Synchronizace dokončena', 'editor.tabs.backup': 'Záloha', 'backup.title': 'Záloha', 'backup.desc': 'Zkopíruje obsah vašeho sync senzoru do záložního senzoru a v případě potřeby obnoví.', 'backup.now': 'Zálohovat nyní', 'backup.restore': 'Obnovit', 'backup.delete': 'Smazat zálohu', 'backup.delete.confirm': 'Smazat vybranou zálohu?', 'backup.new': 'Nová záloha', 'backup.auto': 'Automatická záloha', 'backup.auto.desc': 'Provádět automatické zálohy v intervalech.', 'backup.interval': 'Interval (dny)', 'backup.interval.desc': 'Počet dnů mezi automatickými zálohami (min. 1).', 'backup.sensor': 'Záložní senzor', 'backup.last': 'Poslední záloha', 'backup.export.title': 'Exportovat soubor zálohy', 'backup.export.desc': 'Stáhnout kompletní thermostat_timeline_backup.json se všemi sloty a daty.', 'backup.export.btn': 'Stáhnout soubor', 'backup.file.add_selected': 'Importovat zálohu', 'backup.file.add_all': 'Importovat všechny zálohy', 'backup.file.loaded_n': 'Soubor načten ({n} záloh)', 'backup.file.imported_ok': 'Importováno ({n})', 'backup.slot.title': 'Vybrat zálohu', 'backup.slot.desc': 'Vyberte, kterou zálohu obnovit.', 'backup.slot.empty': 'Zatím žádné zálohy', // Away 'away.enable': 'Povolit režim mimo domov', 'away.desc': 'Když žádní z vybraných lidí nejsou doma, automaticky snižuje teploty na nastavenou hodnotu.', 'away.temp': 'Teplota mimo domov', 'away.persons': 'Osoby', 'away.add_person': 'Přidat osobu', // Barvy tepla 'editor.heat_colors.title': 'Barvy tepelných bloků', 'editor.heat_colors.add': 'Přidat rozsah barev', 'editor.colors.col_from': 'Od °C', 'editor.colors.col_to': 'Do °C', 'editor.colors.col_color': 'Barva', 'editor.colors.empty': 'Zatím nebyly přidány žádné místnosti. Přidejte místnost pro definici barev.', 'editor.colors.go_add': 'Přidat místnost', 'editor.select_entities': 'Místnosti', 'editor.colors.refresh': 'Aktualizovat rozvrh', 'editor.colors.clear_local': 'Smazat lokální barevná data', 'editor.colors.clear_local_confirm': 'Tímto smažete barevné rozsahy uložené ve vašem prohlížeči pro tuto kartu. Pokračovat?', 'editor.colors.clear_shared': 'Smazat sdílené barevné úložiště', 'editor.colors.clear_shared_confirm': 'Tímto smažete barevné rozsahy ve vybraném senzoru úložiště. Pokračovat?', 'editor.colors.clear_all': 'Smazat všechna barevná data', 'editor.colors.clear_all_confirm': 'Tímto smažete všechny barevné rozsahy jak lokálně, tak ve vybraném senzoru úložiště. Pokračovat?', 'editor.colors.mode': 'Režim barev', 'editor.colors.mode.desc': 'Zvolte barvy pro každou místnost nebo jeden set pro všechny.', 'editor.colors.mode_per_room': 'Podle místnosti', 'editor.colors.mode_global': 'Všechny místnosti', 'editor.colors.global_title': 'Všechny místnosti', 'editor.reload_editor': 'Znovu načíst editor', 'editor.reload_dashboard': 'Znovu načíst dashboard', // Onboarding 'onboard.title': 'Vítejte v Časové ose termostatu', 'onboard.next': 'Další', 'onboard.prev': 'Zpět', 'onboard.skip': 'Přeskočit', 'onboard.done': 'Hotovo', 'onboard.dont_show': 'Nezobrazovat znovu', 'onboard.step_main': 'Toto je hlavní časová osa. Zobrazuje požadované teploty během 24 hodin pro každou místnost.', 'onboard.step_add': 'Přidejte tepelný blok přes + Přidat blok nebo dvojitým kliknutím na časovou osu.', 'onboard.step_edit': 'Upravte nebo smažte blok dvojitým kliknutím. Použijte Dny v týdnu pro různé dny.', 'onboard.reset': 'Zobrazit průvodce znovu', 'onboard.reset_ok': 'Vítání se zobrazí příště.', // Pause 'pause.menu_title': 'Pozastavit auto‑apply', 'pause.off_1h': 'Pozastavit na 1 hodinu', 'pause.off_5h': 'Pozastavit na 5 hodin', 'pause.off_custom': 'Vlastní délka…', 'pause.off_indef': 'Pozastavit do obnovení', 'pause.resume': 'Obnovit nyní', 'pause.close': 'Zavřít', 'pause.active_until': 'Pozastaveno do {time}', 'pause.active_indef': 'Pozastaveno', 'editor.pausebtn.title': 'Tlačítko pozastavení', 'editor.pausebtn.desc': 'Zobrazí globální tlačítko pozastavení v záhlaví karty. Během pauzy jsou všechny příkazy set_temperature potlačeny.', 'editor.integration.connected': 'Integrace připravena', 'editor.integration.disconnected': 'Integrace není k dispozici', // Profily 'profiles.enable': 'Povolit profily (přepis)', 'profiles.enable.desc': 'Povolit pojmenované denní rozvrhy, které můžete aktivovat pro přepsání hlavního plánu.', 'season.enable.title': 'Povolit letní/zimní rozvrhy', 'season.enable.desc': 'Když je povoleno, každý rozvrh má verzi Léto a Zima, mezi kterými můžete přepínat.', 'season.mode.title': 'Sezóna', 'season.mode.winter': 'Zima', 'season.mode.summer': 'Léto', 'profiles.button': 'Manuální plán', 'profiles.editor.title': 'Profily', 'profiles.add': 'Přidat profil', 'profiles.rename': 'Přejmenovat', 'profiles.delete': 'Smazat', 'profiles.activate': 'Aktivovat', 'profiles.deactivate': 'Deaktivovat', 'profiles.active': 'Aktivní', 'profiles.none': 'Žádný', 'profiles.name_placeholder': 'Název profilu', 'profiles.edit': 'Upravit', 'profiles.preview_note': 'Pouze náhled. Rozvrhy se stanou aktivními po aktivaci profilu.', 'profiles.active_label': 'Manuální profil aktivní:', 'profiles.save_new': 'Uložit nový profil', 'profiles.save_existing': 'Uložit změny do vybraného profilu', // Svátky 'editor.tabs.holidays': 'Svátky', 'holidays.enable': 'Povolit svátky', 'holidays.desc': 'Použít speciální denní rozvrh o svátcích.', 'holidays.source': 'Zdroj svátků', 'holidays.source.calendar': 'Kalendář', 'holidays.source.manual': 'Manuální data', 'holidays.entity': 'Entita svátků', 'holidays.entity_desc': 'Vyberte kalendář, který hlásí pouze on/off. Má být on ve svátky a off v ostatní dny.', 'holidays.dates': 'Data svátků', 'holidays.add_date': 'Přidat datum', 'holidays.editor.title': 'Rozvrh o svátcích', 'holidays.edit': 'Upravit rozvrh o svátcích', 'holidays.button': 'Svátky', 'editor.perroom.title': 'Výchozí °C pro místnost', 'editor.perroom.desc': 'Zobrazit pole výchozí °C v každém řádku. Při vypnutí se použije globální výchozí °C pro všechny místnosti.', 'away.resume_schedule': 'Obnovit plán', 'away.resume_away': 'Obnovit teplotu mimo domov', 'presence.button': 'Plány přítomnosti', 'presence.editor.title': 'Plány přítomnosti', 'presence.enable_advanced': 'Povolit pokročilé rozvrhy přítomnosti', 'presence.enable_advanced.desc': 'Použijte kombinace a rozvrhy podle toho, kdo je doma/pryč.', 'away.delay_enable': 'Zpozdit akce Nepřítomnosti', 'away.delay_desc': 'Před použitím změn nepřítomnosti vyčkejte.', 'away.delay_value': 'Zpoždění', 'away.delay_unit': 'Jednotka', 'away.delay_unit.seconds': 'Sekundy', 'away.delay_unit.minutes': 'Minuty', 'presence.combos': 'Kombinace', 'presence.enable_combo': 'Povolit tuto kombinaci', 'presence.all_home': 'Všichni doma', 'presence.none_home': 'Nikdo doma', 'presence.home': 'Doma: {names}', 'presence.away': 'Mimo domov: {names}', 'presence.manage': 'Spravovat kombinace', 'presence.who_home': 'Kdo je doma', 'presence.who_away': 'Kdo není doma' , 'backup.select': 'Vyberte, co zálohovat' , 'backup.select.desc': 'Zaškrtnuté položky jsou zahrnuty do zálohy/obnovy' , 'backup.opt.all': 'Vše' , 'backup.opt.main': 'Hlavní rozvrhy' , 'backup.opt.weekday': 'Rozvrhy podle dní' , 'backup.opt.presence': 'Plány přítomnosti' , 'backup.opt.settings': 'Nastavení editoru' , 'backup.opt.holiday': 'Sváteční rozvrhy' , 'backup.opt.colors': 'Barevné bloky' , 'editor.tabs.weekdays': 'Dny v týdnu' , 'presence.live_header': 'Zobrazit živou přítomnost v záhlaví' , 'presence.live_header.desc': 'Štítky jsou zobrazeny v pravém rohu.' , 'week.view.title': 'Pohled na týden' , 'week.view.rooms_one_day': 'Všechny místnosti • jeden den' , 'week.view.days_one_room': 'Všechny dny • jedna místnost' , 'week.view_switch.title': 'Přepnout zobrazení z časové osy' , 'week.view_switch.desc': 'Zobrazí přepínač v záhlaví časové osy pro přepínání mezi dvěma režimy zobrazení dnů v týdnu.' , 'week.copy_from_main.title': 'Kopírovat hlavní rozvrh' , 'week.copy_from_main.desc': 'Zapnuli jste dny v týdnu. Chcete zkopírovat hlavní rozvrh do dnů v týdnu? Vyberte režim, do kterého se kopíruje.' , 'week.copy_from_main.mode_label': 'Režim dnů v týdnu' , 'week.copy_from_main.target_label': 'Kopírovat do' , 'week.copy_from_main.copy_btn': 'Kopírovat' , 'week.copy_from_main.skip_btn': 'Nekopírovat' , 'week.title': 'Dny v týdnu' , 'week.select_room': 'Vyberte místnost' , 'err.config_entities': "Konfigurace musí obsahovat 'entities: [climate.xxx, ...]'." , 'onboard.video_not_found': 'Video nebylo nalezeno' , 'onboard.image_not_found': 'Obrázek nebyl nalezen' , 'pause.prompt_minutes': 'Zadejte dobu trvání (minuty):' , 'editor.test_tool': 'Testovací nástroj' , 'editor.test_select_entity': 'Vyberte entitu' , 'editor.test_empty': 'Nic k exportu' , 'editor.test_export': 'Exportovat' , 'editor.test_no_fields': 'Žádná pole k zobrazení' , 'editor.test_select_placeholder': 'Vybrat...' , 'editor.error_prefix': 'Chyba: ' , 'backup.restore.confirm_overwrite': 'Obnovit ze zálohy? Tím se přepíší aktuální plány a nastavení.' , 'backup.import.err_no_hass': 'Home Assistant není připraven' , 'backup.import.err_storage_off': 'Sdílené úložiště je VYPNUTÉ. Nejprve jej zapněte.' , 'backup.import.err_no_integration': 'Integrace thermostat_timeline není dostupná' , 'backup.import.err_json': 'Neplatný JSON' , 'profiles.name_exists': 'Název již existuje' }, sl: { 'card.title_default': 'Časovnica termostata', 'ui.edit_block': 'Uredi blok', 'ui.add_block': 'Dodaj blok', 'ui.from': 'Od', 'ui.to': 'Do', 'ui.temperature': 'Temperatura', 'ui.delete': 'Izbriši', 'ui.cancel': 'Prekliči', 'ui.close': 'Zapri', 'ui.unsaved_confirm': 'Imate neshranjene spremembe. Želite vseeno zapreti?', 'ui.discard_changes': 'Zavrzi spremembe', 'ui.keep_editing': 'Nadaljuj z urejanjem', 'ui.save': 'Shrani', 'ui.error_prefix': 'Napaka: ', 'ui.overlap_msg': 'Izbrali ste prekrivajoče se čase — prekrivanje med {start} in {end}.', 'ui.auto_fix': 'Samodejno popravi', 'ui.fix_start_to': 'Nastavi začetek na {time}', 'ui.fix_end_to': 'Nastavi konec na {time}', // Inline meta 'ui.current_temp': 'Trenutna temperatura:', 'editor.title_label': 'Naslov', 'editor.add_entity': 'Dodaj prostor', 'editor.entity_placeholder': 'Izberite entiteto', 'editor.room_mode.title': 'Vrsta entitete prostora', 'editor.room_mode.desc': 'Ko je omogočeno, izberite input_number (ciljna temperatura). Ko je onemogočeno, izberite climate entiteto.', 'editor.room_temp_sensor_override.title': 'Uporabi tipalo temperature prostora', 'editor.room_temp_sensor_override.desc': 'Ko je omogočeno, prikazana temperatura prostora (in logika kotla) uporablja izbrano tipalo temperature namesto climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Tipalo temperature', 'editor.drag_reorder': 'Povleci za spremembo vrstnega reda', 'editor.remove': 'Odstrani', 'editor.default_c': 'Privzeta °C', 'editor.perroom.title': 'Privzeta °C po prostoru', 'editor.perroom.desc': 'Prikaže polje Privzeta °C v vsaki vrstici. Ko je izklopljeno, se globalna Privzeta °C uporabi za vse prostore.', 'editor.row_height_px': 'Višina vrstice (px)', 'editor.max_c': 'Največ °C', 'editor.min_c': 'Najmanj °C', 'editor.auto_apply.title': 'Samodejna uporaba', 'editor.auto_apply.desc': 'Ko je omogočeno, kartica poskuša nastaviti temperaturo na izbranih climate entitetah tako, da ustreza načrtovani vrednosti za "trenutno".', 'editor.apply_edit.title': 'Ob urejanju', 'editor.apply_edit.desc': 'Ko spremenite bloke na časovnici in sprememba vpliva na trenutni čas, se nova temperatura uporabi takoj.', 'editor.apply_default.title': 'Ob spremembi privzete °C', 'editor.apply_default.desc': 'Ko se "Privzeta °C" spremeni in to vpliva na trenutno obdobje, se nova temperatura uporabi takoj.', 'editor.show_room_temp.title': 'Prikaži temperaturo prostora', 'editor.show_room_temp.desc': 'Prikaže mehurček s trenutno temperaturo prostora na vsaki vrstici v glavi časovnice.', 'ui.copy_plan': 'Kopiraj načrt', 'ui.paste_plan': 'Prilepi načrt', 'ui.copy_day': 'Kopiraj dan', 'ui.paste_day': 'Prilepi dan', 'editor.merge_label': 'Združi z (dodaj dodatni termostat)', 'editor.display_name_optional': 'Ime prostora (neobvezno)', 'editor.merged_with': 'Združeno z', 'editor.store_enable.title': 'Deljena shramba + ozadni nadzor', 'editor.store_enable.short': 'Shrani urnike v izbrani senzor in omogoči nadzor v ozadju.', 'editor.store_enable.desc': 'Shrani urnike v izbrani senzor in omogoči ozadnje ukaze set_temperature prek integracije Thermostat Timeline. Termostati se posodobijo tudi, ko kartica ni odprta. Prav tako deli urnike (toplotne bloke) in nastavitve kartice med uporabniki/napravami. Ko je izklopljeno, se ukazi pošiljajo le, ko je kartica vidna na napravi (tablica/PC/mobilnik) in mora ostati odprta.', 'editor.store_missing': 'Integracija "Thermostat Pro Timeline Sync" ni nameščena. Deljena shramba ni na voljo.', 'editor.clear_store': 'Počisti shrambo', 'editor.clear_store_confirm': 'To bo izbrisalo vse shranjene urnike. Nadaljujem?', 'editor.migrate_to_store': 'Prenesi podatke v shrambo', 'editor.migrate_confirm': 'Prenesem urnike, shranjene v brskalniku, v izbrani senzor? To bo prepisalo obstoječe urnike v shrambi.', 'editor.migrate_pull_confirm': 'Uvozim urnike iz shrambe in prepišem lokalne podatke v brskalniku?', 'editor.select_storage_first': 'Najprej izberite senzor shrambe.', 'editor.no_local_data': 'Lokalnih podatkov v brskalniku ni.', 'editor.clear_all': 'Počisti vse podatke', 'editor.clear_all_confirm': 'To bo izbrisalo urnike iz shrambe in lokalnega brskalnika. Nadaljujem?', 'editor.clear_storage_only': 'Počisti samo shrambo', 'editor.clear_storage_only_confirm': 'To bo izbrisalo urnike iz izbranega senzora shrambe. Nadaljujem?', 'editor.clear_local_only': 'Počisti samo lokalne podatke', 'editor.clear_local_only_confirm': 'To bo izbrisalo le urnike, shranjene v vašem brskalniku. Nadaljujem?', 'editor.reset.desc': 'Kaj to naredi:\n\n• Počisti vse podatke: Ponastavi backend shrambo (.storage) za Thermostat Timeline in izbriše VSE lokalne predpomnilnike/osnutke v tem brskalniku.\n• Počisti samo lokalne podatke: Počisti samo podatke, shranjene v tem brskalniku (localStorage).\n\nNamig: Po popolni ponastavitvi osveži stran, da se stari osnutki ne pojavijo znova.', 'editor.temp_sensor': 'Senzor temperature', 'editor.test_tool': 'Testno orodje', 'editor.test_select_entity': 'Izberite entiteto', 'editor.test_empty': 'Izberite entiteto za prikaz razpoložljivih ukazov…', 'editor.test_export': 'Izvozi .md', 'editor.test_no_fields': 'Ni polj', 'editor.test_select_placeholder': '— Izberi —', 'editor.error_prefix': 'Napaka: ', 'editor.open_in_hacs': 'Odpri v HACS' , 'week.enable': 'Omogoči dneve v tednu' , 'week.mode': 'Način tedna' , 'week.mode.weekday_weekend': 'Delavnik + Vikend' , 'week.mode.weekday_sat_sun': 'Delavnik + Sobota + Nedelja' , 'week.mode.all_7': 'Vsi dnevi' , 'week.button': 'Dnevi v tednu' , 'week.group.weekdays': 'Delavniki' , 'week.group.weekend': 'Vikend' , 'week.days_full.mon': 'Ponedeljek' , 'week.days_full.tue': 'Torek' , 'week.days_full.wed': 'Sreda' , 'week.days_full.thu': 'Četrtek' , 'week.days_full.fri': 'Petek' , 'week.days_full.sat': 'Sobota' , 'week.days_full.sun': 'Nedelja' , 'week.days.mon': 'Pon' , 'week.days.tue': 'Tor' , 'week.days.wed': 'Sre' , 'week.days.thu': 'Čet' , 'week.days.fri': 'Pet' , 'week.days.sat': 'Sob' , 'week.days.sun': 'Ned' , 'week.editor.title': 'Urnik po dnevih' , 'week.editor.preview_note': 'Samo predogled. Spremembe se uporabijo, ko pritisnete Shrani.' , 'week.open_editor.desc': 'Odpri urejevalnik urnika po dnevih.' , 'week.open_editor.btn': 'Odpri' , 'week.view.title': 'Pogled tedna' , 'week.view.rooms_one_day': 'Vsi prostori • en dan' , 'week.view.days_one_room': 'Vsi dnevi • en prostor' , 'week.view_switch.title': 'Preklopi pogled na časovnici' , 'week.view_switch.desc': 'Prikaže preklopnik v glavi časovnice za preklapljanje med obema načinoma pogleda za dneve v tednu.' , 'week.copy_from_main.title': 'Kopiraj glavni urnik' , 'week.copy_from_main.desc': 'Omogočili ste dneve v tednu. Želite kopirati glavni urnik v dneve v tednu? Izberite način, v katerega se kopira.' , 'week.copy_from_main.mode_label': 'Način dni v tednu' , 'week.copy_from_main.target_label': 'Kopiraj v' , 'week.copy_from_main.copy_btn': 'Kopiraj' , 'week.copy_from_main.skip_btn': 'Ne kopiraj' , 'week.title': 'Dnevi v tednu' , 'week.select_room': 'Izberi prostor' , 'week.copy_to_rooms': 'Kopiraj teden v prostore' , 'week.copy_to_rooms.title': 'Kopiraj teden v prostore' , 'week.copy_to_rooms.select': 'Izberi prostore' , 'week.copy_to_rooms.apply': 'Vstavi v prostore' , 'week.enable.desc': 'Omogoči načrtovanje po posameznem dnevu. Ko je izklopljeno, se vsak dan uporabi isti načrt.' , 'editor.timefmt.title': 'Oblika časa' , 'editor.timefmt.desc': 'Izberite 24-urni ali 12-urni (AM/PM) prikaz časov na kartici.' , 'editor.tempfmt.title': 'Enota temperature' , 'editor.tempfmt.desc': 'Izberite Celsius (°C) ali Fahrenheit (°F) za prikaz na kartici in v urejevalnikih.' , 'editor.timesrc.title': 'Vir časa' , 'editor.timesrc.desc': 'Izberite, ali kartica za "zdaj" uporablja čas brskalnika ali časovni pas Home Assistanta.' , 'editor.timesrc.browser': 'Brskalnik' , 'editor.timesrc.ha': 'Home Assistant' , 'editor.instance.title': 'ID konfiguracije' , 'editor.instance.desc': 'Ohranite več neodvisnih urnikov/nastavitev pod različnimi ID-ji.' , 'editor.instance.new_id': 'Nov ID' , 'editor.tabs.settings': 'Nastavitve' , 'editor.tabs.pause': 'Premor' , 'editor.tabs.weekdays': 'Dnevi v tednu' , 'editor.tabs.rooms': 'Prostori' , 'editor.tabs.colors': 'Barve' , 'editor.tabs.presence_sensor': 'Senzor prisotnosti' , 'editor.presence_sensor.enable.title': 'Senzor prisotnosti' , 'editor.presence_sensor.enable.desc': 'Omogoči nastavitev senzorja prisotnosti po posameznih prostorih.' , 'editor.presence_sensor.entity.title': 'Entiteta prisotnosti' , 'editor.presence_sensor.entity.desc': 'Izberi binary_sensor, ki označuje prisotnost v tem prostoru.' , 'editor.presence_sensor.on_temp.title': 'Temperatura, ko je prisotnost VKLOPLJENA' , 'editor.presence_sensor.on_temp.desc': 'Če je senzor VKLOPLJEN, ta soba uporablja to temperaturo namesto urnika.' , 'editor.presence_sensor.on_delay.title': 'Zamik pred uporabo prisotnosti' , 'editor.presence_sensor.on_delay.desc': 'Kako dolgo mora biti senzor VKLOPLJEN, preden se uporabi temperatura prisotnosti. Vrednost uporablja izbrano enoto.' , 'editor.presence_sensor.off_delay.title': 'Zamik pred nadaljevanjem urnika' , 'editor.presence_sensor.off_delay.desc': 'Kako dolgo mora biti senzor IZKLOPLJEN, preden se urnik nadaljuje. Vrednost uporablja izbrano enoto.' , 'editor.tabs.owd': 'Open Window Detection' , 'owd.enable': 'Open Window Detection' , 'owd.enable.desc': 'Turn off thermostats in a room when selected window/door sensors are open.' , 'owd.sensors': 'Window/Door sensors' , 'owd.delays.title': 'Delays' , 'owd.delays.desc': 'Minutes to wait before turning off (open) and resuming schedule (closed).' , 'owd.open_delay': 'After open (min)' , 'owd.close_delay': 'After closed (min)' , 'editor.tabs.away': 'Odsotnost' , 'editor.tabs.reset': 'Ponastavi' , 'editor.tabs.boiler': 'Krmiljenje kotla' , 'boiler.switch': 'Stikalo kotla' , 'boiler.switch_type': 'Vrsta entitete' , 'boiler.switch_type.switch': 'Stikalo' , 'boiler.switch_type.input_boolean': 'Vhodni boolean' , 'boiler.rooms': 'Vključeni prostori' , 'boiler.rooms.desc': 'Izberite, kateri prostori naj bodo vključeni v krmiljenje kotla.' , 'boiler.multi_enable': 'Omogoči več kotlov' , 'boiler.multi_enable.desc': 'Dodeli sobe različnim kotlom in nastavi odmike za posamezno sobo.' , 'boiler.multi_rooms': 'Kotel na sobo' , 'boiler.multi_rooms.desc': 'Izberi stikalo kotla in odmike za vsako sobo.' , 'boiler.room.enable': 'Vključi sobo' , 'boiler.room.enable.desc': 'Vključi to sobo v krmiljenje kotla.' , 'boiler.enable': 'Omogoči krmiljenje kotla' , 'boiler.enable.desc': 'Prikaži nastavitve kotla v urejevalniku te kartice.' , 'boiler.temp_sensor': 'Temperaturni senzor kotla' , 'boiler.temp_sensor.desc': 'Temperaturni senzor za krmiljenje stikala kotla.' , 'boiler.min_temp': 'Min. temperatura kotla (°C)' , 'boiler.max_temp': 'Maks. temperatura kotla (°C)' , 'boiler.offsets': 'Odmiki' , 'boiler.offsets.desc': 'ON odmik: kako daleč pod načrtovanim ciljem, preden se vklopi. OFF odmik: kako daleč nad načrtovanim ciljem, preden se izklopi.' , 'boiler.on_offset': 'ON odmik (°C)' , 'boiler.off_offset': 'OFF odmik (°C)' , 'editor.tabs.sync': 'Sinhronizacijski pogon' , 'sync.running': 'Sinhronizacija poteka…' , 'sync.completed': 'Sinhronizacija končana' , 'away.enable': 'Omogoči način odsotnosti' , 'away.desc': 'Ko nihče iz izbranih oseb ni doma, termostati samodejno spustijo na nastavljeno temperaturo.' , 'away.temp': 'Temperatura odsotnosti' , 'away.persons': 'Osebe' , 'away.add_person': 'Dodaj osebo' , 'away.resume_schedule': 'Nadaljuj urnik' , 'away.resume_away': 'Nadaljuj temperaturo odsotnosti' , 'editor.heat_colors.title': 'Barve toplotnih blokov' , 'editor.heat_colors.add': 'Dodaj barvno območje' , 'editor.colors.col_from': 'Od °C' , 'editor.colors.col_to': 'Do °C' , 'editor.colors.col_color': 'Barva' , 'editor.colors.empty': 'Ni še dodanih prostorov. Dodajte prostor za določitev barv.' , 'editor.colors.go_add': 'Dodaj prostor' , 'editor.select_entities': 'Prostori' , 'editor.colors.refresh': 'Osveži urnik' , 'editor.colors.clear_local': 'Počisti lokalne barvne podatke' , 'editor.colors.clear_local_confirm': 'To bo izbrisalo barvna območja, shranjena v vašem brskalniku za to kartico. Nadaljujem?' , 'editor.colors.clear_shared': 'Počisti deljeno barvno shrambo' , 'editor.colors.clear_shared_confirm': 'To bo izbrisalo barvna območja v izbranem senzoru shrambe. Nadaljujem?' , 'editor.colors.clear_all': 'Počisti vse barvne podatke' , 'editor.colors.clear_all_confirm': 'To bo izbrisalo vsa barvna območja iz lokalne in deljene shrambe. Nadaljujem?' , 'editor.colors.mode': 'Način barv' , 'editor.colors.mode.desc': 'Izberite barve po prostorih ali en nabor za vse prostore.' , 'editor.colors.mode_per_room': 'Po prostorih' , 'editor.colors.mode_global': 'Vsi prostori' , 'editor.colors.global_title': 'Vsi prostori' , 'editor.reload_editor': 'Ponovno naloži urejevalnik' , 'editor.reload_dashboard': 'Ponovno naloži nadzorno ploščo' , 'onboard.title': 'Dobrodošli v Thermostat Timeline' , 'onboard.next': 'Naprej' , 'onboard.prev': 'Nazaj' , 'onboard.skip': 'Preskoči' , 'onboard.done': 'Končano' , 'onboard.dont_show': 'Ne pokaži več' , 'onboard.step_main': 'To je glavna časovnica. Prikazuje želene temperature za 24 ur v vsakem prostoru.' , 'onboard.step_add': 'Toplotni blok dodate z + Dodaj blok ali z dvojnim klikom na časovnico.' , 'onboard.step_edit': 'Blok uredite ali izbrišete z dvojnim klikom. Uporabite Dneve v tednu za načrtovanje različnih dni.' , 'onboard.reset': 'Prikaži vodič znova' , 'onboard.reset_ok': 'Pozdrav se bo prikazal naslednjič.' , 'pause.menu_title': 'Premor samodejne uporabe' , 'pause.off_1h': 'Premor 1 ura' , 'pause.off_5h': 'Premor 5 ur' , 'pause.off_custom': 'Poljubno trajanje…' , 'pause.off_indef': 'Premor do nadaljnjega' , 'pause.resume': 'Nadaljuj zdaj' , 'pause.close': 'Zapri' , 'pause.active_until': 'V premoru do {time}' , 'pause.active_indef': 'V premoru' , 'editor.pausebtn.title': 'Gumb premora' , 'editor.pausebtn.desc': 'V glavi kartice prikaži globalni gumb premora. Med premorom so vsi ukazi set_temperature blokirani.' , 'editor.integration.connected': 'Integracija pripravljena' , 'editor.integration.disconnected': 'Integracija ni na voljo' , 'profiles.enable': 'Omogoči profile (prepis)' , 'profiles.enable.desc': 'Dovoli poimenovane dnevne urnike, ki lahko preglasijo glavni načrt.' , 'season.enable.title': 'Omogoči poletne/zimne urnike' , 'season.enable.desc': 'Ko je omogočeno, ima vsak urnik ločeni poletni in zimski različici, med katerimi lahko preklapljate.' , 'season.mode.title': 'Sezona' , 'season.mode.winter': 'Zima' , 'season.mode.summer': 'Poletje' , 'profiles.button': 'Ročni urnik' , 'profiles.editor.title': 'Profili' , 'profiles.add': 'Dodaj profil' , 'profiles.rename': 'Preimenuj' , 'profiles.delete': 'Izbriši' , 'profiles.activate': 'Aktiviraj' , 'profiles.deactivate': 'Deaktiviraj' , 'profiles.active': 'Aktiven' , 'profiles.none': 'Noben' , 'profiles.name_placeholder': 'Ime profila' , 'profiles.edit': 'Uredi' , 'profiles.preview_note': 'Samo predogled. Urniki postanejo aktivni, ko profil aktivirate.' , 'profiles.active_label': 'Ročni profil aktiven:' , 'profiles.save_new': 'Shrani nov profil' , 'profiles.save_existing': 'Shrani spremembe v izbran profil' , 'editor.tabs.holidays': 'Prazniki' , 'editor.tabs.backup': 'Varnostna kopija' , 'backup.title': 'Varnostna kopija' , 'backup.desc': 'Kopiraj urnike in nastavitve v varnostno kopijo ter jih po potrebi obnovi.' , 'backup.now': 'Ustvari kopijo zdaj' , 'backup.restore': 'Obnovi' , 'backup.delete': 'Izbriši varnostno kopijo' , 'backup.delete.confirm': 'Izbrišem izbrano varnostno kopijo?' , 'backup.new': 'Nova varnostna kopija' , 'backup.auto': 'Samodejna kopija' , 'backup.auto.desc': 'Samodejno ustvarjaj varnostne kopije po urniku.' , 'backup.interval': 'Interval (dni)' , 'backup.interval.desc': 'Število dni med samodejnimi kopijami (min. 1).' , 'backup.sensor': 'Senzor varnostne kopije' , 'backup.last': 'Zadnja kopija' , 'backup.export.title': 'Izvozi datoteko varnostne kopije' , 'backup.export.desc': 'Prenesi popoln thermostat_timeline_backup.json z vsemi mesti (slot) in podatki.' , 'backup.export.btn': 'Prenesi datoteko' , 'backup.file.add_selected': 'Uvozi varnostno kopijo' , 'backup.file.add_all': 'Uvozi vse varnostne kopije' , 'backup.file.loaded_n': 'Datoteka naložena ({n} varnostnih kopij)' , 'backup.file.imported_ok': 'Uvoženo ({n})' , 'backup.slot.title': 'Izberi varnostno kopijo' , 'backup.slot.desc': 'Izberite, kateri slot varnostne kopije želite obnoviti.' , 'backup.slot.empty': 'Še ni varnostnih kopij' , 'backup.select': 'Izberi, kaj kopirati' , 'backup.select.desc': 'Označene postavke so vključene v kopiranje/obnovo' , 'backup.opt.all': 'Vse' , 'backup.opt.main': 'Glavni urniki' , 'backup.opt.weekday': 'Urniki po dnevih' , 'backup.opt.presence': 'Urniki prisotnosti' , 'backup.opt.settings': 'Nastavitve urejevalnika' , 'backup.opt.holiday': 'Praznični urniki' , 'backup.opt.colors': 'Barvni bloki' , 'holidays.enable': 'Omogoči praznike' , 'holidays.desc': 'Uporabi poseben dnevni urnik ob praznikih.' , 'holidays.source': 'Vir praznikov' , 'holidays.source.calendar': 'Koledar' , 'holidays.source.manual': 'Ročni datumi' , 'holidays.entity': 'Entiteta za praznike' , 'holidays.entity_desc': 'Izberite koledar, ki poroča le vklop/izklop. Na praznike mora biti vklopljen, sicer izklopljen.' , 'holidays.dates': 'Datumi praznikov' , 'holidays.add_date': 'Dodaj datum' , 'holidays.editor.title': 'Praznični urnik' , 'holidays.edit': 'Uredi praznični urnik' , 'holidays.button': 'Prazniki' , 'presence.button': 'Urniki prisotnosti' , 'presence.editor.title': 'Urniki prisotnosti' , 'presence.enable_advanced': 'Omogoči napredne urnike prisotnosti' , 'presence.enable_advanced.desc': 'Uporabi kombinacije in urnike glede na to, kdo je doma/odsoten.' , 'away.delay_enable': 'Zakasni dejanja odsotnosti' , 'away.delay_desc': 'Počakaj preden uporabiš spremembe odsotnosti.' , 'away.delay_value': 'Zamik' , 'away.delay_unit': 'Enota' , 'away.delay_unit.seconds': 'Sekunde' , 'away.delay_unit.minutes': 'Minute' , 'presence.live_header': 'Prikaži živo prisotnost v glavi' , 'presence.live_header.desc': 'Oznake so prikazane v desnem kotu.' , 'presence.combos': 'Kombinacije' , 'presence.enable_combo': 'Omogoči to kombinacijo' , 'presence.all_home': 'Vsi doma' , 'presence.none_home': 'Nihče doma' , 'presence.home': 'Doma: {names}' , 'presence.away': 'Odsotni: {names}' , 'presence.manage': 'Upravljaj kombinacije' , 'presence.who_home': 'Kdo je doma' , 'presence.who_away': 'Kdo ni doma' , 'err.config_entities': "Konfiguracija mora imeti 'entities: [climate.xxx, ...]'." , 'onboard.video_not_found': 'Videoposnetka ni bilo mogoče najti' , 'onboard.image_not_found': 'Slike ni bilo mogoče najti' , 'pause.prompt_minutes': 'Vnesite trajanje (minute):' , 'editor.test_tool': 'Orodje za testiranje' , 'editor.test_select_entity': 'Izberite entiteto' , 'editor.test_empty': 'Ni ničesar za izvoz' , 'editor.test_export': 'Izvozi' , 'editor.test_no_fields': 'Ni polj za prikaz' , 'editor.test_select_placeholder': 'Izberite...' , 'editor.error_prefix': 'Napaka: ' , 'backup.restore.confirm_overwrite': 'Obnovi iz varnostne kopije? To bo prepisalo trenutne urnike in nastavitve.' , 'backup.import.err_no_hass': 'Home Assistant ni pripravljen' , 'backup.import.err_storage_off': 'Skupna shramba je IZKLOPLJENA. Najprej jo vklopite.' , 'backup.import.err_no_integration': 'Integracija thermostat_timeline ni na voljo' , 'backup.import.err_json': 'Neveljaven JSON' , 'profiles.name_exists': 'Ime že obstaja' }, ro: { 'card.title_default': 'Cronologia termostatului', 'err.config_entities': "Configurația trebuie să conțină 'entities: [climate.xxx, ...]'.", 'ui.edit_block': 'Editează blocul', 'ui.add_block': 'Adaugă bloc', 'ui.from': 'De la', 'ui.to': 'Până la', 'ui.temperature': 'Temperatură', 'ui.delete': 'Șterge', 'ui.cancel': 'Anulează', 'ui.close': 'Închide', 'ui.unsaved_confirm': 'Ai modificări nesalvate. Închizi oricum?', 'ui.discard_changes': 'Renunță la modificări', 'ui.keep_editing': 'Continuă editarea', 'ui.save': 'Salvează', 'ui.error_prefix': 'Eroare: ', 'ui.overlap_msg': 'Ai selectat intervale care se suprapun — suprapunerea este {start} – {end}.', 'ui.auto_fix': 'Remediere automată', 'ui.fix_start_to': 'Setează începutul la {time}', 'ui.fix_end_to': 'Setează sfârșitul la {time}', 'ui.current_temp': 'Temperatura curentă:', 'ui.copy_plan': 'Copiază planul', 'ui.paste_plan': 'Lipește planul', 'ui.copy_day': 'Copiază ziua', 'ui.paste_day': 'Lipește ziua', 'editor.title_label': 'Titlu', 'editor.add_entity': 'Adaugă cameră', 'editor.entity_placeholder': 'Selectează o entitate', 'editor.drag_reorder': 'Trage pentru reordonare', 'editor.remove': 'Elimină', 'editor.default_c': 'Implicit °C', 'editor.row_height_px': 'Înălțime rând (px)', 'editor.max_c': 'Max °C', 'editor.min_c': 'Min °C', 'editor.auto_apply.title': 'Aplicare automată', 'editor.auto_apply.desc': 'Când este activat, cardul încearcă să seteze temperatura pe entitățile climate selectate pentru a se potrivi cu valoarea programată pentru „acum”.', 'editor.apply_edit.title': 'La editare', 'editor.apply_edit.desc': 'Când editezi blocurile și asta afectează timpul curent, aplică imediat noua temperatură.', 'editor.apply_default.title': 'La schimbarea temperaturii implicite', 'editor.apply_default.desc': 'Când „Implicit °C” se schimbă și afectează perioada curentă, aplică imediat noua temperatură.', 'editor.perroom.title': 'Implicit °C per cameră', 'editor.perroom.desc': 'Afișează un câmp Implicit °C pe fiecare rând. Când este oprit, Implicit °C global este folosit pentru toate camerele.', 'editor.show_room_temp.title': 'Afișează temperatura camerei', 'editor.show_room_temp.desc': 'Afișează bula cu temperatura curentă a camerei pe fiecare rând în antetul cronologiei.', 'editor.room_mode.title': 'Tip entitate cameră', 'editor.room_mode.desc': 'Când este activat, selectează un input_number (temperatură țintă). Când este dezactivat, selectează o entitate climate.', 'editor.room_temp_sensor_override.title': 'Folosește senzorul de temperatură al camerei', 'editor.room_temp_sensor_override.desc': 'Când este activat, temperatura camerei afișată (și logica boilerului) folosește senzorul de temperatură selectat în loc de climate current_temperature.', 'editor.room_temp_sensor_override.entity': 'Senzor de temperatură', 'editor.turn_on.title': 'Trimite comanda turn_on', 'editor.turn_on.desc': 'Unele termostate necesită o comandă turn_on pe lângă set_temperature.', 'editor.turn_on.order': 'Ordine', 'editor.turn_on.order.before': 'Înainte de set_temperature', 'editor.turn_on.order.after': 'După set_temperature', 'editor.merge_label': 'Unește cu (adaugă termostat suplimentar)', 'editor.display_name_optional': 'Nume cameră (opțional)', 'editor.merged_with': 'Combinat cu', 'editor.store_enable.title': 'Stocare partajată + control în fundal', 'editor.store_enable.short': 'Stochează programele în senzorul selectat și activează controlul în fundal.', 'editor.store_enable.desc': 'Stochează programele în senzorul selectat și activează set_temperature în fundal. Termostatele sunt actualizate și când cardul este închis. Partajează programele și setările cardului între utilizatori/dispozitive. Când este oprit: comenzile sunt trimise doar cât timp cardul este vizibil pe un dispozitiv și trebuie să rămână deschis.', 'editor.store_missing': 'Integrarea „Thermostat Pro Timeline Sync” nu este instalată. Stocarea partajată nu este disponibilă.', 'editor.open_in_hacs': 'Deschide în HACS', 'editor.clear_store': 'Șterge stocarea', 'editor.clear_store_confirm': 'Aceasta va șterge toate programele stocate. Continui?', 'editor.migrate_to_store': 'Transferă datele în stocare', 'editor.migrate_confirm': 'Transferi programele salvate în browser către senzorul selectat? Aceasta va suprascrie programele existente în stocare.', 'editor.migrate_pull_confirm': 'Importi programele din stocare și suprascrii datele locale din browser?', 'editor.select_storage_first': 'Selectează mai întâi un senzor de stocare.', 'editor.no_local_data': 'Nu s-au găsit date locale în browser.', 'editor.test_tool': 'Instrument de test', 'editor.test_select_entity': 'Selectează entitatea', 'editor.test_empty': 'Selectează o entitate pentru a vedea comenzile disponibile…', 'editor.test_export': 'Export .md', 'editor.test_no_fields': 'Fără câmpuri', 'editor.test_select_placeholder': '— Selectează —', 'editor.error_prefix': 'Eroare: ', 'editor.clear_all': 'Șterge toate datele', 'editor.clear_all_confirm': 'Aceasta șterge atât stocarea, cât și programele locale din browser. Continui?', 'editor.clear_storage_only': 'Șterge doar datele din stocare', 'editor.clear_storage_only_confirm': 'Aceasta șterge programele din senzorul de stocare selectat. Continui?', 'editor.clear_local_only': 'Șterge TOATE datele locale (doar local)', 'editor.clear_local_only_confirm': 'Aceasta șterge doar programele salvate în browserul tău. Continui?', 'editor.reset.desc': 'Ce face:\n\n• Șterge toate datele: Resetează stocarea backend (.storage) pentru Thermostat Timeline și elimină TOATE cache-urile/schițele locale din browser.\n• Șterge TOATE datele locale (doar local): Șterge doar datele stocate în acest browser (localStorage).\n\nSfat: După o resetare completă, reîncarcă pagina pentru a evita reapariția schițelor vechi.', 'editor.select_entities': 'Camere', 'editor.colors.empty': 'Nu au fost adăugate camere încă. Adaugă o cameră pentru a defini culorile.', 'editor.colors.go_add': 'Adaugă cameră', 'week.enable': 'Activează zilele săptămânii', 'week.enable.desc': 'Activează planificarea pe zile ale săptămânii. Când este oprit, același plan este folosit în fiecare zi.', 'week.mode': 'Mod zile ale săptămânii', 'week.mode.weekday_weekend': 'Zile lucrătoare + weekend', 'week.mode.weekday_sat_sun': 'Zile lucrătoare + sâmbătă + duminică', 'week.mode.all_7': 'Toate zilele', 'week.button': 'Zilele săptămânii', 'week.title': 'Zilele săptămânii', 'week.group.weekdays': 'Zile lucrătoare', 'week.group.weekend': 'Weekend', 'week.days_full.mon': 'Luni', 'week.days_full.tue': 'Marți', 'week.days_full.wed': 'Miercuri', 'week.days_full.thu': 'Joi', 'week.days_full.fri': 'Vineri', 'week.days_full.sat': 'Sâmbătă', 'week.days_full.sun': 'Duminică', 'week.days.mon': 'Lun', 'week.days.tue': 'Mar', 'week.days.wed': 'Mie', 'week.days.thu': 'Joi', 'week.days.fri': 'Vin', 'week.days.sat': 'Sâm', 'week.days.sun': 'Dum', 'week.editor.title': 'Program pe zile ale săptămânii', 'week.editor.preview_note': 'Doar previzualizare. Modificările se aplică atunci când apeși Salvează.', 'week.open_editor.desc': 'Deschide fereastra editorului pentru zilele săptămânii.', 'week.open_editor.btn': 'Deschide', 'week.view.title': 'Vizualizare zilele săptămânii', 'week.view.rooms_one_day': 'Toate camerele • o zi', 'week.view.days_one_room': 'Toate zilele • o cameră', 'week.view_switch.title': 'Schimbă vizualizarea din cronologie', 'week.view_switch.desc': 'Afișează un comutator în antetul cronologiei pentru a schimba între cele două moduri de vizualizare ale zilelor săptămânii.', 'week.copy_from_main.title': 'Copiază programul principal', 'week.copy_from_main.desc': 'Ai activat zilele săptămânii. Vrei să copiezi programul principal în zilele săptămânii? Alege modul în care să copiezi.', 'week.copy_from_main.mode_label': 'Mod zile ale săptămânii', 'week.copy_from_main.target_label': 'Copiază în', 'week.copy_from_main.copy_btn': 'Copiază', 'week.copy_from_main.skip_btn': 'Nu copia', 'week.select_room': 'Selectează cameră', 'week.copy_to_rooms': 'Copiază zilele săptămânii în camere', 'week.copy_to_rooms.title': 'Copiază zilele săptămânii în camere', 'week.copy_to_rooms.select': 'Selectează camere', 'week.copy_to_rooms.apply': 'Aplică la camere', 'editor.timefmt.title': 'Format oră', 'editor.timefmt.desc': 'Alege afișarea orei în format 24h sau 12h (AM/PM).', 'editor.tempfmt.title': 'Unitate temperatură', 'editor.tempfmt.desc': 'Alege Celsius (°C) sau Fahrenheit (°F) pentru afișare în card și editor.', 'editor.timesrc.title': 'Sursa timpului', 'editor.timesrc.desc': 'Alege dacă cardul folosește ora din browser sau fusul orar Home Assistant pentru „acum”.', 'editor.timesrc.browser': 'Browser', 'editor.timesrc.ha': 'Home Assistant', 'editor.instance.title': 'ID configurație', 'editor.instance.desc': 'Păstrează mai multe programe/setări independente sub ID-uri diferite.', 'editor.instance.new_id': 'ID nou', 'editor.tabs.settings': 'Setări', 'editor.tabs.pause': 'Pauză', 'editor.tabs.weekdays': 'Zilele săptămânii', 'editor.tabs.rooms': 'Camere', 'editor.tabs.boiler': 'Control boiler', 'editor.tabs.colors': 'Culori', 'editor.tabs.presence_sensor': 'Senzor de prezență', 'editor.tabs.owd': 'Detectare fereastră deschisă', 'editor.tabs.away': 'Plecat de acasă', 'editor.tabs.reset': 'Resetare', 'editor.tabs.sync': 'Motor de sincronizare', 'editor.tabs.backup': 'Backup', 'editor.presence_sensor.enable.title': 'Senzor de prezență', 'editor.presence_sensor.enable.desc': 'Activează configurarea senzorului de prezență per cameră.', 'editor.presence_sensor.entity.title': 'Entitate prezență', 'editor.presence_sensor.entity.desc': 'Selectează binary_sensor care indică prezența în această cameră.', 'editor.presence_sensor.on_temp.title': 'Temperatură când prezența este ON', 'editor.presence_sensor.on_temp.desc': 'Dacă senzorul este ON, această cameră va folosi această temperatură în locul programului.', 'editor.presence_sensor.on_delay.title': 'Întârziere înainte de aplicarea prezenței', 'editor.presence_sensor.on_delay.desc': 'Cât timp trebuie să rămână ON senzorul înainte de a folosi temperatura de suprascriere. Valoarea folosește unitatea selectată.', 'editor.presence_sensor.off_delay.title': 'Întârziere înainte de reluarea programului', 'editor.presence_sensor.off_delay.desc': 'Cât timp trebuie să rămână OFF senzorul înainte de reluarea programului. Valoarea folosește unitatea selectată.', 'owd.enable': 'Detectare fereastră deschisă', 'owd.enable.desc': 'Oprește termostatele într-o cameră când senzorii de fereastră/ușă selectați sunt deschiși.', 'owd.sensors': 'Senzori fereastră/ușă', 'owd.delays.title': 'Întârzieri', 'owd.delays.desc': 'Minute de așteptare înainte de oprire (deschis) și reluare program (închis).', 'owd.open_delay': 'După deschidere (min)', 'owd.close_delay': 'După închidere (min)', 'boiler.switch': 'Comutator boiler', 'boiler.switch_type': 'Tip entitate', 'boiler.switch_type.switch': 'Comutator', 'boiler.switch_type.input_boolean': 'Input boolean', 'boiler.rooms': 'Camere incluse', 'boiler.rooms.desc': 'Selectează ce camere trebuie incluse în controlul boilerului.', 'boiler.multi_enable': 'Activează mai multe boilere', 'boiler.multi_enable.desc': 'Atribuie camere către boilere diferite și setează decalaje per cameră.', 'boiler.multi_rooms': 'Boiler per cameră', 'boiler.multi_rooms.desc': 'Selectează comutatorul boilerului și decalajele pentru fiecare cameră.', 'boiler.room.enable': 'Include cameră', 'boiler.room.enable.desc': 'Include această cameră în controlul boilerului.', 'boiler.enable': 'Activează controlul boilerului', 'boiler.enable.desc': 'Afișează setările boilerului în editorul acestui card.', 'boiler.temp_sensor': 'Senzor temperatură boiler', 'boiler.temp_sensor.desc': 'Senzor de temperatură care controlează comutatorul boilerului.', 'boiler.min_temp': 'Temperatură minimă boiler (°C)', 'boiler.max_temp': 'Temperatură maximă boiler (°C)', 'boiler.offsets': 'Decalaje', 'boiler.offsets.desc': 'Decalaj ON: cât de mult sub ținta programată înainte de a porni. Decalaj OFF: cât de mult peste ținta programată înainte de a opri.', 'boiler.on_offset': 'Decalaj ON (°C)', 'boiler.off_offset': 'Decalaj OFF (°C)', 'sync.running': 'Sincronizare în curs…', 'sync.completed': 'Sincronizare finalizată', 'backup.title': 'Backup', 'backup.desc': 'Copiază conținutul complet al senzorului de sincronizare în senzorul de backup și restaurează când este nevoie.', 'backup.now': 'Backup acum', 'backup.restore': 'Restaurare', 'backup.restore.confirm_overwrite': 'Restaurezi din backup? Aceasta va suprascrie programele și setările curente.', 'backup.delete': 'Șterge backupul', 'backup.delete.confirm': 'Ștergi backupul selectat?', 'backup.new': 'Backup nou', 'backup.auto': 'Backup automat', 'backup.auto.desc': 'Creează backupuri automat la un interval fix.', 'backup.interval': 'Interval (zile)', 'backup.interval.desc': 'Câte zile între backupurile automate (min 1).', 'backup.sensor': 'Senzor backup', 'backup.last': 'Ultimul backup', 'backup.slot.title': 'Selectează backup', 'backup.slot.desc': 'Selectează ce slot de backup să restaurezi.', 'backup.slot.empty': 'Încă nu există backupuri', 'backup.select': 'Selectează ce să fie salvat', 'backup.select.desc': 'Elementele bifate sunt incluse în backup/restaurare', 'backup.opt.all': 'Toate', 'backup.opt.main': 'Programe principale', 'backup.opt.weekday': 'Programe zile săptămânii', 'backup.opt.presence': 'Programe de prezență', 'backup.opt.settings': 'Setări editor', 'backup.opt.holiday': 'Programe de sărbători', 'backup.opt.colors': 'Blocuri de culori', 'backup.import.title': 'Importă thermostat_timeline.json', 'backup.import.desc': 'Încarcă un fișier thermostat_timeline.json și importă-l. Se creează mai întâi un backup nou.', 'backup.import.btn': 'Importă fișier', 'backup.export.title': 'Exportă fișierul de backup', 'backup.export.desc': 'Descarcă un thermostat_timeline_backup.json complet cu toate sloturile și datele.', 'backup.export.btn': 'Descarcă fișier', 'backup.imported_suffix': ' - importat', 'backup.importbk.title': 'Importă fișier de backup', 'backup.importbk.desc': 'Încarcă un fișier thermostat_timeline_backup.json pentru a adăuga backupurile lui în listă.', 'backup.importbk.btn': 'Importă backup', 'backup.importbk.err_invalid': 'Fișier de backup invalid', 'backup.file.choose': 'Alege backupul importat', 'backup.file.add_selected': 'Importă backup', 'backup.file.add_all': 'Importă toate backupurile', 'backup.file.loaded_n': 'Fișier încărcat ({n} backupuri)', 'backup.file.imported_ok': 'Importat ({n})', 'backup.import.err_no_hass': 'Home Assistant nu este pregătit', 'backup.import.err_storage_off': 'Stocarea partajată este OPRITĂ. Pornește-o mai întâi.', 'backup.import.err_no_integration': 'Integrarea thermostat_timeline nu este disponibilă', 'backup.import.err_json': 'JSON invalid', 'backup.import.err_invalid': 'Fișier invalid: lipsește data.schedules', 'away.enable': 'Activează modul Plecat', 'away.desc': 'Când niciuna dintre persoanele selectate nu este acasă, termostatele sunt reduse automat la temperatura specificată.', 'away.temp': 'Temperatură de plecare', 'away.persons': 'Persoane', 'away.add_person': 'Adaugă persoană', 'away.resume_schedule': 'Reia programul', 'away.resume_away': 'Reia temperatura de plecare', 'editor.heat_colors.title': 'Culori pentru blocurile de încălzire', 'editor.heat_colors.add': 'Adaugă interval de culoare', 'editor.colors.col_from': 'De la °C', 'editor.colors.col_to': 'Până la °C', 'editor.colors.col_color': 'Culoare', 'editor.colors.refresh': 'Reîmprospătează programul', 'editor.colors.clear_local': 'Șterge datele locale de culoare', 'editor.colors.clear_local_confirm': 'Aceasta șterge intervalele de culoare stocate în browser pentru acest card. Continui?', 'editor.colors.clear_shared': 'Șterge stocarea partajată a culorilor', 'editor.colors.clear_shared_confirm': 'Aceasta șterge intervalele de culoare în senzorul de stocare selectat. Continui?', 'editor.colors.clear_all': 'Șterge toate datele de culoare', 'editor.colors.clear_all_confirm': 'Aceasta șterge toate intervalele de culoare atât local, cât și în senzorul de stocare selectat. Continui?', 'editor.colors.mode': 'Mod de culoare', 'editor.colors.mode.desc': 'Alege culori pe cameră sau un singur set pentru toate camerele.', 'editor.colors.mode_per_room': 'Pe cameră', 'editor.colors.mode_global': 'Toate camerele', 'editor.colors.global_title': 'Toate camerele', 'editor.reload_editor': 'Reîncarcă editorul', 'editor.reload_dashboard': 'Reîncarcă panoul', 'onboard.title': 'Bine ai venit la Thermostat Timeline', 'onboard.next': 'Următorul', 'onboard.prev': 'Înapoi', 'onboard.skip': 'Sari peste', 'onboard.done': 'Gata', 'onboard.dont_show': 'Nu mai arăta', 'onboard.video_not_found': 'Video negăsit', 'onboard.image_not_found': 'Imagine negăsită', 'onboard.step_main': 'Aceasta este cronologia principală. Afișează temperaturile țintă pe 24 de ore pentru fiecare cameră.', 'onboard.step_add': 'Adaugă un bloc de încălzire prin + Adaugă bloc sau prin dublu clic pe cronologie.', 'onboard.step_edit': 'Editează sau șterge un bloc prin dublu clic. Folosește Zilele săptămânii pentru zile diferite.', 'onboard.reset': 'Afișează ghidul din nou', 'onboard.reset_ok': 'Ghidul va fi afișat data viitoare.', 'pause.menu_title': 'Pune pe pauză aplicarea automată', 'pause.off_1h': 'Pauză pentru 1 oră', 'pause.off_5h': 'Pauză pentru 5 ore', 'pause.off_custom': 'Durată personalizată…', 'pause.prompt_minutes': 'Introdu durata (minute):', 'pause.off_indef': 'Pauză până reiau', 'pause.resume': 'Reia acum', 'pause.close': 'Închide', 'pause.active_until': 'Pauză până la {time}', 'pause.active_indef': 'Pauză', 'editor.pausebtn.title': 'Buton de pauză', 'editor.pausebtn.desc': 'Afișează un buton global de pauză în antetul cardului. Când pauza este activă, toate comenzile set_temperature sunt suprimate.', 'editor.pause_sensor.title': 'Pauză prin binary sensor', 'editor.pause_sensor.desc': 'Pune automat pe pauză când binary sensor-ul selectat este pornit.', 'editor.pause_sensor.entity_title': 'Senzor de pauză', 'editor.pause_sensor.entity_desc': 'Binary sensor care controlează pauza (on = pauză, off = reluare).', 'editor.integration.connected': 'Conectat la integrare', 'editor.integration.disconnected': 'Nu este conectat la integrare', 'profiles.enable': 'Activează profilele (suprascriere)', 'profiles.enable.desc': 'Permite programe zilnice numite care pot fi activate pentru a suprascrie planul principal.', 'season.enable.title': 'Activează programe de vară/iarnă', 'season.enable.desc': 'Când este activat, fiecare program are versiuni separate de Vară și Iarnă între care poți comuta.', 'season.mode.title': 'Sezon', 'season.mode.winter': 'Iarnă', 'season.mode.summer': 'Vară', 'profiles.button': 'Profile', 'profiles.editor.title': 'Profile', 'profiles.add': 'Adaugă profil', 'profiles.rename': 'Redenumește', 'profiles.delete': 'Șterge', 'profiles.activate': 'Activează', 'profiles.deactivate': 'Dezactivează', 'profiles.active': 'Activ', 'profiles.none': 'Niciunul', 'profiles.name_placeholder': 'Nume profil', 'profiles.name_exists': 'Numele există', 'profiles.edit': 'Editează', 'profiles.preview_note': 'Doar previzualizare. Profilele devin active când le activezi.', 'profiles.active_label': 'Profil activ:', 'profiles.save_new': 'Salvează profil nou', 'profiles.save_existing': 'Salvează modificările în profilul selectat', 'editor.tabs.holidays': 'Sărbători', 'holidays.enable': 'Activează sărbătorile', 'holidays.desc': 'Folosește un program special în zilele de sărbătoare.', 'holidays.source': 'Sursa sărbătorilor', 'holidays.source.calendar': 'Calendar', 'holidays.source.manual': 'Date manuale', 'holidays.entity': 'Entitate sărbători', 'holidays.entity_desc': 'Selectează un calendar care oferă doar on/off. Trebuie să fie on în sărbători și off în celelalte zile.', 'holidays.dates': 'Date de sărbătoare', 'holidays.add_date': 'Adaugă dată', 'holidays.add_range': 'Adaugă interval', 'holidays.date_in_range': 'Data este deja acoperită de un interval', 'holidays.ranges': 'Intervale de sărbători', 'holidays.single_dates': 'Date individuale', 'holidays.editor.title': 'Program de sărbătoare', 'holidays.edit': 'Editează programul de sărbătoare', 'holidays.button': 'Sărbători', 'holidays.copy_from': 'Copiază din…', 'holidays.copy.title': 'Copiază în sărbători', 'holidays.copy.select': 'Selectează sursa', 'holidays.copy.apply': 'Inserează', 'holidays.copy.replace_confirm': 'Înlocuiești blocurile existente de sărbătoare?', 'holidays.copy.group.main': 'Plan principal', 'holidays.copy.group.weekdays': 'Zilele săptămânii', 'holidays.copy.group.profiles': 'Profile', 'holidays.copy.group.presence': 'Prezență', 'presence.button': 'Programe de prezență', 'presence.editor.title': 'Programe de prezență', 'presence.enable_advanced': 'Activează programe avansate de prezență', 'presence.enable_advanced.desc': 'Folosește combinații și programe în funcție de cine este acasă/plecat.', 'presence.live_header': 'Afișează prezența live în antet', 'presence.live_header.desc': 'Insignele sunt afișate în colțul din dreapta.', 'away.delay_enable': 'Întârzie acțiunile de plecare', 'away.delay_desc': 'Așteaptă înainte de a aplica schimbările de plecare.', 'away.delay_value': 'Întârziere', 'away.delay_unit': 'Unitate', 'away.delay_unit.seconds': 'Secunde', 'away.delay_unit.minutes': 'Minute', 'presence.combos': 'Combinații', 'presence.enable_combo': 'Activează această combinație', 'presence.all_home': 'Toți acasă', 'presence.none_home': 'Nimeni acasă', 'presence.home': 'Acasă: {names}', 'presence.away': 'Plecați: {names}', 'presence.manage': 'Gestionează combinațiile', 'presence.who_home': 'Cine este acasă', 'presence.who_away': 'Cine este plecat' }, }; const TT_LANG_ALIAS = { no: 'nb', cz: 'cs', dk: 'da' }; function ttGetLangFromHass(hass) { // Home Assistant can report locales like "da_DK"; normalize to BCP-47 ("da-DK"). const raw = (typeof hass === 'string' ? hass : (hass?.locale?.language || hass?.language)) || navigator.language || 'en'; return String(raw).replace(/_/g, '-').toLowerCase(); } function ttLocalize(key, langOrHass) { const raw = typeof langOrHass === 'string' ? langOrHass : ttGetLangFromHass(langOrHass); const lang = String(raw).replace(/_/g, '-').toLowerCase(); const parts = lang.split('-'); const candidates = [ lang, parts[0], TT_LANG_ALIAS[lang], TT_LANG_ALIAS[parts[0]], 'en', ].filter(Boolean); for (const c of candidates) { const dict = TT_I18N[c]; if (dict && dict[key]) return dict[key]; } // Default/fallback language is English. return TT_I18N.en[key] || key; } // ---- end i18n ---- /* eslint-disable no-undef */ // Simple runtime version to help with cache-busting diagnostics in HA. // Update this when shipping changes so the version appears in the // "Custom cards" panel and in logs. const TT_CARD_VERSION = "2026.02.08-seasonal"; class ThermostatTimelineCard extends HTMLElement { static get version() { return TT_CARD_VERSION; } static getConfigElement() { return document.createElement("thermostat-timeline-card-editor"); } static getStubConfig() { return { // Localize default title based on browser language; editor will further adapt to HA language title: ttLocalize('card.title_default', navigator.language || 'en'), entities: [], time_12h: null, time_source: 'browser', // 'browser' | 'ha' color_global: false, // when true, use one global set of colors (key "*") for all rooms now_update_ms: 60000, // kun til UI 'nu'-stregen storage_enabled: true, // default: on -> uses file storage via API // Optional per-card instance namespace (off by default for legacy compatibility) instance_enabled: false, // Pre-generate a unique ID so new cards don't inherit another card's instance. // Safe chars only; will be normalized if user later edits it. instance_id: `thermostat_pro_timeline_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,6)}`, // Internal per-card uid to make local persistence stable even when multiple // cards have identical entities/settings (and when Lovelace uses Shadow DOM). instance_uid: `ttuid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,10)}`, storage_sync_min: 5, // minutes when mode = 'delay' (legacy) backup_auto_enabled: false, backup_interval_days: 1, show_top_now: false, now_extend_px: 76, show_pause_button: true, show_room_temp: true, pause_sensor_enabled: false, pause_sensor_entity: '', labels: {}, merges: {}, // { [primary_eid]: [linked_eid, ...] } temp_sensors: {}, // { [primary_eid]: sensor.temperature_entity_id } turn_on: {}, // { [primary_eid]: { enabled: bool, order: 'before'|'after' } } boiler_enabled: false, // enable boiler controls in editor and runtime boiler_switch: '', // optional switch.* entity used for boiler control boiler_switch_domain: 'switch', // switch | input_boolean boiler_rooms: null, // null = all rooms; array = selected climate rooms boiler_on_offset: 0, // °C relative to target: ON when current < target - on_offset boiler_off_offset: 0, // °C relative to target: OFF when current >= target + off_offset boiler_temp_sensor: '', // optional sensor.* entity used for boiler temperature control boiler_min_temp: 20, // boiler min temp (stored internally as °C) boiler_max_temp: 25, // boiler max temp (stored internally as °C) boiler_multi_enabled: false, // enable per-room boiler mapping boiler_room_settings: {}, // { [room_eid]: { enabled, switch, switch_domain, on_offset, off_offset } } weekdays_enabled: false, seasonal_enabled: false, seasonal_mode: 'winter', // 'winter' | 'summer' weekdays_mode: 'weekday_weekend', // 'weekday_weekend' | 'weekday_sat_sun' | 'all_7' weekdays_view: 'all_rooms_one_day', // 'all_rooms_one_day' | 'one_room_all_days' weekdays_view_switch_in_timeline: false, weekdays_selected_room: '', profiles_enabled: false, away: { enabled: false, persons: [], target_c: 17, advanced_enabled: false, combos: {} }, // Open Window Detection (editor + shared settings) open_window: { enabled: false, sensors: {}, open_delay_min: 2, close_delay_min: 5 }, holidays_enabled: false, holidays_source: 'calendar', // 'calendar' | 'manual' holidays_entity: '', // calendar.* or binary_sensor.* that is on/off for holiday holidays_dates: [], // [ 'YYYY-MM-DD', ... ] holidays_groups: [], // UI groups for manual ranges: [{id, from, to, dates:[...] }] presence_live_header: true // Show live presence chips in header , room_use_input_number: [] // per-room toggle: show/select input_number instead of climate (matches entities[] index) , room_use_temp_sensor: [] // per-room toggle: use temp_sensors[eid] as room temperature instead of climate.current_temperature }; } connectedCallback(){ // Attach minimal listeners to avoid re-render while typing in row inputs try { this.shadowRoot?.addEventListener('focusin', (e)=>{ try { const t = e.target; if (!t) return; // Suspend while editing any number input in row header if ((t.tagName === 'INPUT' || t.tagName === 'HA-TEXTFIELD') && t.closest && t.closest('.row-head')) { this._suspendRender = true; } } catch {} }); this.shadowRoot?.addEventListener('focusout', ()=>{ setTimeout(()=>{ this._suspendRender = false; }, 150); }); } catch {} } // ---------- Home Assistant hook ---------- set hass(hass) { this._hass = hass; this._lang = ttGetLangFromHass(hass); // Storage is now file-based via API, no sensor auto-detection needed // (Select-based global profile removed) // Detect Away mode changes and apply immediately try { const curAway = this._isAwayActive(); if (this._lastAwayActive === null) { this._lastAwayActive = curAway; } else if (this._initialized && curAway !== this._lastAwayActive) { this._lastAwayActive = curAway; // Reset manual away bypass on any away enter/exit this._awayBypass = false; if (this._config?.auto_apply) { // Apply immediately on Away enter/exit this._applyCurrentSetpoints(true); // When Sync is ON, nudge backend to apply right away as well try { if (this._config?.storage_enabled) this._nudgeBackgroundApplyNow(); } catch {} this._scheduleNextApply(); } } } catch {} // Hent fra sensor hvis version ændret // Detect Presence combo changes and apply immediately try { const curKey = this._activePresenceComboKey ? this._activePresenceComboKey() : null; if (this._lastPresenceKey === null) { this._lastPresenceKey = curKey; } else if (this._initialized && curKey !== this._lastPresenceKey) { this._lastPresenceKey = curKey; if (this._config?.auto_apply) { this._applyCurrentSetpoints(true); try { if (this._config?.storage_enabled) this._nudgeBackgroundApplyNow(); } catch {} this._scheduleNextApply(); } } } catch {} // Detect per-room presence sensor transitions and apply immediately // (also schedules timers so delayed ON/OFF can take effect without new HA events) try { const curSnap = this._presenceSensorActiveSnapshot ? this._presenceSensorActiveSnapshot() : {}; if (this._lastPresenceSensorSnap == null) { this._lastPresenceSensorSnap = curSnap; } else if (this._initialized && this._presenceSensorSnapChanged && this._presenceSensorSnapChanged(this._lastPresenceSensorSnap, curSnap)) { this._lastPresenceSensorSnap = curSnap; if (this._config?.auto_apply) { if (this._config?.storage_enabled) this._nudgeBackgroundApplyNow(); else this._applyCurrentSetpoints(true, true); this._scheduleNextApply(); } } else { this._lastPresenceSensorSnap = curSnap; } try { this._schedulePresenceDelayTimer && this._schedulePresenceDelayTimer(); } catch {} } catch {} // Detect pause sensor transitions (UI responsiveness + frontend-only behavior when Sync is OFF) try { const cur = this._isPausedBySensor ? !!this._isPausedBySensor() : false; if (this._lastPauseSensorOn === null || this._lastPauseSensorOn === undefined) { this._lastPauseSensorOn = cur; } else if (this._initialized && cur !== this._lastPauseSensorOn) { this._lastPauseSensorOn = cur; try { if (!this._inlineEditing && !this._editing) this._render(); } catch {} if (this._config?.auto_apply) { if (cur) { // Pause: cancel any pending boundary timer try { this._scheduleNextApply(); } catch {} } else { // Resume: reconcile once + schedule next this._applyCurrentSetpoints(true, true); try { if (this._config?.storage_enabled) this._nudgeBackgroundApplyNow(); } catch {} this._scheduleNextApply(); } } } } catch {} // Version checking is now done via API polling in _startStoreWatchTimer // When Sync is OFF, do not override local editor toggles (profiles_enabled) with sensor values. // Visibility of the Manual schedule button is driven by local config and localStorage only. try { if (this._config?.storage_enabled) this._startStoreWatchTimer(); else if (this._storeWatchTimer) { clearInterval(this._storeWatchTimer); this._storeWatchTimer = null; } } catch {} // Boiler control (frontend only when Sync is OFF) try { this._setupBoilerControl(); } catch {} // (Removed select/state subscription; card updates via version change as before) if (!this._initialized) { this._initialized = true; this._init(); this._loadStore(true).then(async () => { this._ensureSchedules(); // Ensure labels/merges/temp_sensors from backend are reflected in config before first render try { await this._hydrateUiNamesFromBackend(); } catch {} // Restore delayed sync countdown if previously scheduled try { this._restoreSyncDue(); } catch {} this._render(); // Open deferred Holiday editor right after first render if flagged by editor try { this._maybeOpenDeferredHoliday && this._maybeOpenDeferredHoliday(); } catch {} // Ved load sætter vi korrekt setpoint én gang this._applyCurrentSetpoints(true); this._scheduleNextApply(); // Push any YAML-provided settings to storage/local after first load // but avoid resetting a pending delayed sync countdown on hard refresh try { const hasPending = Number(this._storeDelayDue||0) > Date.now() || !!localStorage.getItem(this._syncDueKey()); if (!(this._config?.storage_enabled && this._config?.storage_sync_mode === 'delay' && hasPending)) { this._debouncedSaveStore(); } } catch {} // (Removed polling safety net) }); } else { if (!this._inlineEditing && !this._editing) this._render(); } } // Ensure UI-friendly names (labels) are hydrated from backend if missing locally async _hydrateUiNamesFromBackend(){ try { if (!this._config) return; const hasAnyLabel = this._config.labels && typeof this._config.labels==='object' && Object.keys(this._config.labels).length>0; const hasAnyMerge = this._config.merges && typeof this._config.merges==='object' && Object.keys(this._config.merges).length>0; const hasAnySensors = this._config.temp_sensors && typeof this._config.temp_sensors==='object' && Object.keys(this._config.temp_sensors).length>0; const hasAnyTurnOn = this._config.turn_on && typeof this._config.turn_on==='object' && Object.keys(this._config.turn_on).length>0; if (hasAnyLabel && hasAnyMerge && hasAnySensors && hasAnyTurnOn) return; const api = await this._apiFetchState(); const s = api?.settings || {}; let changed = false; if (!hasAnyLabel && s.labels && typeof s.labels==='object' && Object.keys(s.labels).length){ this._config.labels = { ...s.labels }; changed = true; } if (!hasAnyMerge && s.merges && typeof s.merges==='object' && Object.keys(s.merges).length){ this._config.merges = { ...s.merges }; changed = true; } if (!hasAnySensors && s.temp_sensors && typeof s.temp_sensors==='object' && Object.keys(s.temp_sensors).length){ this._config.temp_sensors = { ...s.temp_sensors }; changed = true; } if (!hasAnyTurnOn && s.turn_on && typeof s.turn_on==='object' && Object.keys(s.turn_on).length){ this._config.turn_on = { ...s.turn_on }; changed = true; } if (changed){ try { const payload = this._makeStoragePayload(true); localStorage.setItem(this._localStoreKey(), JSON.stringify(payload)); } catch {} } } catch {} } // ---------- Config ---------- setConfig(config) { if (!config || !Array.isArray(config.entities)) throw new Error(ttLocalize('err.config_entities', navigator.language || 'en')); const prevDefault = Number(this._config?.default_temp); const yamlSetsDefault = Object.prototype.hasOwnProperty.call(config, 'default_temp'); let rowh = Number(config.row_height ?? 64); rowh = isNaN(rowh) ? 64 : Math.max(40, Math.min(120, Math.round(rowh))); let deft = Number(config.default_temp ?? 20); deft = isNaN(deft) ? 20 : Math.max(5, Math.min(35, deft)); let nowms = Number(config.now_update_ms ?? 60000); nowms = isNaN(nowms) ? 60000 : Math.max(200, nowms); let maxt = Number(config.max_temp ?? 25); maxt = isNaN(maxt) ? 25 : Math.max(5, Math.min(120, maxt)); let mint = Number(config.min_temp ?? 5); mint = isNaN(mint) ? 5 : Math.max(0, Math.min(45, mint)); // Boiler limits default to 20/25°C. Keep previous values when not provided. const prevBMin = (this._config && Object.prototype.hasOwnProperty.call(this._config, 'boiler_min_temp')) ? this._config.boiler_min_temp : null; const prevBMax = (this._config && Object.prototype.hasOwnProperty.call(this._config, 'boiler_max_temp')) ? this._config.boiler_max_temp : null; const clampBoiler = (n)=>{ const v = Number(n); return Number.isFinite(v) ? Math.max(0, Math.min(120, v)) : null; }; let boilerMin = (config.boiler_min_temp === undefined) ? (Number.isFinite(Number(prevBMin)) ? Number(prevBMin) : 20) : ((config.boiler_min_temp === null || config.boiler_min_temp === '') ? 20 : clampBoiler(config.boiler_min_temp)); let boilerMax = (config.boiler_max_temp === undefined) ? (Number.isFinite(Number(prevBMax)) ? Number(prevBMax) : 25) : ((config.boiler_max_temp === null || config.boiler_max_temp === '') ? 25 : clampBoiler(config.boiler_max_temp)); const showTop = Boolean(config.show_top_now ?? false); let extendPx = Number(config.now_extend_px ?? 76); extendPx = isNaN(extendPx) ? 76 : Math.max(48, Math.min(140, Math.round(extendPx))); // Mark which keys were explicitly provided in YAML so storage won't // override them on first hass() load this._yamlProvided = { labels: Object.prototype.hasOwnProperty.call(config, 'labels'), merges: Object.prototype.hasOwnProperty.call(config, 'merges'), color_ranges: Object.prototype.hasOwnProperty.call(config, 'color_ranges'), default_temp: Object.prototype.hasOwnProperty.call(config, 'default_temp'), min_temp: Object.prototype.hasOwnProperty.call(config, 'min_temp'), max_temp: Object.prototype.hasOwnProperty.call(config, 'max_temp'), temp_sensors: Object.prototype.hasOwnProperty.call(config, 'temp_sensors'), turn_on: Object.prototype.hasOwnProperty.call(config, 'turn_on'), // Boiler settings are storage-driven; do not lock them from Lovelace/YAML defaults. boiler_enabled: false, boiler_switch: false, boiler_switch_domain: false, boiler_rooms: false, boiler_on_offset: false, boiler_off_offset: false, boiler_temp_sensor: false, boiler_min_temp: false, boiler_max_temp: false, show_pause_button: Object.prototype.hasOwnProperty.call(config, 'show_pause_button'), show_room_temp: Object.prototype.hasOwnProperty.call(config, 'show_room_temp'), pause_sensor_enabled: Object.prototype.hasOwnProperty.call(config, 'pause_sensor_enabled'), pause_sensor_entity: Object.prototype.hasOwnProperty.call(config, 'pause_sensor_entity'), away: Object.prototype.hasOwnProperty.call(config, 'away'), time_12h: Object.prototype.hasOwnProperty.call(config, 'time_12h'), time_source: Object.prototype.hasOwnProperty.call(config, 'time_source'), temp_unit: Object.prototype.hasOwnProperty.call(config, 'temp_unit'), // Holiday settings are storage-driven; do not lock them from Lovelace/YAML defaults. holidays_enabled: false, holidays_source: false, holidays_entity: false, holidays_dates: false, presence_live_header: false, presence_sensor_enabled: Object.prototype.hasOwnProperty.call(config, 'presence_sensor_enabled'), presence_sensors: Object.prototype.hasOwnProperty.call(config, 'presence_sensors'), presence_sensor_temps: Object.prototype.hasOwnProperty.call(config, 'presence_sensor_temps'), presence_sensor_delays: Object.prototype.hasOwnProperty.call(config, 'presence_sensor_delays'), presence_sensor_delay_units: Object.prototype.hasOwnProperty.call(config, 'presence_sensor_delay_units'), }; this._config = { // If no explicit title, localize a default based on HA/browser language title: (config.title !== undefined && config.title !== null) ? String(config.title) : ttLocalize('card.title_default', this._hass || this._lang || navigator.language || 'en'), entities: config.entities, row_height: rowh, default_temp: deft, max_temp: maxt, min_temp: mint, temp_unit: (config.temp_unit === undefined || config.temp_unit === null || config.temp_unit === 'auto') ? (this._detectPreferF() ? 'F' : 'C') : (String(config.temp_unit).toUpperCase()==='F'?'F':'C'), time_12h: (config.time_12h === undefined || config.time_12h === null || config.time_12h === 'auto') ? this._detectPrefer12h() : !!config.time_12h, time_source: (config.time_source === 'ha' ? 'ha' : 'browser'), now_update_ms: nowms, // File-based storage is always used - no sensor entity configuration needed storage_enabled: !!(config.storage_enabled ?? true), instance_enabled: !!(config.instance_enabled ?? false), instance_id: String(config.instance_id ?? ''), storage_sync_mode: (config.storage_sync_mode === 'delay' ? 'delay' : 'instant'), storage_sync_min: Number.isFinite(config.storage_sync_min) ? Math.max(0, Math.min(1440, Number(config.storage_sync_min))) : (this._config?.storage_sync_min ?? 5), storage_sync_sec: Number.isFinite(config.storage_sync_sec) ? Math.max(5, Math.min(86400, Math.round(config.storage_sync_sec))) : (Number.isFinite(config.storage_sync_min) ? Math.max(5, Math.min(86400, Math.round(Number(config.storage_sync_min)*60))) : (this._config?.storage_sync_sec ?? 15)), backup_auto_enabled: !!(config.backup_auto_enabled ?? this._config?.backup_auto_enabled ?? false), backup_interval_days: Number.isFinite(config.backup_interval_days) ? Math.max(1, Math.min(365, Math.round(config.backup_interval_days))) : (this._config?.backup_interval_days ?? 1), show_top_now: showTop, now_extend_px: extendPx, show_pause_button: !!(config.show_pause_button ?? true), show_room_temp: !!(config.show_room_temp ?? true), pause_sensor_enabled: !!(config.pause_sensor_enabled ?? this._config?.pause_sensor_enabled ?? false), pause_sensor_entity: String(config.pause_sensor_entity ?? this._config?.pause_sensor_entity ?? ''), presence_sensor_enabled: !!(config.presence_sensor_enabled ?? this._config?.presence_sensor_enabled ?? false), presence_sensors: { ...(config.presence_sensors || {}) }, presence_sensor_temps: (()=>{ try { const src = (config.presence_sensor_temps && typeof config.presence_sensor_temps === 'object') ? config.presence_sensor_temps : {}; const out = {}; for (const k of Object.keys(src||{})) { const v = Number(src[k]); if (Number.isFinite(v)) out[String(k)] = v; } return out; } catch { return {}; } })(), presence_sensor_delays: (()=>{ try { const src = (config.presence_sensor_delays && typeof config.presence_sensor_delays === 'object') ? config.presence_sensor_delays : {}; const out = {}; for (const k of Object.keys(src||{})) { const v = src?.[k]; if (!v || typeof v !== 'object') continue; const onS = Number(v.on_s); const offS = Number(v.off_s); const obj = {}; if (Number.isFinite(onS) && onS >= 0) obj.on_s = onS; if (Number.isFinite(offS) && offS >= 0) obj.off_s = offS; if (Object.keys(obj).length) out[String(k)] = obj; } return out; } catch { return {}; } })(), presence_sensor_delay_units: (()=>{ try { const src = (config.presence_sensor_delay_units && typeof config.presence_sensor_delay_units === 'object') ? config.presence_sensor_delay_units : {}; const out = {}; for (const k of Object.keys(src||{})) { const u = String(src[k] || '').toLowerCase().trim(); if (u === 'seconds' || u === 'minutes') out[String(k)] = u; } return out; } catch { return {}; } })(), auto_apply: config.auto_apply ?? true, apply_on_edit: config.apply_on_edit ?? true, apply_on_default_change: config.apply_on_default_change ?? true, labels: { ...(config.labels || {}) }, merges: { ...(config.merges || {}) }, temp_sensors: { ...(config.temp_sensors || {}) }, turn_on: { ...(config.turn_on || {}) }, boiler_enabled: !!(config.boiler_enabled ?? this._config?.boiler_enabled ?? false), boiler_switch: String(config.boiler_switch ?? this._config?.boiler_switch ?? ''), boiler_switch_domain: (()=>{ try { const sw = String(config.boiler_switch ?? this._config?.boiler_switch ?? ''); const fromEid = (()=>{ const dom = String(sw||'').split('.')[0]; return (dom === 'input_boolean') ? 'input_boolean' : 'switch'; })(); const raw = String((config.boiler_switch_domain ?? this._config?.boiler_switch_domain ?? fromEid) || '').toLowerCase().trim(); return (raw === 'input_boolean') ? 'input_boolean' : 'switch'; } catch { return 'switch'; } })(), boiler_rooms: (()=>{ try { const ents = Array.isArray(config.entities) ? config.entities.filter(Boolean).map(String) : []; const climateRooms = ents.filter(e=>String(e).startsWith('climate.')); const raw = Object.prototype.hasOwnProperty.call(config, 'boiler_rooms') ? config.boiler_rooms : (this._config?.boiler_rooms ?? null); if (Array.isArray(raw)) { const want = new Set(raw.filter(Boolean).map(String)); return climateRooms.filter(eid => want.has(eid)); } return null; } catch { return null; } })(), boiler_on_offset: (()=>{ try { const prev = Number(this._config?.boiler_on_offset); const raw = (config.boiler_on_offset === undefined) ? prev : Number(config.boiler_on_offset); const v = Number.isFinite(raw) ? raw : (Number.isFinite(prev) ? prev : 0); return Math.max(-10, Math.min(10, v)); } catch { return 0; } })(), boiler_off_offset: (()=>{ try { const prev = Number(this._config?.boiler_off_offset); const raw = (config.boiler_off_offset === undefined) ? prev : Number(config.boiler_off_offset); const v = Number.isFinite(raw) ? raw : (Number.isFinite(prev) ? prev : 0); return Math.max(-10, Math.min(10, v)); } catch { return 0; } })(), boiler_temp_sensor: String(config.boiler_temp_sensor ?? this._config?.boiler_temp_sensor ?? ''), boiler_min_temp: boilerMin, boiler_max_temp: boilerMax, boiler_multi_enabled: !!(config.boiler_multi_enabled ?? this._config?.boiler_multi_enabled ?? false), boiler_room_settings: (()=>{ try { const src = (config.boiler_room_settings && typeof config.boiler_room_settings === 'object') ? config.boiler_room_settings : (this._config?.boiler_room_settings || {}); return JSON.parse(JSON.stringify(src || {})); } catch { return {}; } })(), color_ranges: { ...(config.color_ranges || {}) }, color_global: !!(config.color_global ?? this._config?.color_global ?? false), per_room_defaults: !!(config.per_room_defaults ?? this._config?.per_room_defaults ?? false), presence_live_header: !!(config.presence_live_header ?? this._config?.presence_live_header ?? true), weekdays_enabled: !!(config.weekdays_enabled ?? false), seasonal_enabled: !!(config.seasonal_enabled ?? this._config?.seasonal_enabled ?? false), seasonal_mode: (String(config.seasonal_mode || this._config?.seasonal_mode || 'winter') === 'summer') ? 'summer' : 'winter', weekdays_mode: (config.weekdays_mode || 'weekday_weekend'), weekdays_view: (config.weekdays_view || this._config?.weekdays_view || 'all_rooms_one_day'), weekdays_view_switch_in_timeline: !!(config.weekdays_view_switch_in_timeline ?? this._config?.weekdays_view_switch_in_timeline ?? false), weekdays_selected_room: String(config.weekdays_selected_room || this._config?.weekdays_selected_room || ''), profiles_enabled: !!(config.profiles_enabled ?? false), away: { enabled: !!(config.away?.enabled ?? false), persons: [...(config.away?.persons || [])].filter(Boolean).map(String), target_c: Number.isFinite(config.away?.target_c) ? Number(config.away.target_c) : 17, advanced_enabled: !!(config.away?.advanced_enabled ?? this._config?.away?.advanced_enabled ?? false), combos: { ...(config.away?.combos || this._config?.away?.combos || {}) }, delay_enabled: !!(config.away?.delay_enabled ?? this._config?.away?.delay_enabled ?? false), delay_value: Number.isFinite(config.away?.delay_value) ? Number(config.away.delay_value) : (this._config?.away?.delay_value ?? 0), delay_unit: String(config.away?.delay_unit || this._config?.away?.delay_unit || 'minutes') } , holidays_enabled: !!(config.holidays_enabled ?? this._config?.holidays_enabled ?? false) , holidays_source: (config.holidays_source || this._config?.holidays_source || 'calendar') , holidays_entity: String(config.holidays_entity || this._config?.holidays_entity || '') , holidays_dates: Array.isArray(config.holidays_dates) ? [...config.holidays_dates] : ([...(this._config?.holidays_dates || [])]) , holidays_groups: Array.isArray(config.holidays_groups) ? [...config.holidays_groups] : ([...(this._config?.holidays_groups || [])]) }; // Guard: Away/Presence should not be active until at least one person is selected. // Keep the user's enabled toggle, but disable advanced features if no persons are set. try { const ap = Array.isArray(this._config?.away?.persons) ? this._config.away.persons.filter(Boolean).map(String) : []; this._config.away.persons = ap; if (!ap.length) { this._config.away.advanced_enabled = false; } } catch {} // If YAML explicitly changed default_temp, update rows that still use the old // default value (do not overwrite rooms with a user-specific override) try { if (yamlSetsDefault && Number.isFinite(prevDefault)) { for (const eid of (this._config.entities || [])) { const r = this._schedules[eid]; if (!r) continue; if (!Number.isFinite(r.defaultTemp) || Math.abs(Number(r.defaultTemp) - prevDefault) < 0.001) { r.defaultTemp = this._config.default_temp; } } } } catch {} if (this._initialized) { this._ensureSchedules(); // Extra safety: if YAML changed default_temp, propagate to rows that // still used the previous global default try { if (yamlSetsDefault && Number.isFinite(prevDefault)) { for (const eid of (this._config.entities || [])) { const r = this._schedules[eid]; if (!r) continue; if (!Number.isFinite(r.defaultTemp) || Math.abs(Number(r.defaultTemp) - prevDefault) < 0.001) { r.defaultTemp = this._config.default_temp; } } } } catch {} this._startUiTimer(); if (!this._inlineEditing && !this._editing) this._render(); this._scheduleNextApply(); // Persist shared settings to storage so other users/dashboards pick them up this._debouncedSaveStore(); // Boiler control (frontend only when Sync is OFF) try { this._setupBoilerControl(); } catch {} } // Mark config as ready this._hasConfig = true; // Boiler control (frontend only when Sync is OFF) try { this._setupBoilerControl(); } catch {} } getCardSize() { return 3 + (this._config?.entities?.length || 0) * 2; } // ---------- Lifecycle ---------- constructor() { super(); this.attachShadow({ mode: "open" }); this._initialized = false; this._config = ThermostatTimelineCard.getStubConfig(); // Track whether storage was loaded from backend to avoid early overwrites. this._storeLoaded = false; this._storeLoadedFromApi = false; // Guard: wait for setConfig before any apply logic this._hasConfig = false; // Track which YAML keys were explicitly provided so storage doesn't // override them on first load this._yamlProvided = {}; // Pop-up overlays (block editor, weekday editor, copy-to-rooms, onboarding) // Enable by default so Weekday and Add buttons are visible this._disablePopups = false; // Disable the onboarding guide popup explicitly this._disableOnboard = true; // In-card settings popup state (draft; only saved on Save button) this._settingsPopupTab = 'settings'; this._settingsDraft = null; // Data this._schedules = {}; // { [entity_id]: { defaultTemp:number, blocks:[{id,startMin,endMin,temp}] } } // UI state this._uiTimer = null; // kun til at flytte "nu"-stregen this._transitionTimer = null; // timer til næste setpoint-ændring this._active = null; // { entity, id } this._lastTap = { t: 0, target: null }; this._editing = null; // { entity, blockId } // When a weekly block is opened from main timeline, auto-save the weekly draft on block save this._autoSaveWeeklyFromTimeline = false; // When a profile block is opened from main timeline, auto-save the profile draft on block save this._autoSaveProfileFromTimeline = false; // Inline edit hard-lock this._inlineEditing = false; // er et inline input aktivt? this._inlineEntity = null; // hvilken entitys Standard-felt? this._inlineTempDraft = null; // den værdi man er i gang med at skrive // Sync/debounce this._saving = false; this._saveTimer = null; this._saveDebounceMs = 300; // Delayed sync scheduler this._storeDelayTimer = null; this._storeDelayDue = 0; // Defer writes while an editor is open to avoid rapid file churn this._pendingSaveAfterEdit = false; this._markPendingSave = ()=>{ this._pendingSaveAfterEdit = true; try { this._syncSeasonalRowsToBucket(); } catch {} try { this._persistDraftNow(); } catch {} try { this._render(); } catch {} }; this._lastVersion = 0; this._lastProfileVersion = 0; // Polling watcher to pick up sensor updates in case frontend misses events this._storeWatchTimer = null; this._storeWatchMs = 10000; // ms this._storeWatchBusy = false; // avoid overlapping polls // Track if user explicitly touched boiler toggle this session this._boilerToggleTouched = false; // Track explicit edits in Holidays tab so background saves don't overwrite backend values. this._holidaysSourceTouched = false; this._holidaysEntityTouched = false; this._holidaysDatesTouched = false; this._holidaysGroupsTouched = false; this._awayTouched = false; // Cross-card sync (same dashboard + other tabs). Needed when storage is OFF, // because multiple cards otherwise won't notice localStorage changes. this._broadcastId = `ttbc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,10)}`; this._bc = null; this._onStoreUpdated = (ev)=>{ try { const d = ev?.detail || {}; if (!d || d.origin === this._broadcastId) return; if (this._inlineEditing || this._editing) return; if (this._isSettingsPopupOpen && this._isSettingsPopupOpen()) return; const key = String(d.key || ''); const myKey = this._localStoreKey(); if (!key || !myKey || key !== myKey) return; this._loadStore(false).then(()=>{ try { this._ensureSchedules(); } catch {} try { if (!this._inlineEditing && !this._editing) this._render(); } catch {} try { this._scheduleNextApply(); } catch {} }); } catch {} }; try { window.addEventListener('tt-store-updated', this._onStoreUpdated); } catch {} try { if (typeof BroadcastChannel !== 'undefined') { this._bc = new BroadcastChannel('thermostat_timeline_store_updated'); this._bc.onmessage = (m)=>{ try { this._onStoreUpdated({ detail: m?.data }); } catch {} }; } } catch { this._bc = null; } // Boiler hysteresis control tick (frontend only when Sync is OFF) this._boilerTimer = null; // Auto apply guard this._lastApplied = {}; // { [entity_id]: { min:number, temp:number } } this._lastAwayActive = null; // Track last away state to trigger immediate apply on change this._lastPresenceKey = null; // Track last active presence combo key // Track per-row tools open state this._toolsOpen = {}; // { [entity_id]: boolean } // Seasonal schedule state this._seasonAppliedKey = null; // 'winter' | 'summer' | null // Copy/paste schedule state this._copyFrom = null; // entity id of source room this._copyPayload = null; // deep-cloned schedule from source // Weekday modal state this._weeklyOpen = false; this._weeklyEntity = null; this._weeklyDraft = null; // { mode, days: { mon:[], ... } } this._weeklyDayKey = null; this._weeklyOverlayHiddenForEditor = false; // Weekdays timeline view state (browse day when showing one day across rooms) this._weekdaysViewDayKey = null; // Weekly copy/paste by group (weekdays/weekend/sat/sun/mon..sun) this._weeklyCopyFromGroup = null; this._weeklyCopyPayload = null; // Pending copy-to-rooms targets (applied on Save) this._weeklyCopyTargets = []; // Profiles modal state (per-room named daily schedules) this._profilesOpen = false; this._profilesEntity = null; // entity id for which profiles are managed this._profilesSelected = null; // currently selected profile name this._profilesOverlayHiddenForEditor = false; // track if hidden while block editor is open this._profilesDraft = null; // { name: string, blocks: [...], defaultTemp:number } this._profilesDirty = false; this._profilesSaveLock = false; // debounce guard to prevent double-prompt on Enter this._profilesRoom = null; // current room eid being edited in profiles modal // Global profile (shared via integration settings) this._globalProfile = null; // Track settings version even when storage is off this._lastSettingsVersion = 0; this._lastColorsVersion = 0; this._lastWeekdayVersion = 0; // HA event subscription (state_changed for storage sensor) this._haEvtUnsub = null; this._subStorageEid = null; // Holidays modal state this._holidayOpen = false; this._holidayEntity = null; // deprecated (use _holidayRoom) this._holidayRoom = null; // current room eid being edited in holiday modal this._holidayDraft = null; // { rooms: { [eid]: [blocks] } } this._holidayOverlayHiddenForEditor = false; // Holiday copy popup this._holidayCopyOpen = false; this._holidayCopySel = null; // value string like 'main::main', 'weekday::mon', 'profile::Name', 'presence::key' // External refresh hook (legacy). Reload store so other cards update immediately. this._onExternalRefresh = () => { try { if (this._inlineEditing || this._editing) return; this._loadStore(false).then(()=>{ try { this._ensureSchedules(); } catch {} try { this._render(); } catch {} try { this._scheduleNextApply(); } catch {} }); } catch {} }; try { window.addEventListener('thermostat-timeline-refresh', this._onExternalRefresh); } catch {} // External request to open holiday editor for a specific room this._onOpenHoliday = (ev)=>{ try { const eid = ev?.detail?.eid || (this._config?.entities||[])[0] || null; if (eid) { this._openHolidayEditor(eid); } } catch {} }; try { window.addEventListener('tt-open-holiday', this._onOpenHoliday); } catch {} // External request to open presence editor this._onOpenPresence = (ev)=>{ try { const eid = ev?.detail?.eid || (this._config?.entities||[])[0] || null; if (eid) this._openPresenceEditor(eid); } catch {} }; try { window.addEventListener('tt-open-presence', this._onOpenPresence); } catch {} // Onboarding state this._onboardOpen = false; this._onboardStep = 0; this._onboardLastUrl = null; this._onboardSlides = [ // Supports video (.mp4/.webm) or images (.webp/.png/.gif) { key: 'onboard.step_main', img: this._assetUrl('media/thermostat-guide/step1.mp4'), fallback: '/local/media/thermostat-guide/step1.mp4' }, { key: 'onboard.step_add', img: this._assetUrl('media/thermostat-guide/step2.mp4'), fallback: '/local/media/thermostat-guide/step2.mp4' }, { key: 'onboard.step_edit', img: this._assetUrl('media/thermostat-guide/step3.mp4'), fallback: '/local/media/thermostat-guide/step3.mp4' }, ]; // Global pause state (temporary suppression of set_temperature) this._pauseIndef = false; // boolean this._pauseUntilMs = 0; // epoch ms; > now => paused until time this._resumeTimer = null; // timer to auto-resume this._countdownTimer = null; // 1s updater for header countdown // Sync countdown UI (for delayed sync) this._syncTimer = null; this._syncJustUntil = 0; // ms timestamp to show "done" message briefly // Away bypass (manual override while nobody is home) this._awayBypass = false; // when true and away is active, do not clamp to away.target_c // Header tools open/close state this._headToolsOpen = false; // Presence (advanced away) modal state this._presenceOpen = false; this._presenceCombo = null; // active combo key selected for editing this._presenceRoom = null; // current room eid being edited in presence modal this._presenceDraft = null; // { combo: string, rooms: { [eid]: [blocks] } } } disconnectedCallback() { if (this._uiTimer) clearInterval(this._uiTimer); if (this._transitionTimer) clearTimeout(this._transitionTimer); if (this._saveTimer) clearTimeout(this._saveTimer); if (this._storeWatchTimer) clearInterval(this._storeWatchTimer); try { if (this._onExternalRefresh) window.removeEventListener('thermostat-timeline-refresh', this._onExternalRefresh); } catch {} try { if (this._onStoreUpdated) window.removeEventListener('tt-store-updated', this._onStoreUpdated); } catch {} try { if (this._bc && this._bc.close) this._bc.close(); } catch {} this._bc = null; try { if (this._onOpenHoliday) window.removeEventListener('tt-open-holiday', this._onOpenHoliday); } catch {} try { if (this._onOpenPresence) window.removeEventListener('tt-open-presence', this._onOpenPresence); } catch {} try { if (this._haEvtUnsub) { this._haEvtUnsub(); this._haEvtUnsub = null; } } catch {} } _broadcastStoreUpdated(key){ try { const payload = { key: String(key || ''), origin: this._broadcastId, ts: Date.now() }; try { window.dispatchEvent(new CustomEvent('tt-store-updated', { detail: payload })); } catch {} try { if (this._bc && this._bc.postMessage) this._bc.postMessage(payload); } catch {} } catch {} } // Resolve a static asset URL so it works in both dev (/local) and HACS (/hacsfiles/) installs. // Place assets next to the JS file in your HACS repo (e.g. ./media/...), // or in Home Assistant's config/www (refer using media/...). _assetUrl(p){ try { const rel = String(p||'').replace(/^[\/\\]+/, ''); // Resolve relative to the current JS module location (works under /local and /hacsfiles) return new URL(rel, import.meta.url).href; } catch (e) { try { return `/local/${String(p||'').replace(/^[\/\\]+/, '')}`; } catch { return String(p||''); } } } // ---------- Storage (file-based via API) ---------- // Stub function for backwards compatibility - always returns empty string (no sensor entities) _storageEntity(kind = 'schedules') { return ''; } _normInstanceId(raw){ try { const s = String(raw ?? '').trim(); if (!s) return 'default'; const out = []; for (const ch of s) { const ok = (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_' || ch === '-' || ch === '.'; out.push(ok ? ch : '_'); } const s2 = out.join('').slice(0, 64); return s2 || 'default'; } catch { return 'default'; } } _instanceId(){ try { if (!this._config?.instance_enabled) return 'default'; return this._normInstanceId(this._config?.instance_id || 'default'); } catch { return 'default'; } } _localStoreKey(){ try { const iid = this._instanceId(); return (this._config?.instance_enabled) ? `thermostat_timeline_store:${iid}` : 'thermostat_timeline_store'; } catch { return 'thermostat_timeline_store'; } } _hardResetKey(){ // Marker to suppress importing legacy/local drafts after a full factory reset. return 'tt_hard_reset_ts'; } _hardResetActive(maxAgeMs = 10 * 60 * 1000){ try { const raw = localStorage.getItem(this._hardResetKey()) || ''; const ts = Number(raw); if (!Number.isFinite(ts) || ts <= 0) return false; return (Date.now() - ts) >= 0 && (Date.now() - ts) <= maxAgeMs; } catch { return false; } } _hashStr(s){ try { let h = 5381; const str = String(s ?? ''); for (let i = 0; i < str.length; i++) { h = ((h << 5) + h) ^ str.charCodeAt(i); } return (h >>> 0).toString(36); } catch { return '0'; } } _instanceCfgLocalKeyLegacy(){ // Legacy v1 key (shared across cards with the same entities) try { const ents = Array.isArray(this._config?.entities) ? this._config.entities.filter(Boolean).map(String) : []; const base = `v1|${ents.slice().sort().join(',')}`; return `tt_instance_cfg:${this._hashStr(base)}`; } catch { return 'tt_instance_cfg:0'; } } _instanceCfgGroupHash(){ // Used to group similar cards; keep aligned with legacy v1 base. try { const ents = Array.isArray(this._config?.entities) ? this._config.entities.filter(Boolean).map(String) : []; const base = `v1|${ents.slice().sort().join(',')}`; return this._hashStr(base); } catch { return '0'; } } _pageHash(){ try { const loc = (window?.location?.pathname || '') + (window?.location?.search || '') + (window?.location?.hash || ''); return this._hashStr(loc); } catch { return '0'; } } _cardOrdinalInGroup(){ try { const grp = this._instanceCfgGroupHash(); const root = (typeof this.getRootNode === 'function' ? this.getRootNode() : null); const scope = (root && typeof root.querySelectorAll === 'function') ? root : document; const all = Array.from(scope.querySelectorAll('thermostat-timeline-card') || []); const same = []; for (const el of all) { try { const g = (typeof el?._instanceCfgGroupHash === 'function') ? el._instanceCfgGroupHash() : null; if (g === grp) same.push(el); } catch {} } const idx = same.indexOf(this); return idx >= 0 ? idx : 0; } catch { return 0; } } _instanceCfgLocalKey(){ // v3: stable per-card key try { const uid = this._normInstanceId(this._config?.instance_uid || ''); if (uid && uid !== 'default') return `tt_instance_cfg:v3:${uid}`; // Fallback (should rarely happen) const page = this._pageHash(); const grp = this._instanceCfgGroupHash(); const ord = this._cardOrdinalInGroup(); return `tt_instance_cfg:v2:${page}:${grp}:${ord}`; } catch { return 'tt_instance_cfg:v3:0'; } } _instanceCfgLocalKeyV2(){ try { const page = this._pageHash(); const grp = this._instanceCfgGroupHash(); const ord = this._cardOrdinalInGroup(); return `tt_instance_cfg:v2:${page}:${grp}:${ord}`; } catch { return 'tt_instance_cfg:v2:0:0:0'; } } _genInstanceId(){ try { const suffix = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2,6)}`; return this._normInstanceId(`thermostat_pro_timeline_${suffix}`); } catch { return this._normInstanceId(`thermostat_pro_timeline_${Math.random().toString(36).slice(2,10)}`); } } _loadPersistedInstanceCfg(){ try { const key = this._instanceCfgLocalKey(); const raw = localStorage.getItem(key) || ''; if (raw) { const obj = JSON.parse(raw); if (!obj || typeof obj !== 'object') return null; const enabled = !!obj.enabled; const id = this._normInstanceId(obj.id || ''); return { enabled, id }; } // v2 fallback (older per-card key) try { const k2 = this._instanceCfgLocalKeyV2(); const r2 = localStorage.getItem(k2) || ''; if (r2) { const o2 = JSON.parse(r2); const enabled2 = !!o2?.enabled; const id2 = this._normInstanceId(o2?.id || ''); if (enabled2 && id2) { try { this._persistInstanceCfg(true, id2); } catch {} return { enabled: true, id: id2 }; } } } catch {} // Migration from legacy shared key (prevents two identical cards getting the same ID) const legacyKey = this._instanceCfgLocalKeyLegacy(); const legacyRaw = localStorage.getItem(legacyKey) || ''; if (!legacyRaw) return null; const objL = JSON.parse(legacyRaw); if (!objL || typeof objL !== 'object') return null; const enabledL = !!objL.enabled; const idL = this._normInstanceId(objL.id || ''); if (!enabledL || !idL) return null; const ord = this._cardOrdinalInGroup(); if (ord === 0) { try { this._persistInstanceCfg(true, idL); } catch {} return { enabled: true, id: idL }; } // For subsequent cards with identical config, auto-generate a new id. const newId = this._genInstanceId(); try { this._persistInstanceCfg(true, newId); } catch {} try { this._copyInstanceData(idL, newId); } catch {} return { enabled: true, id: newId }; } catch { return null; } } _persistInstanceCfg(enabled, id){ try { const obj = { enabled: !!enabled, id: this._normInstanceId(id || '') }; localStorage.setItem(this._instanceCfgLocalKey(), JSON.stringify(obj)); } catch {} } async _copyInstanceData(oldId, newId){ try { const oldI = this._normInstanceId(oldId); const newI = this._normInstanceId(newId); if (!oldI || !newI || oldI === newI) return; // Backend store copy (keeps old instance intact) if (this._config?.storage_enabled && this._hass?.callApi && this._hass?.callService) { try { const api = await this._hass.callApi('GET', `thermostat_timeline/state?instance_id=${encodeURIComponent(oldI)}`); const payload = { force: true, instance_id: newI, activate: false }; if (api?.schedules) payload.schedules = api.schedules; if (api?.settings) payload.settings = api.settings; if (api?.colors) payload.colors = api.colors; if (api?.weekdays) payload.weekdays = api.weekdays; if (api?.profiles) payload.profiles = api.profiles; await this._hass.callService('thermostat_timeline', 'set_store', payload); return; } catch {} } // Local-only copy try { this._copyInstanceLocalKeys(oldI, newI); } catch {} } catch {} } async _renameInstanceData(oldId, newId){ try { const oldI = this._normInstanceId(oldId); const newI = this._normInstanceId(newId); if (!oldI || !newI || oldI === newI) return false; // Backend rename/move (does not create a second instance) if (this._config?.storage_enabled && this._hass?.callService) { try { await this._hass.callService('thermostat_timeline', 'rename_instance', { old_instance_id: oldI, new_instance_id: newI, }); return true; } catch {} } // Local-only rename/move try { return !!this._moveInstanceLocalKeys(oldI, newI); } catch {} } catch {} return false; } _hydrateInstanceCfgFromLocal(){ try { const curEnabled = !!this._config?.instance_enabled; const curId = String(this._config?.instance_id || '').trim(); if (curEnabled && curId) { this._persistInstanceCfg(true, curId); return; } const persisted = this._loadPersistedInstanceCfg(); if (persisted && persisted.enabled && persisted.id) { this._config.instance_enabled = true; this._config.instance_id = persisted.id; } } catch {} } _apiSupported(){ try { return !!(this._hass?.callApi) || !!(this._hass?.auth?.data?.access_token); } catch { return false; } } async _apiGet(path){ const url = path.startsWith('/') ? path : `/api/${path}`; // 1) Prefer Home Assistant's callApi when available if (this._hass?.callApi) { try { return await this._hass.callApi('GET', path); } catch (e) {} } // 2) Fallback: direct fetch with bearer token (mobile/webview safety) try { const token = this._hass?.auth?.data?.access_token; const resp = await fetch(url, { method: 'GET', headers: token ? { Authorization: `Bearer ${token}` } : {}, credentials: 'same-origin' }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return await resp.json(); } catch (e2) { return null; } } async _apiFetchState(){ try { const iid = this._instanceId(); const q = (this._config?.instance_enabled && iid) ? `?instance_id=${encodeURIComponent(iid)}` : ''; return await this._apiGet(`thermostat_timeline/state${q}`); } catch { return await this._apiGet('thermostat_timeline/state'); } } async _apiFetchVersion(){ return await this._apiGet('thermostat_timeline/version'); } async _loadStore(preferSensor = true) { const hardReset = this._hardResetActive(); let stMain = null, stSet = null, stCol = null, stWeek = null, stProf = null; // Load from integration API (file-based storage) if (this._config?.storage_enabled) { const api = await this._apiFetchState(); if (api && typeof api === 'object') { stMain = { state: Number(api.version || 0), attributes: { schedules: api.schedules, weekdays: api.weekdays, profiles: api.profiles, settings: api.settings, colors: api.colors } }; stSet = { state: Number(api.settings_version || api.version || 0), attributes: { settings: api.settings, colors: api.colors } }; stCol = { state: Number(api.colors_version || api.version || 0), attributes: { colors: api.colors } }; stWeek = { state: Number(api.weekday_version || api.version || 0), attributes: { weekdays: api.weekdays } }; stProf = { state: Number(api.profile_version || api.version || 0), attributes: { profiles: api.profiles, settings: api.settings } }; } } if (stMain || stSet || stCol || stWeek || stProf) { try { const attrsMain = stMain?.attributes || {}; const attrsSet = stSet?.attributes || {}; const attrsCol = stCol?.attributes || {}; const attrsWeek = stWeek?.attributes || {}; const attrsProf = stProf?.attributes || {}; let storUnit = 'C'; const schedSource = (attrsMain?.schedules && typeof attrsMain.schedules === "object") ? attrsMain : ((attrsSet?.schedules && typeof attrsSet.schedules === 'object') ? attrsSet : null); if (schedSource) { // Unwrap if an older/incorrect payload put the entire object under 'schedules' let sch = JSON.parse(JSON.stringify(schedSource.schedules)); if (sch && sch.schedules && typeof sch.schedules === 'object') sch = sch.schedules; // Canonical storage is °C. For backwards compatibility, detect legacy stores that saved °F values. try { const su = String((schedSource?.settings?.storage_temp_unit || '')).toUpperCase(); const legacyDisplayUnit = String((schedSource?.settings?.temp_unit || 'C')).toUpperCase(); const maxT = this._maxTempInSchedules(sch); const looksLikeF = Number.isFinite(maxT) && maxT > 45; const legacyWasF = (legacyDisplayUnit === 'F'); const storeWasF = (su === 'F') || (!su && (legacyWasF || looksLikeF)); if (storeWasF) sch = this._convertSchedulesTemps(sch, 'C'); } catch {} this._schedules = sch; try { this._seasonAppliedKey = null; } catch {} // If there is a pending delayed sync (due stored locally), prefer draft schedules try { if (hardReset) throw new Error('hard reset active'); const dueRaw = localStorage.getItem(this._syncDueKey()) || ''; const due = Number(dueRaw); if (Number.isFinite(due) && due > Date.now()) { const dr = localStorage.getItem(this._draftKey()) || ''; if (dr) { const parsed = JSON.parse(dr); const dsch = parsed?.schedules && parsed.schedules.schedules ? parsed.schedules.schedules : (parsed?.schedules || parsed || {}); if (dsch && typeof dsch === 'object') { this._schedules = dsch; try { this._seasonAppliedKey = null; } catch {} } } } } catch {} } // Merge weekday-only payload into schedules (weekly + weekly_modes) try { const weekSrc = (attrsWeek?.weekdays && typeof attrsWeek.weekdays === 'object') ? attrsWeek.weekdays : ((attrsMain?.weekdays && typeof attrsMain.weekdays === 'object') ? attrsMain.weekdays : null); if (weekSrc && typeof weekSrc === 'object') { for (const eid of Object.keys(weekSrc)) { const wk = weekSrc[eid]; if (!wk || typeof wk !== 'object') continue; const row = this._schedules[eid] || {}; if (wk.weekly) row.weekly = wk.weekly; if (wk.weekly_modes) row.weekly_modes = wk.weekly_modes; this._schedules[eid] = row; } } } catch {} // Merge profile-only payload into schedules (profiles + activeProfile) try { let profSrc = null; if (attrsProf?.profiles && typeof attrsProf.profiles === 'object') profSrc = attrsProf.profiles; else if (attrsMain?.profiles && typeof attrsMain.profiles === 'object') profSrc = attrsMain.profiles; if (profSrc && typeof profSrc === 'object') { // Canonical storage is °C. For backwards compatibility, detect legacy °F profile payloads. try { const su = String((attrsProf?.settings?.storage_temp_unit || attrsMain?.settings?.storage_temp_unit || '')).toUpperCase(); const legacyDisplayUnit = String((attrsProf?.settings?.temp_unit || attrsMain?.settings?.temp_unit || 'C')).toUpperCase(); const maxT = this._maxTempInSchedules(profSrc); const looksLikeF = Number.isFinite(maxT) && maxT > 45; const legacyWasF = (legacyDisplayUnit === 'F'); const storeWasF = (su === 'F') || (!su && (legacyWasF || looksLikeF)); if (storeWasF) profSrc = this._convertSchedulesTemps(profSrc, 'C'); } catch {} for (const eid of Object.keys(profSrc)) { const src = profSrc[eid]; if (!src || typeof src !== 'object') continue; const row = this._schedules[eid] || {}; if (src.profiles && typeof src.profiles === 'object') row.profiles = src.profiles; if (typeof src.activeProfile === 'string' || src.activeProfile === null) row.activeProfile = src.activeProfile; this._schedules[eid] = row; } } } catch {} // Optional shared settings (prefer dedicated settings sensor when provided) let settingsSource = null; if (attrsSet.settings && typeof attrsSet.settings === 'object') settingsSource = attrsSet.settings; else if (attrsMain.settings && typeof attrsMain.settings === 'object') settingsSource = attrsMain.settings; try { if (storUnit === 'C' && settingsSource?.temp_unit) storUnit = String(settingsSource.temp_unit).toUpperCase(); } catch {} let colorsSource = null; if (attrsCol.colors && typeof attrsCol.colors === 'object') colorsSource = attrsCol.colors; else if (attrsSet.colors && typeof attrsSet.colors === 'object') colorsSource = attrsSet.colors; else if (settingsSource && (settingsSource.color_ranges || settingsSource.color_global)) { colorsSource = { color_ranges: settingsSource.color_ranges, color_global: settingsSource.color_global }; } if (settingsSource) { try { const s0 = settingsSource; // Canonical storage is °C. For backwards compatibility, detect legacy stores that saved numeric settings in °F. let s = s0; try { const su = String(s0?.storage_temp_unit || '').toUpperCase(); const legacyDisplayUnit = String(s0?.temp_unit||'C').toUpperCase(); const nums = [s0?.default_temp, s0?.min_temp, s0?.max_temp, s0?.boiler_min_temp, s0?.boiler_max_temp, s0?.away?.target_c].map(v=>Number(v)).filter(v=>Number.isFinite(v)); const maxN = nums.length ? Math.max(...nums) : -Infinity; const looksLikeF = Number.isFinite(maxN) && maxN > 45; const legacyWasF = (legacyDisplayUnit === 'F'); const storeWasF = (su === 'F') || (!su && (legacyWasF || looksLikeF)); if (storeWasF) { const conv = (n)=>{ const v=Number(n); return Number.isFinite(v)? this._fToC(v): n; }; const so = { ...s0 }; if (so.default_temp !== undefined) so.default_temp = conv(so.default_temp); if (so.min_temp !== undefined) so.min_temp = conv(so.min_temp); if (so.max_temp !== undefined) so.max_temp = conv(so.max_temp); if (so.boiler_min_temp !== undefined) so.boiler_min_temp = conv(so.boiler_min_temp); if (so.boiler_max_temp !== undefined) so.boiler_max_temp = conv(so.boiler_max_temp); if (so.away && typeof so.away==='object') { so.away = { ...so.away }; if (so.away.target_c !== undefined) so.away.target_c = conv(so.away.target_c); } try { if (so.presence_sensor_temps && typeof so.presence_sensor_temps === 'object') { const out = {}; for (const k of Object.keys(so.presence_sensor_temps||{})) out[String(k)] = conv(so.presence_sensor_temps[k]); so.presence_sensor_temps = out; } } catch {} s = so; } } catch {} // Pick up format/unit from shared storage when not YAML-provided if (typeof s.time_12h === 'boolean' && !this._yamlProvided?.time_12h) this._config.time_12h = !!s.time_12h; if (typeof s.temp_unit === 'string' && !this._yamlProvided?.temp_unit) this._config.temp_unit = (String(s.temp_unit).toUpperCase()==='F' ? 'F' : 'C'); // Pick up time_source from shared storage when not YAML-provided if (typeof s.time_source === 'string' && !this._yamlProvided?.time_source) this._config.time_source = (s.time_source === 'ha' ? 'ha' : 'browser'); if (s.color_ranges && typeof s.color_ranges==='object' && !this._yamlProvided?.color_ranges) this._config.color_ranges = { ...s.color_ranges }; if (typeof s.color_global === 'boolean') this._config.color_global = !!s.color_global; if (Number.isFinite(s.default_temp)) this._config.default_temp = Number(s.default_temp); if (Number.isFinite(s.min_temp)) this._config.min_temp = Number(s.min_temp); if (Number.isFinite(s.max_temp)) this._config.max_temp = Number(s.max_temp); if (Number.isFinite(s.row_height)) this._config.row_height = Math.max(30, Math.min(200, Math.round(Number(s.row_height)))); // Rooms from shared storage (used by Settings popup) try { if (Array.isArray(s.entities)) { const ents = s.entities.filter(Boolean).map(String); this._config.entities = ents; const rawModes = Array.isArray(s.room_use_input_number) ? s.room_use_input_number.map(v=>!!v) : []; while (rawModes.length < ents.length) rawModes.push(false); this._config.room_use_input_number = ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return true; if (String(eid).startsWith('climate.')) return false; return !!rawModes[idx]; }); const rawTemp = Array.isArray(s.room_use_temp_sensor) ? s.room_use_temp_sensor.map(v=>!!v) : []; while (rawTemp.length < ents.length) rawTemp.push(false); this._config.room_use_temp_sensor = ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return false; if (!String(eid).startsWith('climate.')) return false; return !!rawTemp[idx]; }); } } catch {} if (s.merges && typeof s.merges === 'object' && !this._yamlProvided?.merges) this._config.merges = { ...s.merges }; if (s.labels && typeof s.labels === 'object' && !this._yamlProvided?.labels) this._config.labels = { ...s.labels }; if (s.temp_sensors && typeof s.temp_sensors === 'object' && !this._yamlProvided?.temp_sensors) this._config.temp_sensors = { ...s.temp_sensors }; if (s.turn_on && typeof s.turn_on === 'object' && !this._yamlProvided?.turn_on) this._config.turn_on = { ...s.turn_on }; if (s.presence_sensors && typeof s.presence_sensors === 'object' && !this._yamlProvided?.presence_sensors) this._config.presence_sensors = { ...s.presence_sensors }; try { if (s.presence_sensor_temps && typeof s.presence_sensor_temps === 'object' && !this._yamlProvided?.presence_sensor_temps) { const out = {}; for (const k of Object.keys(s.presence_sensor_temps||{})) { const v = Number(s.presence_sensor_temps[k]); if (Number.isFinite(v)) out[String(k)] = v; } this._config.presence_sensor_temps = out; } } catch {} try { if (s.presence_sensor_delays && typeof s.presence_sensor_delays === 'object' && !this._yamlProvided?.presence_sensor_delays) { const out = {}; for (const k of Object.keys(s.presence_sensor_delays||{})) { const v = s.presence_sensor_delays?.[k]; if (!v || typeof v !== 'object') continue; const onS = Number(v.on_s); const offS = Number(v.off_s); const obj = {}; if (Number.isFinite(onS) && onS >= 0) obj.on_s = onS; if (Number.isFinite(offS) && offS >= 0) obj.off_s = offS; if (Object.keys(obj).length) out[String(k)] = obj; } this._config.presence_sensor_delays = out; } } catch {} try { if (s.presence_sensor_delay_units && typeof s.presence_sensor_delay_units === 'object' && !this._yamlProvided?.presence_sensor_delay_units) { const out = {}; for (const k of Object.keys(s.presence_sensor_delay_units||{})) { const u = String(s.presence_sensor_delay_units?.[k] || '').toLowerCase().trim(); out[String(k)] = (u === 'seconds') ? 'seconds' : 'minutes'; } this._config.presence_sensor_delay_units = out; } } catch {} try { if (typeof s.presence_live_header === 'boolean') { this._config.presence_live_header = !!s.presence_live_header; } } catch {} // Fallbacks for Rooms tab settings if backend didn't persist them: // Merge from local browser copy to avoid losing user edits (e.g., custom room names) try { if (hardReset) throw new Error('hard reset active'); const rawLocal = localStorage.getItem(this._localStoreKey()) || ''; if (rawLocal) { const parsed = JSON.parse(rawLocal); const ls = parsed?.settings || {}; // Only merge when YAML did not provide the key and backend didn't send it const isEmptyObj = (o)=>{ try { return o && typeof o==='object' && Object.keys(o).length===0; } catch { return false; } }; if (!this._yamlProvided?.labels && (!s.labels || typeof s.labels !== 'object' || isEmptyObj(s.labels))) { if (ls.labels && typeof ls.labels === 'object') { this._config.labels = { ...(this._config.labels || {}), ...ls.labels }; } } if (!this._yamlProvided?.merges && (!s.merges || typeof s.merges !== 'object' || isEmptyObj(s.merges))) { if (ls.merges && typeof ls.merges === 'object') { this._config.merges = { ...(this._config.merges || {}), ...ls.merges }; } } if (!this._yamlProvided?.temp_sensors && (!s.temp_sensors || typeof s.temp_sensors !== 'object' || isEmptyObj(s.temp_sensors))) { if (ls.temp_sensors && typeof ls.temp_sensors === 'object') { this._config.temp_sensors = { ...(this._config.temp_sensors || {}), ...ls.temp_sensors }; } } if (!this._yamlProvided?.turn_on && (!s.turn_on || typeof s.turn_on !== 'object' || isEmptyObj(s.turn_on))) { if (ls.turn_on && typeof ls.turn_on === 'object') { this._config.turn_on = { ...(this._config.turn_on || {}), ...ls.turn_on }; } } if (!this._yamlProvided?.presence_sensors && (!s.presence_sensors || typeof s.presence_sensors !== 'object' || isEmptyObj(s.presence_sensors))) { if (ls.presence_sensors && typeof ls.presence_sensors === 'object') { this._config.presence_sensors = { ...(this._config.presence_sensors || {}), ...ls.presence_sensors }; } } if (!this._yamlProvided?.presence_sensor_temps && (!s.presence_sensor_temps || typeof s.presence_sensor_temps !== 'object' || isEmptyObj(s.presence_sensor_temps))) { if (ls.presence_sensor_temps && typeof ls.presence_sensor_temps === 'object') { this._config.presence_sensor_temps = { ...(this._config.presence_sensor_temps || {}), ...ls.presence_sensor_temps }; } } if (!this._yamlProvided?.presence_sensor_delays && (!s.presence_sensor_delays || typeof s.presence_sensor_delays !== 'object' || isEmptyObj(s.presence_sensor_delays))) { if (ls.presence_sensor_delays && typeof ls.presence_sensor_delays === 'object') { this._config.presence_sensor_delays = { ...(this._config.presence_sensor_delays || {}), ...ls.presence_sensor_delays }; } } if (!this._yamlProvided?.presence_sensor_delay_units && (!s.presence_sensor_delay_units || typeof s.presence_sensor_delay_units !== 'object' || isEmptyObj(s.presence_sensor_delay_units))) { if (ls.presence_sensor_delay_units && typeof ls.presence_sensor_delay_units === 'object') { this._config.presence_sensor_delay_units = { ...(this._config.presence_sensor_delay_units || {}), ...ls.presence_sensor_delay_units }; } } if (!this._yamlProvided?.presence_live_header && typeof s.presence_live_header !== 'boolean') { if (typeof ls.presence_live_header === 'boolean') { this._config.presence_live_header = !!ls.presence_live_header; } } if (Array.isArray(this._config.entities) && (!Array.isArray(s.entities) || !s.entities.length)) { if (Array.isArray(ls.entities) && ls.entities.length) { this._config.entities = ls.entities.filter(Boolean).map(String); // room_use_input_number alignment const rawModes = Array.isArray(ls.room_use_input_number) ? ls.room_use_input_number.map(v=>!!v) : []; while (rawModes.length < this._config.entities.length) rawModes.push(false); this._config.room_use_input_number = this._config.entities.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return true; if (String(eid).startsWith('climate.')) return false; return !!rawModes[idx]; }); const rawTemp = Array.isArray(ls.room_use_temp_sensor) ? ls.room_use_temp_sensor.map(v=>!!v) : []; while (rawTemp.length < this._config.entities.length) rawTemp.push(false); this._config.room_use_temp_sensor = this._config.entities.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return false; if (!String(eid).startsWith('climate.')) return false; return !!rawTemp[idx]; }); } } // Boiler settings fallbacks: if backend omitted these keys, pick last known values from localStorage try { const hasBool = (v)=> (typeof v === 'boolean' || typeof v === 'number'); if (!this._yamlProvided?.boiler_enabled && !hasBool(s.boiler_enabled)) { if (hasBool(ls.boiler_enabled)) this._config.boiler_enabled = !!ls.boiler_enabled; } if (!this._yamlProvided?.boiler_switch && typeof s.boiler_switch !== 'string') { if (typeof ls.boiler_switch === 'string') this._config.boiler_switch = String(ls.boiler_switch || ''); } if (!this._yamlProvided?.boiler_rooms && !Array.isArray(s.boiler_rooms) && s.boiler_rooms !== null) { if (Array.isArray(ls.boiler_rooms)) this._config.boiler_rooms = ls.boiler_rooms.filter(Boolean).map(String); else if (ls.boiler_rooms === null) this._config.boiler_rooms = null; } const numOrNull = (v)=> (v === null || Number.isFinite(Number(v))); if (!this._yamlProvided?.boiler_on_offset && !numOrNull(s.boiler_on_offset)) { if (numOrNull(ls.boiler_on_offset)) { const v = (ls.boiler_on_offset === null) ? 0 : Number(ls.boiler_on_offset); if (Number.isFinite(v)) this._config.boiler_on_offset = Math.max(-10, Math.min(10, v)); } } if (!this._yamlProvided?.boiler_off_offset && !numOrNull(s.boiler_off_offset)) { if (numOrNull(ls.boiler_off_offset)) { const v = (ls.boiler_off_offset === null) ? 0 : Number(ls.boiler_off_offset); if (Number.isFinite(v)) this._config.boiler_off_offset = Math.max(-10, Math.min(10, v)); } } if (!this._yamlProvided?.boiler_temp_sensor && typeof s.boiler_temp_sensor !== 'string') { if (typeof ls.boiler_temp_sensor === 'string') this._config.boiler_temp_sensor = String(ls.boiler_temp_sensor || ''); } if (!this._yamlProvided?.boiler_min_temp && !numOrNull(s.boiler_min_temp)) { if (numOrNull(ls.boiler_min_temp)) { const v = (ls.boiler_min_temp === null) ? 20 : Number(ls.boiler_min_temp); if (Number.isFinite(v)) this._config.boiler_min_temp = v; } } if (!this._yamlProvided?.boiler_max_temp && !numOrNull(s.boiler_max_temp)) { if (numOrNull(ls.boiler_max_temp)) { const v = (ls.boiler_max_temp === null) ? 25 : Number(ls.boiler_max_temp); if (Number.isFinite(v)) this._config.boiler_max_temp = v; } } if (!this._yamlProvided?.boiler_multi_enabled && !hasBool(s.boiler_multi_enabled)) { if (hasBool(ls.boiler_multi_enabled)) this._config.boiler_multi_enabled = !!ls.boiler_multi_enabled; } if (!this._yamlProvided?.boiler_room_settings && (!s.boiler_room_settings || typeof s.boiler_room_settings !== 'object')) { if (ls.boiler_room_settings && typeof ls.boiler_room_settings === 'object') { try { this._config.boiler_room_settings = JSON.parse(JSON.stringify(ls.boiler_room_settings)); } catch { this._config.boiler_room_settings = { ...(ls.boiler_room_settings||{}) }; } } } } catch {} } } catch {} if ((typeof s.boiler_enabled === 'boolean' || typeof s.boiler_enabled === 'number') && !this._yamlProvided?.boiler_enabled) this._config.boiler_enabled = !!s.boiler_enabled; if (typeof s.boiler_switch === 'string') { const curSw = String(this._config?.boiler_switch || ''); if (!this._yamlProvided?.boiler_switch || !curSw) this._config.boiler_switch = s.boiler_switch || ''; } try { const rawDom = String(s.boiler_switch_domain || '').toLowerCase().trim(); const domFromSetting = (rawDom === 'input_boolean') ? 'input_boolean' : 'switch'; const swDom = String(s.boiler_switch || '').split('.')[0]; const domFromSwitch = (swDom === 'input_boolean' || swDom === 'switch') ? swDom : domFromSetting; const curSw = String(this._config?.boiler_switch || ''); if (!this._yamlProvided?.boiler_switch_domain || !curSw) this._config.boiler_switch_domain = domFromSwitch; } catch {} if ((typeof s.boiler_multi_enabled === 'boolean' || typeof s.boiler_multi_enabled === 'number') && !this._yamlProvided?.boiler_multi_enabled) this._config.boiler_multi_enabled = !!s.boiler_multi_enabled; if (s.boiler_room_settings && typeof s.boiler_room_settings === 'object' && !this._yamlProvided?.boiler_room_settings) { try { this._config.boiler_room_settings = JSON.parse(JSON.stringify(s.boiler_room_settings)); } catch { this._config.boiler_room_settings = { ...(s.boiler_room_settings||{}) }; } } if (!this._yamlProvided?.boiler_rooms) { if (Array.isArray(s.boiler_rooms)) this._config.boiler_rooms = s.boiler_rooms.filter(Boolean).map(String); else if (s.boiler_rooms === null) this._config.boiler_rooms = null; } if (!this._yamlProvided?.boiler_on_offset) { if (s.boiler_on_offset === null) this._config.boiler_on_offset = 0; else { const v = Number(s.boiler_on_offset); if (Number.isFinite(v)) this._config.boiler_on_offset = Math.max(-10, Math.min(10, v)); } } if (!this._yamlProvided?.boiler_off_offset) { if (s.boiler_off_offset === null) this._config.boiler_off_offset = 0; else { const v = Number(s.boiler_off_offset); if (Number.isFinite(v)) this._config.boiler_off_offset = Math.max(-10, Math.min(10, v)); } } if (typeof s.boiler_temp_sensor === 'string' && !this._yamlProvided?.boiler_temp_sensor) this._config.boiler_temp_sensor = s.boiler_temp_sensor || ''; if (!this._yamlProvided?.boiler_min_temp) { if (s.boiler_min_temp === null) this._config.boiler_min_temp = 20; else { const v = Number(s.boiler_min_temp); if (Number.isFinite(v)) this._config.boiler_min_temp = v; } } if (!this._yamlProvided?.boiler_max_temp) { if (s.boiler_max_temp === null) this._config.boiler_max_temp = 25; else { const v = Number(s.boiler_max_temp); if (Number.isFinite(v)) this._config.boiler_max_temp = v; } } try { this._awayBypass = !!s.away_bypass; } catch {} if (typeof s.per_room_defaults === 'boolean') this._config.per_room_defaults = !!s.per_room_defaults; if (typeof s.show_pause_button === 'boolean') this._config.show_pause_button = !!s.show_pause_button; if (typeof s.show_room_temp === 'boolean' && !this._yamlProvided?.show_room_temp) this._config.show_room_temp = !!s.show_room_temp; if (typeof s.pause_sensor_enabled === 'boolean' && !this._yamlProvided?.pause_sensor_enabled) this._config.pause_sensor_enabled = !!s.pause_sensor_enabled; if (typeof s.pause_sensor_entity === 'string' && !this._yamlProvided?.pause_sensor_entity) this._config.pause_sensor_entity = String(s.pause_sensor_entity || ''); if ((typeof s.presence_sensor_enabled === 'boolean' || typeof s.presence_sensor_enabled === 'number') && !this._yamlProvided?.presence_sensor_enabled) this._config.presence_sensor_enabled = !!s.presence_sensor_enabled; if (typeof s.profiles_enabled === 'boolean' && !this._yamlProvided?.profiles_enabled) this._config.profiles_enabled = !!s.profiles_enabled; if (typeof s.auto_apply_enabled === 'boolean') this._config.auto_apply = !!s.auto_apply_enabled; if (typeof s.apply_on_edit === 'boolean') this._config.apply_on_edit = !!s.apply_on_edit; if (typeof s.apply_on_default_change === 'boolean') this._config.apply_on_default_change = !!s.apply_on_default_change; // Final fallback: if backend omitted or defaulted boiler settings, prefer last local copy to keep UI consistent after hard refresh try { const rawLocal2 = localStorage.getItem(this._localStoreKey()) || ''; if (rawLocal2) { const parsed2 = JSON.parse(rawLocal2); const ls2 = parsed2?.settings || {}; const has = (obj, k)=> Object.prototype.hasOwnProperty.call(obj||{}, k); if (!this._yamlProvided?.boiler_enabled && has(ls2, 'boiler_enabled')) this._config.boiler_enabled = !!ls2.boiler_enabled; if (!this._yamlProvided?.boiler_switch && has(ls2, 'boiler_switch')) this._config.boiler_switch = String(ls2.boiler_switch || ''); if (!this._yamlProvided?.boiler_rooms && (has(ls2, 'boiler_rooms'))) { if (Array.isArray(ls2.boiler_rooms)) this._config.boiler_rooms = ls2.boiler_rooms.filter(Boolean).map(String); else if (ls2.boiler_rooms === null) this._config.boiler_rooms = null; } if (!this._yamlProvided?.boiler_on_offset && has(ls2, 'boiler_on_offset')) { const v = (ls2.boiler_on_offset === null) ? 0 : Number(ls2.boiler_on_offset); if (Number.isFinite(v)) this._config.boiler_on_offset = Math.max(-10, Math.min(10, v)); } if (!this._yamlProvided?.boiler_off_offset && has(ls2, 'boiler_off_offset')) { const v = (ls2.boiler_off_offset === null) ? 0 : Number(ls2.boiler_off_offset); if (Number.isFinite(v)) this._config.boiler_off_offset = Math.max(-10, Math.min(10, v)); } if (!this._yamlProvided?.boiler_temp_sensor && has(ls2, 'boiler_temp_sensor')) this._config.boiler_temp_sensor = String(ls2.boiler_temp_sensor || ''); if (!this._yamlProvided?.boiler_min_temp && has(ls2, 'boiler_min_temp')) { const v = (ls2.boiler_min_temp === null) ? 20 : Number(ls2.boiler_min_temp); if (Number.isFinite(v)) this._config.boiler_min_temp = v; } if (!this._yamlProvided?.boiler_max_temp && has(ls2, 'boiler_max_temp')) { const v = (ls2.boiler_max_temp === null) ? 25 : Number(ls2.boiler_max_temp); if (Number.isFinite(v)) this._config.boiler_max_temp = v; } if (!this._yamlProvided?.boiler_multi_enabled && has(ls2, 'boiler_multi_enabled')) this._config.boiler_multi_enabled = !!ls2.boiler_multi_enabled; if (!this._yamlProvided?.boiler_room_settings && has(ls2, 'boiler_room_settings')) { try { this._config.boiler_room_settings = JSON.parse(JSON.stringify(ls2.boiler_room_settings)); } catch { this._config.boiler_room_settings = { ...(ls2.boiler_room_settings||{}) }; } } } } catch {} // Open Window Detection from shared storage try { if (s.open_window && typeof s.open_window === 'object') this._config.open_window = { ...s.open_window }; } catch {} // Holidays shared settings try { if (typeof s.holidays_enabled === 'boolean') this._config.holidays_enabled = !!s.holidays_enabled; if (typeof s.holidays_source === 'string' && !this._yamlProvided?.holidays_source) this._config.holidays_source = s.holidays_source === 'manual' ? 'manual' : 'calendar'; if (typeof s.holidays_entity === 'string' && !this._yamlProvided?.holidays_entity) this._config.holidays_entity = s.holidays_entity || ''; if (Array.isArray(s.holidays_dates) && !this._yamlProvided?.holidays_dates) this._config.holidays_dates = [...s.holidays_dates]; if (Array.isArray(s.holidays_groups)) this._config.holidays_groups = [...s.holidays_groups]; } catch {} // Weekdays settings (shared) if (this._config?.respect_storage_weekdays !== false) { try { if (typeof s.weekdays_enabled === 'boolean') this._config.weekdays_enabled = !!s.weekdays_enabled; } catch {} try { if (typeof s.weekdays_mode === 'string') this._config.weekdays_mode = s.weekdays_mode; } catch {} try { if (typeof s.seasonal_enabled === 'boolean') this._config.seasonal_enabled = !!s.seasonal_enabled; } catch {} try { if (typeof s.seasonal_mode === 'string') this._config.seasonal_mode = (s.seasonal_mode === 'summer' ? 'summer' : 'winter'); } catch {} try { if (typeof s.weekdays_view === 'string') this._config.weekdays_view = s.weekdays_view; } catch {} try { if (typeof s.weekdays_view_switch_in_timeline === 'boolean') this._config.weekdays_view_switch_in_timeline = !!s.weekdays_view_switch_in_timeline; } catch {} try { if (typeof s.weekdays_selected_room === 'string') this._config.weekdays_selected_room = s.weekdays_selected_room; } catch {} } // Pick up a global profile selection from shared storage try { this._globalProfile = (typeof s.global_profile === 'string' && s.global_profile) ? String(s.global_profile) : null; } catch {} // Backup settings try { if (typeof s.backup_auto_enabled === 'boolean') this._config.backup_auto_enabled = !!s.backup_auto_enabled; if (Number.isFinite(s.backup_interval_days)) this._config.backup_interval_days = Math.max(1, Math.min(365, Math.round(Number(s.backup_interval_days)))); else if (Number.isFinite(s.backup_interval_min)) this._config.backup_interval_days = Math.max(1, Math.min(365, Math.ceil(Number(s.backup_interval_min)/1440))); } catch {} if (s.away && typeof s.away === 'object') { try { const a = s.away; this._config.away = { enabled: !!a.enabled, persons: Array.isArray(a.persons) ? [...a.persons].filter(Boolean).map(String) : [], target_c: Number.isFinite(a.target_c) ? Number(a.target_c) : (this._config.away?.target_c ?? 17), advanced_enabled: !!(a.advanced_enabled ?? this._config?.away?.advanced_enabled ?? false), combos: (a.combos && typeof a.combos === 'object') ? { ...a.combos } : (this._config?.away?.combos || {}), delay_enabled: !!(a.delay_enabled ?? this._config?.away?.delay_enabled ?? false), delay_value: Number.isFinite(a.delay_value) ? Number(a.delay_value) : (this._config?.away?.delay_value ?? 0), delay_unit: String(a.delay_unit || this._config?.away?.delay_unit || 'minutes') }; } catch {} } // Guard: Away/Presence should not be active until at least one person is selected. // Keep the enabled toggle; only disable advanced features when no persons are set. try { const ap = Array.isArray(this._config?.away?.persons) ? this._config.away.persons.filter(Boolean).map(String) : []; this._config.away.persons = ap; if (!ap.length) { this._config.away.advanced_enabled = false; } } catch {} // Pause flags from shared storage try { this._pauseIndef = !!s.pause_indef; const pu = Number(s.pause_until_ms); this._pauseUntilMs = Number.isFinite(pu) ? pu : 0; } catch {} // Delayed sync due time from shared storage (persist across reloads) try { const due = Number(s.sync_pending_until_ms); if (Number.isFinite(due) && due > Date.now()) { this._storeDelayDue = due; try { localStorage.setItem(this._syncDueKey(), String(due)); } catch {} // While a delayed sync is pending, prefer local browser draft for settings that affect UI immediately try { if (hardReset) throw new Error('hard reset active'); const rawLocal = localStorage.getItem(this._localStoreKey()) || ''; if (rawLocal) { const parsed = JSON.parse(rawLocal); const ls = parsed?.settings || {}; if (typeof ls.holidays_enabled === 'boolean') this._config.holidays_enabled = !!ls.holidays_enabled; if (typeof ls.holidays_source === 'string') this._config.holidays_source = (ls.holidays_source === 'manual') ? 'manual' : 'calendar'; if (typeof ls.holidays_entity === 'string') this._config.holidays_entity = String(ls.holidays_entity||''); if (Array.isArray(ls.holidays_dates)) this._config.holidays_dates = [...ls.holidays_dates]; if (Array.isArray(ls.holidays_groups)) this._config.holidays_groups = [...ls.holidays_groups]; } } catch {} } } catch {} } catch {} } if (colorsSource) { try { if (colorsSource.color_ranges && typeof colorsSource.color_ranges==='object' && !this._yamlProvided?.color_ranges) this._config.color_ranges = { ...colorsSource.color_ranges }; if (typeof colorsSource.color_global === 'boolean') this._config.color_global = !!colorsSource.color_global; } catch {} } this._lastVersion = Number(stMain?.state || stSet?.state || 0) || 0; this._lastSettingsVersion = Number(stSet?.state || stMain?.state || 0) || this._lastVersion; this._lastColorsVersion = Number(stCol?.state || this._lastSettingsVersion || this._lastVersion || 0) || 0; this._lastWeekdayVersion = Number(stWeek?.state || this._lastVersion || 0) || 0; this._lastProfileVersion = Number(stProf?.state || this._lastVersion || 0) || 0; try { const payload = this._makeStoragePayload(true); localStorage.setItem(this._localStoreKey(), JSON.stringify(payload)); } catch {} this._storeLoaded = true; this._storeLoadedFromApi = true; return; } catch (e) { /* fallback */ } } try { // During a hard reset window: do not import any local browser state. if (hardReset) { this._schedules = {}; return; } let raw = localStorage.getItem(this._localStoreKey()) || ""; // Migration fallback: if instances are enabled and no per-instance cache exists yet, reuse legacy cache. if (!raw && this._config?.instance_enabled) { const legacy = localStorage.getItem('thermostat_timeline_store') || ''; if (legacy) { raw = legacy; try { localStorage.setItem(this._localStoreKey(), legacy); } catch {} } } if (!raw) { this._schedules = {}; return; } const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && parsed.schedules) { // Unwrap legacy nested structure if present const sch = parsed.schedules && parsed.schedules.schedules ? parsed.schedules.schedules : parsed.schedules; // If local copy was stored as °F, convert to °C for internal use try { const unit = String(parsed?.settings?.temp_unit||'C').toUpperCase(); this._schedules = (unit==='F') ? this._convertSchedulesTemps(sch||{}, 'C') : (sch||{}); } catch { this._schedules = sch || {}; } try { const wk = parsed.weekdays; if (wk && typeof wk === 'object') { for (const eid of Object.keys(wk)) { const row = this._schedules[eid] || {}; const src = wk[eid]; if (src && typeof src === 'object') { if (src.weekly) row.weekly = src.weekly; if (src.weekly_modes) row.weekly_modes = src.weekly_modes; this._schedules[eid] = row; } } } } catch {} try { let prof = parsed.profiles; const unit = String(parsed?.settings?.temp_unit||'C').toUpperCase(); if (prof && typeof prof === 'object' && unit === 'F') { prof = this._convertSchedulesTemps(prof, 'C'); } if (prof && typeof prof === 'object') { for (const eid of Object.keys(prof)) { const src = prof[eid]; if (!src || typeof src !== 'object') continue; const row = this._schedules[eid] || {}; if (src.profiles && typeof src.profiles === 'object') row.profiles = src.profiles; if (Object.prototype.hasOwnProperty.call(src, 'activeProfile')) row.activeProfile = src.activeProfile; this._schedules[eid] = row; } } } catch {} const s0 = parsed.settings || {}; // Convert numeric settings from °F to °C for internal use let s = s0; try { const unit = String(s0?.temp_unit||'C').toUpperCase(); if (unit==='F') { const conv = (n)=>{ const v=Number(n); return Number.isFinite(v)? this._fToC(v): n; }; const so = { ...s0 }; if (so.default_temp !== undefined) so.default_temp = conv(so.default_temp); if (so.min_temp !== undefined) so.min_temp = conv(so.min_temp); if (so.max_temp !== undefined) so.max_temp = conv(so.max_temp); if (so.boiler_min_temp !== undefined) so.boiler_min_temp = conv(so.boiler_min_temp); if (so.boiler_max_temp !== undefined) so.boiler_max_temp = conv(so.boiler_max_temp); if (so.away && typeof so.away==='object') { so.away = { ...so.away }; if (so.away.target_c !== undefined) so.away.target_c = conv(so.away.target_c); } try { if (so.presence_sensor_temps && typeof so.presence_sensor_temps === 'object') { const out = {}; for (const k of Object.keys(so.presence_sensor_temps||{})) out[String(k)] = conv(so.presence_sensor_temps[k]); so.presence_sensor_temps = out; } } catch {} s = so; } } catch {} // Pick up format/unit from local browser storage when not YAML-provided if (typeof s.time_12h === 'boolean' && !this._yamlProvided?.time_12h) this._config.time_12h = !!s.time_12h; if (typeof s.temp_unit === 'string' && !this._yamlProvided?.temp_unit) this._config.temp_unit = (String(s.temp_unit).toUpperCase()==='F' ? 'F' : 'C'); if (s.color_ranges && typeof s.color_ranges==='object' && !this._yamlProvided?.color_ranges) this._config.color_ranges = { ...s.color_ranges }; if (typeof s.color_global === 'boolean') this._config.color_global = !!s.color_global; if (Number.isFinite(s.default_temp) && !this._yamlProvided?.default_temp) this._config.default_temp = Number(s.default_temp); if (Number.isFinite(s.min_temp) && !this._yamlProvided?.min_temp) this._config.min_temp = Number(s.min_temp); if (Number.isFinite(s.max_temp) && !this._yamlProvided?.max_temp) this._config.max_temp = Number(s.max_temp); // Rooms from local browser storage (used by Settings popup) try { if (Array.isArray(s.entities)) { const ents = s.entities.filter(Boolean).map(String); this._config.entities = ents; const rawModes = Array.isArray(s.room_use_input_number) ? s.room_use_input_number.map(v=>!!v) : []; while (rawModes.length < ents.length) rawModes.push(false); this._config.room_use_input_number = ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return true; if (String(eid).startsWith('climate.')) return false; return !!rawModes[idx]; }); const rawTemp = Array.isArray(s.room_use_temp_sensor) ? s.room_use_temp_sensor.map(v=>!!v) : []; while (rawTemp.length < ents.length) rawTemp.push(false); this._config.room_use_temp_sensor = ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return false; if (!String(eid).startsWith('climate.')) return false; return !!rawTemp[idx]; }); } } catch {} if (s.merges && typeof s.merges==='object' && !this._yamlProvided?.merges) this._config.merges = { ...s.merges }; if (s.labels && typeof s.labels==='object' && !this._yamlProvided?.labels) this._config.labels = { ...s.labels }; if (s.temp_sensors && typeof s.temp_sensors==='object' && !this._yamlProvided?.temp_sensors) this._config.temp_sensors = { ...s.temp_sensors }; if (s.presence_sensors && typeof s.presence_sensors==='object' && !this._yamlProvided?.presence_sensors) this._config.presence_sensors = { ...s.presence_sensors }; try { if (s.presence_sensor_temps && typeof s.presence_sensor_temps === 'object' && !this._yamlProvided?.presence_sensor_temps) { const out = {}; for (const k of Object.keys(s.presence_sensor_temps||{})) { const v = Number(s.presence_sensor_temps[k]); if (Number.isFinite(v)) out[String(k)] = v; } this._config.presence_sensor_temps = out; } } catch {} try { if (s.presence_sensor_delays && typeof s.presence_sensor_delays === 'object' && !this._yamlProvided?.presence_sensor_delays) { const out = {}; for (const k of Object.keys(s.presence_sensor_delays||{})) { const v = s.presence_sensor_delays?.[k]; if (!v || typeof v !== 'object') continue; const onS = Number(v.on_s); const offS = Number(v.off_s); const obj = {}; if (Number.isFinite(onS) && onS >= 0) obj.on_s = onS; if (Number.isFinite(offS) && offS >= 0) obj.off_s = offS; if (Object.keys(obj).length) out[String(k)] = obj; } this._config.presence_sensor_delays = out; } } catch {} try { if (s.presence_sensor_delay_units && typeof s.presence_sensor_delay_units === 'object' && !this._yamlProvided?.presence_sensor_delay_units) { const out = {}; for (const k of Object.keys(s.presence_sensor_delay_units||{})) { const u = String(s.presence_sensor_delay_units?.[k] || '').toLowerCase().trim(); out[String(k)] = (u === 'seconds') ? 'seconds' : 'minutes'; } this._config.presence_sensor_delay_units = out; } } catch {} if ((typeof s.boiler_enabled === 'boolean' || typeof s.boiler_enabled === 'number') && !this._yamlProvided?.boiler_enabled) this._config.boiler_enabled = !!s.boiler_enabled; if (typeof s.boiler_switch === 'string') { const curSw = String(this._config?.boiler_switch || ''); if (!this._yamlProvided?.boiler_switch || !curSw) this._config.boiler_switch = s.boiler_switch || ''; } try { const rawDom = String(s.boiler_switch_domain || '').toLowerCase().trim(); const domFromSetting = (rawDom === 'input_boolean') ? 'input_boolean' : 'switch'; const swDom = String(s.boiler_switch || '').split('.')[0]; const domFromSwitch = (swDom === 'input_boolean' || swDom === 'switch') ? swDom : domFromSetting; const curSw = String(this._config?.boiler_switch || ''); if (!this._yamlProvided?.boiler_switch_domain || !curSw) this._config.boiler_switch_domain = domFromSwitch; } catch {} if (!this._yamlProvided?.boiler_rooms) { if (Array.isArray(s.boiler_rooms)) this._config.boiler_rooms = s.boiler_rooms.filter(Boolean).map(String); else if (s.boiler_rooms === null) this._config.boiler_rooms = null; } if (!this._yamlProvided?.boiler_on_offset) { if (s.boiler_on_offset === null) this._config.boiler_on_offset = 0; else { const v = Number(s.boiler_on_offset); if (Number.isFinite(v)) this._config.boiler_on_offset = Math.max(-10, Math.min(10, v)); } } if (!this._yamlProvided?.boiler_off_offset) { if (s.boiler_off_offset === null) this._config.boiler_off_offset = 0; else { const v = Number(s.boiler_off_offset); if (Number.isFinite(v)) this._config.boiler_off_offset = Math.max(-10, Math.min(10, v)); } } if (typeof s.boiler_temp_sensor === 'string' && !this._yamlProvided?.boiler_temp_sensor) this._config.boiler_temp_sensor = s.boiler_temp_sensor || ''; if (!this._yamlProvided?.boiler_min_temp) { if (s.boiler_min_temp === null) this._config.boiler_min_temp = 20; else { const v = Number(s.boiler_min_temp); if (Number.isFinite(v)) this._config.boiler_min_temp = v; } } if (!this._yamlProvided?.boiler_max_temp) { if (s.boiler_max_temp === null) this._config.boiler_max_temp = 25; else { const v = Number(s.boiler_max_temp); if (Number.isFinite(v)) this._config.boiler_max_temp = v; } } try { this._awayBypass = !!s.away_bypass; } catch {} // Open Window Detection from local storage copy try { if (s.open_window && typeof s.open_window==='object') this._config.open_window = { ...s.open_window }; } catch {} // Weekdays flags and view (persist across refresh) — skip if editor preview forbids overrides if (this._config?.respect_storage_weekdays !== false) { try { if (typeof s.weekdays_enabled === 'boolean') this._config.weekdays_enabled = !!s.weekdays_enabled; } catch {} try { if (typeof s.weekdays_mode === 'string') this._config.weekdays_mode = s.weekdays_mode; } catch {} try { if (typeof s.seasonal_enabled === 'boolean') this._config.seasonal_enabled = !!s.seasonal_enabled; } catch {} try { if (typeof s.seasonal_mode === 'string') this._config.seasonal_mode = (s.seasonal_mode === 'summer' ? 'summer' : 'winter'); } catch {} try { if (typeof s.weekdays_view === 'string') this._config.weekdays_view = s.weekdays_view; } catch {} try { if (typeof s.weekdays_view_switch_in_timeline === 'boolean') this._config.weekdays_view_switch_in_timeline = !!s.weekdays_view_switch_in_timeline; } catch {} try { if (typeof s.weekdays_selected_room === 'string') this._config.weekdays_selected_room = s.weekdays_selected_room; } catch {} } if (typeof s.per_room_defaults === 'boolean') this._config.per_room_defaults = !!s.per_room_defaults; if (typeof s.show_pause_button === 'boolean' && !this._yamlProvided?.show_pause_button) this._config.show_pause_button = !!s.show_pause_button; if (typeof s.show_room_temp === 'boolean' && !this._yamlProvided?.show_room_temp) this._config.show_room_temp = !!s.show_room_temp; if (typeof s.pause_sensor_enabled === 'boolean' && !this._yamlProvided?.pause_sensor_enabled) this._config.pause_sensor_enabled = !!s.pause_sensor_enabled; if (typeof s.pause_sensor_entity === 'string' && !this._yamlProvided?.pause_sensor_entity) this._config.pause_sensor_entity = String(s.pause_sensor_entity || ''); if (s.away && typeof s.away === 'object') { try { const a = s.away; this._config.away = { enabled: !!a.enabled, persons: Array.isArray(a.persons) ? [...a.persons].filter(Boolean).map(String) : [], target_c: Number.isFinite(a.target_c) ? Number(a.target_c) : (this._config.away?.target_c ?? 17), advanced_enabled: !!(a.advanced_enabled ?? this._config?.away?.advanced_enabled ?? false), combos: (a.combos && typeof a.combos === 'object') ? { ...a.combos } : (this._config?.away?.combos || {}), delay_enabled: !!(a.delay_enabled ?? this._config?.away?.delay_enabled ?? false), delay_value: Number.isFinite(a.delay_value) ? Number(a.delay_value) : (this._config?.away?.delay_value ?? 0), delay_unit: String(a.delay_unit || this._config?.away?.delay_unit || 'minutes') }; } catch {} } // Guard: Away/Presence should not be active until at least one person is selected. // Keep the enabled toggle; only disable advanced features when no persons are set. try { const ap = Array.isArray(this._config?.away?.persons) ? this._config.away.persons.filter(Boolean).map(String) : []; this._config.away.persons = ap; if (!ap.length) { this._config.away.advanced_enabled = false; } } catch {} // Load flags from local storage copy try { this._pauseIndef = !!s.pause_indef; const pu = Number(s.pause_until_ms); this._pauseUntilMs = Number.isFinite(pu) ? pu : 0; } catch {} try { if (typeof s.profiles_enabled === 'boolean' && !this._yamlProvided?.profiles_enabled) this._config.profiles_enabled = !!s.profiles_enabled; } catch {} try { if ((typeof s.presence_sensor_enabled === 'boolean' || typeof s.presence_sensor_enabled === 'number') && !this._yamlProvided?.presence_sensor_enabled) this._config.presence_sensor_enabled = !!s.presence_sensor_enabled; } catch {} try { if (typeof s.apply_on_edit === 'boolean') this._config.apply_on_edit = !!s.apply_on_edit; } catch {} try { this._globalProfile = (typeof s.global_profile === 'string' && s.global_profile) ? String(s.global_profile) : null; } catch {} try { if (typeof s.backup_auto_enabled === 'boolean') this._config.backup_auto_enabled = !!s.backup_auto_enabled; if (Number.isFinite(s.backup_interval_days)) this._config.backup_interval_days = Math.max(1, Math.min(365, Math.round(Number(s.backup_interval_days)))); else if (Number.isFinite(s.backup_interval_min)) this._config.backup_interval_days = Math.max(1, Math.min(365, Math.ceil(Number(s.backup_interval_min)/1440))); } catch {} // Sync policy from storage try { if (typeof s.sync_mode === 'string') this._config.storage_sync_mode = (s.sync_mode === 'delay') ? 'delay' : 'instant'; if (Number.isFinite(s.sync_delay_min)) this._config.storage_sync_min = Math.max(0, Math.min(1440, Number(s.sync_delay_min))); if (Number.isFinite(s.sync_delay_sec)) this._config.storage_sync_sec = Math.max(5, Math.min(86400, Math.round(Number(s.sync_delay_sec)))); else if (Number.isFinite(s.sync_delay_min)) this._config.storage_sync_sec = Math.max(5, Math.min(86400, Math.round(Number(s.sync_delay_min)*60))); } catch {} // Holidays from local storage try { if (typeof s.holidays_enabled === 'boolean') this._config.holidays_enabled = !!s.holidays_enabled; if (typeof s.holidays_source === 'string' && !this._yamlProvided?.holidays_source) this._config.holidays_source = s.holidays_source === 'manual' ? 'manual' : 'calendar'; if (typeof s.holidays_entity === 'string' && !this._yamlProvided?.holidays_entity) this._config.holidays_entity = s.holidays_entity || ''; if (Array.isArray(s.holidays_dates) && !this._yamlProvided?.holidays_dates) this._config.holidays_dates = [...s.holidays_dates]; } catch {} } else { // backward compat: schedules stored directly this._schedules = parsed || {}; try { this._seasonAppliedKey = null; } catch {} } this._storeLoaded = true; this._storeLoadedFromApi = false; } catch { this._schedules = {}; } } _isSettingsPopupOpen(){ try { const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings'); if (!ov) return false; return !!ov.classList.contains('open') || !!this._settingsPopupSuspended; } catch { return false; } } _editSessionActive(){ return !!(this._editing || this._inlineEditing || this._weeklyOpen || this._profilesOpen || this._holidayOpen || this._presenceOpen); } _ensureEditModeOn(){ this._pendingSaveAfterEdit = true; } _flushPendingSave(){ try { if (!this._pendingSaveAfterEdit) return; if (this._editSessionActive()) return; this._pendingSaveAfterEdit = false; this._saveStore(true); } catch {} } async _saveStore(force = false) { if (!force && this._editSessionActive()) { this._markPendingSave(); return; } if (!force && this._pendingSaveAfterEdit) return; this._pendingSaveAfterEdit = false; try { const payload = this._makeStoragePayload(true); localStorage.setItem(this._localStoreKey(), JSON.stringify(payload)); } catch {} try { this._broadcastStoreUpdated(this._localStoreKey()); } catch {} if (this._config?.storage_enabled && !this._storeLoadedFromApi && !force) { return; } // Always persist local browser copy immediately const writeRemote = async () => { if (!this._config?.storage_enabled) { // When shared storage is turned off, still push a minimal settings payload to disable backend apply try { const basePayload = { settings: { auto_apply_enabled: false, apply_on_edit: !!this._config.apply_on_edit, profiles_enabled: !!this._config.profiles_enabled, global_profile: this._globalProfile || null, boiler_enabled: !!this._config.boiler_enabled, boiler_switch: String(this._config.boiler_switch || ''), boiler_switch_domain: String((this._config.boiler_switch_domain === 'input_boolean') ? 'input_boolean' : 'switch'), boiler_rooms: Array.isArray(this._config.boiler_rooms) ? this._config.boiler_rooms.filter(Boolean).map(String) : null, boiler_on_offset: (this._config.boiler_on_offset === null || this._config.boiler_on_offset === undefined) ? null : Number(this._config.boiler_on_offset), boiler_off_offset: (this._config.boiler_off_offset === null || this._config.boiler_off_offset === undefined) ? null : Number(this._config.boiler_off_offset), boiler_temp_sensor: String(this._config.boiler_temp_sensor || ''), boiler_min_temp: (this._config.boiler_min_temp === null || this._config.boiler_min_temp === undefined) ? null : Number(this._config.boiler_min_temp), boiler_max_temp: (this._config.boiler_max_temp === null || this._config.boiler_max_temp === undefined) ? null : Number(this._config.boiler_max_temp), boiler_multi_enabled: !!this._config.boiler_multi_enabled, boiler_room_settings: (()=>{ try { const src = (this._config.boiler_room_settings && typeof this._config.boiler_room_settings === 'object') ? this._config.boiler_room_settings : {}; const out = {}; for (const k of Object.keys(src||{})) { const r = src[k]; if (!r || typeof r !== 'object') continue; const enabled = !!r.enabled; const sw = String(r.switch || ''); const dom = String(r.switch_domain || '').toLowerCase().trim(); const onOff = Number(r.on_offset); const offOff = Number(r.off_offset); const obj = { enabled, switch: sw, switch_domain: (dom === 'input_boolean') ? 'input_boolean' : 'switch' }; if (Number.isFinite(onOff)) obj.on_offset = onOff; if (Number.isFinite(offOff)) obj.off_offset = offOff; if (enabled || sw || Number.isFinite(onOff) || Number.isFinite(offOff) || dom) out[String(k)] = obj; } return out; } catch { return {}; } })(), show_pause_button: !!(this._config.show_pause_button ?? true), show_room_temp: !!(this._config.show_room_temp ?? true), pause_sensor_enabled: !!(this._config.pause_sensor_enabled ?? false), pause_sensor_entity: String(this._config.pause_sensor_entity || ''), pause_indef: !!this._pauseIndef, pause_until_ms: Number(this._pauseUntilMs||0), open_window: this._config.open_window || { enabled:false, sensors:{}, open_delay_min:2, close_delay_min:5 }, holidays_enabled: !!this._config.holidays_enabled, holidays_source: this._config.holidays_source || 'calendar', holidays_entity: this._config.holidays_entity || '', holidays_dates: Array.isArray(this._config.holidays_dates) ? this._config.holidays_dates : [] }, colors: { color_ranges: this._config.color_ranges, color_global: !!this._config.color_global } }; if (this._config?.instance_enabled) { basePayload.instance_id = this._instanceId(); basePayload.activate = false; } await this._hass.callService("thermostat_timeline", "set_store", basePayload); } catch {} return; } this._saving = true; try { let payload = this._makeStoragePayload(true); // Merge with current backend settings to avoid wiping keys the editor/card doesn't currently include (e.g. labels) try { const state = await this._apiFetchState(); const base = (state && typeof state === 'object' && state.settings && typeof state.settings === 'object') ? state.settings : {}; // If no base available (early startup), avoid writing holidays_enabled unless user toggled if ((!state || !state.settings) && !this._holidaysToggleTouched) { try { if (payload?.settings && Object.prototype.hasOwnProperty.call(payload.settings,'holidays_enabled')) delete payload.settings.holidays_enabled; } catch {} } const incoming = payload.settings || {}; const merged = { ...base, ...incoming }; // If user didn't toggle holidays during this session, keep backend value explicitly try { if (!this._holidaysToggleTouched && typeof base.holidays_enabled === 'boolean') { merged.holidays_enabled = base.holidays_enabled; } if (!this._holidaysSourceTouched && typeof base.holidays_source === 'string') { merged.holidays_source = String(base.holidays_source || 'calendar') === 'manual' ? 'manual' : 'calendar'; } if (!this._holidaysEntityTouched && typeof base.holidays_entity === 'string') { merged.holidays_entity = String(base.holidays_entity || ''); } if (!this._holidaysDatesTouched && Array.isArray(base.holidays_dates)) { merged.holidays_dates = Array.from(base.holidays_dates); } if (!this._holidaysGroupsTouched && Array.isArray(base.holidays_groups)) { merged.holidays_groups = Array.from(base.holidays_groups); } } catch {} // Heuristic: avoid flipping holidays_enabled=true -> false on startup if nothing holiday-related changed. try { const bHE = typeof base.holidays_enabled === 'boolean' ? base.holidays_enabled : undefined; const iHE = typeof incoming.holidays_enabled === 'boolean' ? incoming.holidays_enabled : undefined; // If background push (no user toggle) tries to change the value, ignore and keep backend if (this._holidaysToggleTouched !== true && bHE !== undefined && iHE !== undefined && bHE !== iHE) { merged.holidays_enabled = bHE; } else if (bHE === true && iHE === false) { const normArr = (a)=>{ try { return Array.from(new Set((a||[]).map(String))).sort(); } catch { return []; } }; const eqDates = JSON.stringify(normArr(incoming.holidays_dates)) === JSON.stringify(normArr(base.holidays_dates)); const eqSource = String(incoming.holidays_source||'') === String(base.holidays_source||''); const eqEntity = String(incoming.holidays_entity||'') === String(base.holidays_entity||''); const eqGroups = JSON.stringify(incoming.holidays_groups||[]) === JSON.stringify(base.holidays_groups||[]); if (eqDates && eqSource && eqEntity && eqGroups) { merged.holidays_enabled = true; } } } catch {} // Preserve backend boiler_enabled unless user explicitly toggled this session try { if (!this._boilerToggleTouched && typeof base.boiler_enabled === 'boolean') { merged.boiler_enabled = base.boiler_enabled; } if (!this._awayTouched) { const localAway = (this._config?.away && typeof this._config.away === 'object') ? this._config.away : null; if (localAway) merged.away = JSON.parse(JSON.stringify(localAway)); if (typeof this._config?.presence_live_header === 'boolean') { merged.presence_live_header = this._config.presence_live_header; } const ldEn = this._config?.away?.delay_enabled; const ldVal = this._config?.away?.delay_value; const ldUnit = this._config?.away?.delay_unit; if (typeof ldEn === 'boolean') merged.away_delay_enabled = ldEn; if (Number.isFinite(Number(ldVal))) merged.away_delay_value = Number(ldVal); if (typeof ldUnit === 'string') merged.away_delay_unit = (String(ldUnit || 'minutes') === 'seconds') ? 'seconds' : 'minutes'; } } catch {} const keepIfEmpty = (key)=>{ try { const inc = incoming[key]; const hasInc = inc && typeof inc === 'object' && Object.keys(inc).length > 0; const baseVal = base[key]; const hasBase = baseVal && typeof baseVal === 'object' && Object.keys(baseVal).length > 0; if (!hasInc && hasBase) merged[key] = baseVal; // preserve backend value when our copy is empty } catch {} }; // If labels were explicitly edited in this session, allow an empty object to clear labels if (!this._labelsTouched) keepIfEmpty('labels'); keepIfEmpty('merges'); keepIfEmpty('temp_sensors'); payload = { ...payload, settings: merged }; } catch {} // Push all data to the integration via set_store service (writes to file) if (this._config?.instance_enabled) { payload = { ...payload, instance_id: this._instanceId(), activate: true }; } await this._hass.callService("thermostat_timeline", "set_store", payload); try { this._broadcastStoreUpdated(this._localStoreKey()); } catch {} } catch (e) {} finally { setTimeout(() => { this._saving = false; try { this._holidaysToggleTouched = false; } catch {} try { this._holidaysSourceTouched = false; } catch {} try { this._holidaysEntityTouched = false; } catch {} try { this._holidaysDatesTouched = false; } catch {} try { this._holidaysGroupsTouched = false; } catch {} try { this._boilerToggleTouched = false; } catch {} try { this._awayTouched = false; } catch {} try { this._labelsTouched = false; } catch {} // Show "done" for a short moment and ensure UI keeps ticking this._syncJustUntil = Date.now() + 2500; // Clear previous due so countdown disappears until next change this._storeDelayDue = 0; this._startSyncTimer(); this._render(); }, 600); } }; // Decide sync policy const mode = this._config?.storage_sync_mode || 'instant'; const secsCfg = Number(this._config?.storage_sync_sec || 0); const secs = Math.max(5, Math.min(86400, Number.isFinite(secsCfg) ? secsCfg : Math.max(0, Number(this._config?.storage_sync_min || 0))*60)); if (this._config?.storage_enabled && mode === 'delay' && secs > 0 && !force) { // Trailing save after N minutes; coalesce multiple changes const delayMs = secs * 1000; if (this._storeDelayTimer) try { clearTimeout(this._storeDelayTimer); } catch {} this._storeDelayDue = Date.now() + delayMs; try { localStorage.setItem(this._syncDueKey(), String(this._storeDelayDue)); } catch {} // Save a draft of current schedules so they survive a hard refresh until delayed sync writes try { localStorage.setItem(this._draftKey(), JSON.stringify({ schedules: this._schedules })); } catch {} // Persist due time across reloads // If we just finished a sync, drop the "done" flag so countdown shows immediately this._syncJustUntil = 0; this._storeDelayTimer = setTimeout(() => { this._storeDelayTimer = null; this._writeStoreNow(); }, delayMs); try { this._startSyncTimer(); this._render(); } catch {} } else { // Instant (existing behavior with short debounce handled by callers) await this._writeStoreNow(); } } _debouncedSaveStore() { // Keep short debounce for bursts; final saving respects sync policy inside _saveStore if (this._saveTimer) clearTimeout(this._saveTimer); // Persist a local draft immediately so edits survive hard refresh before save runs try { this._persistDraftNow(); } catch {} this._saveTimer = setTimeout(() => this._saveStore(), this._saveDebounceMs); } // ---------- Helpers ---------- _sortBlocks(entity) { const r = this._schedules[entity]; r.blocks.sort((a,b)=>a.startMin - b.startMin || a.endMin - b.endMin); } _neighbors(entity, id) { const r = this._schedules[entity]; const i = r.blocks.findIndex(b=>b.id===id); if (i === -1) return {left:null, right:null, index:-1}; return { left: r.blocks[i-1] || null, right: r.blocks[i+1] || null, index:i }; } _applyNoOverlapResize(entity, b, edge, proposed) { this._sortBlocks(entity); const {left, right} = this._neighbors(entity, b.id); if (edge === "left") { let ns = this._clamp(Math.floor(proposed), 0, b.endMin - 5); if (left) ns = Math.max(ns, left.endMin); b.startMin = ns; } else if (edge === "right") { let ne = this._clamp(Math.ceil(proposed), b.startMin + 5, 1440); if (right) ne = Math.min(ne, right.startMin); b.endMin = ne; } this._sortBlocks(entity); } _label(min) { if (!Number.isFinite(min)) min = 0; const m = ((Math.floor(min) % 1440) + 1440) % 1440; const hh = Math.floor(m/60), mm = Math.round(m%60); if (this._config?.time_12h) { const am = hh < 12; let h = hh % 12; if (h === 0) h = 12; return `${String(h).padStart(2,'0')}:${String(mm).padStart(2,'0')} ${am ? 'AM' : 'PM'}`; } return `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`; } _timeParts(min){ // Returns { main: 'hh:mm', mer: 'AM'|'PM'|null } according to current format if (!Number.isFinite(min)) min = 0; const m = ((Math.floor(min) % 1440) + 1440) % 1440; const hh = Math.floor(m/60), mm = Math.round(m%60); if (this._config?.time_12h) { const am = hh < 12; let h = hh % 12; if (h === 0) h = 12; return { main: `${String(h).padStart(2,'0')}:${String(mm).padStart(2,'0')}`, mer: am ? 'AM' : 'PM' }; } return { main: `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`, mer: null }; } _hourLabel(h){ const min = (h%24)*60; return this._label(min); } _useHaTime(){ try { return String(this._config?.time_source||'browser') === 'ha'; } catch { return false; } } _getNowMin(){ try { if (this._useHaTime() && this._hass?.config?.time_zone) { const tz = this._hass.config.time_zone; const fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }); const parts = fmt.formatToParts(new Date()); const hh = Number(parts.find(p=>p.type==='hour')?.value||'0'); const mm = Number(parts.find(p=>p.type==='minute')?.value||'0'); return hh*60 + mm; } } catch {} const d=new Date(); return d.getHours()*60 + d.getMinutes(); } _clamp(v,a,b){ if (isNaN(v)) return a; return Math.min(Math.max(v,a),b); } _prettyName(eid){ const st=this._hass?.states?.[eid]; if (st?.attributes?.friendly_name) return st.attributes.friendly_name; const base=(eid||"").split(".")[1]||eid||""; return base.replace(/_/g," ").replace(/\b\w/g,(m)=>m.toUpperCase()); } _isCompactScale(){ // Compact = show every other hour on small or touch devices (incl. mobile portrait) try { const isCoarse = window.matchMedia && window.matchMedia('(pointer:coarse)').matches; // Prefer the card width when available, fallback to window width const hostW = (this.shadowRoot && this.shadowRoot.host && this.shadowRoot.host.clientWidth) || 0; const w = hostW || window.innerWidth || 0; // Consider compact on touch devices (phones/tablets) or narrow widths return isCoarse || w <= 720; // ~mobile/tablet portrait breakpoint } catch (e) { return false; } } _detectPrefer12h(){ try { // Prefer Home Assistant locale if available const lf = this._hass?.locale?.time_format || this._hass?.locale?.time_format_preference || ''; if (typeof lf === 'string') { const s = lf.toLowerCase(); if (s.includes('12')) return true; if (s.includes('24')) return false; } } catch {} try { // Fallback: browser preference const fmt = new Intl.DateTimeFormat(navigator.language || undefined, { hour: 'numeric' }); const ro = fmt.resolvedOptions?.() || {}; if (typeof ro.hour12 === 'boolean') return ro.hour12; } catch {} // Final fallback: 24h return false; } _detectPreferF(){ try { const u = this._hass?.config?.unit_system?.temperature || this._hass?.locale?.temperature || ''; if (typeof u === 'string' && u.toUpperCase().includes('F')) return true; } catch {} try { const f = new Intl.NumberFormat(navigator.language || undefined, { style:'unit', unit:'fahrenheit' }); // Heuristic: if formatter exists, still default to C; most regions outside US use C, but HA already covers primary preference } catch {} return false; } _getLocalProfilesEnabled(){ try { const raw = localStorage.getItem(this._localStoreKey()) || ''; if (!raw) return false; const parsed = JSON.parse(raw); const s = parsed?.settings || {}; return !!s.profiles_enabled; } catch { return false; } } _isF(){ return (this._config?.temp_unit||'C')==='F'; } _unitSymbol(){ return this._isF()? '°F' : '°C'; } _toDisplayTemp(c){ return this._isF()? Math.round((c*9/5+32)*10)/10 : c; } _fromDisplayTemp(v){ return this._isF()? ( (v-32)*5/9 ) : v; } // --- Temperature conversion helpers (internal storage is °C) --- _cToF(c){ try { return (Number(c)*9/5)+32; } catch { return c; } } _fToC(f){ try { return (Number(f)-32)*5/9; } catch { return f; } } // Service calls must follow Home Assistant's unit system (not the card display unit) _serviceTempFromC(c){ return this._haIsF() ? this._cToF(c) : c; } _haIsF(){ try { const u = String(this._hass?.config?.unit_system?.temperature||''); return u.toUpperCase().includes('F'); } catch { return false; } } _readSensorTemperatureC(sensorEid){ try { const st = this._hass?.states?.[sensorEid]; const raw = Number(String(st?.state ?? '').trim().replace(',', '.')); if (!Number.isFinite(raw)) return NaN; const u = String(st?.attributes?.unit_of_measurement || this._hass?.config?.unit_system?.temperature || '').toUpperCase(); const isF = u.includes('F'); return isF ? this._fToC(raw) : raw; } catch { return NaN; } } _readClimateCurrentTemperatureC(climateEid){ try { const st = this._hass?.states?.[climateEid]; let raw = Number(String(st?.attributes?.current_temperature ?? st?.attributes?.current_temp ?? st?.attributes?.temperature ?? '').trim().replace(',', '.')); if (!Number.isFinite(raw)) { // Some custom climate entities expose the temperature as a numeric state. raw = Number(String(st?.state ?? '').trim().replace(',', '.')); } if (!Number.isFinite(raw)) return NaN; return this._haIsF() ? this._fToC(raw) : raw; } catch { return NaN; } } _roomCurrentTemperatureC(primaryEid){ try { if (!this._hass || !this._config) return NaN; const ents = Array.isArray(this._config.entities) ? this._config.entities.map(String) : []; const idx = ents.indexOf(String(primaryEid)); const flags = Array.isArray(this._config.room_use_temp_sensor) ? this._config.room_use_temp_sensor : []; const enabled = (idx >= 0) ? !!flags[idx] : false; if (enabled) { const sensorEid = String(this._config?.temp_sensors?.[primaryEid] || '').trim(); if (sensorEid) { const v = this._readSensorTemperatureC(sensorEid); if (Number.isFinite(v)) return v; } } // Fallback: climate current_temperature (average across merged group) const merged = this._config?.merges?.[primaryEid]; const extra = Array.isArray(merged) ? merged : []; const group = [primaryEid, ...extra]; const vals = []; for (const g of group) { const v = this._readClimateCurrentTemperatureC(g); if (Number.isFinite(v)) vals.push(v); } if (!vals.length) return NaN; return vals.reduce((a,b)=>a+b,0) / vals.length; } catch { return NaN; } } async _applySetpointForEntity(eid, desiredC){ try { const dom = String(eid||'').split('.')[0] || ''; if (dom === 'input_number') { // Mirror climate behavior: send in configured display/service unit (°C or °F) const val = this._serviceTempFromC(desiredC); await this._hass.callService('input_number','set_value', { entity_id: eid, value: val }); return; } if (dom !== 'climate') return; const st = this._hass?.states?.[eid]; if (!st) return; const attrs = st.attributes || {}; let hvacMode = String(attrs.hvac_mode ?? st.state ?? '').toLowerCase(); const hvacModes = Array.isArray(attrs.hvac_modes) ? attrs.hvac_modes.map(x=>String(x).toLowerCase()) : []; const presetMode = String(attrs.preset_mode || '').toLowerCase(); const presetModes = Array.isArray(attrs.preset_modes) ? attrs.preset_modes.map(x=>String(x).toLowerCase()) : []; // Optional: enable manual/hold preset to avoid schedule overrides try { const want = presetModes.includes('manual') ? 'manual' : (presetModes.includes('hold') ? 'hold' : null); if (want && presetMode !== want){ await this._hass.callService('climate','set_preset_mode',{ entity_id: eid, preset_mode: want }); } } catch {} // If mode ignores setpoint, try switching to heat/cool try { if (['off','dry','fan_only'].includes(hvacMode)){ let newMode = null; if (hvacModes.includes('heat')) newMode = 'heat'; else if (hvacModes.includes('cool')) newMode = 'cool'; if (newMode){ await this._hass.callService('climate','set_hvac_mode',{ entity_id: eid, hvac_mode: newMode }); hvacMode = newMode; } } } catch {} // Decide between single setpoint vs range const hasLow = Object.prototype.hasOwnProperty.call(attrs, 'target_temp_low'); const hasHigh = Object.prototype.hasOwnProperty.call(attrs, 'target_temp_high'); const modeSuggestsRange = (hvacMode === 'heat_cool') || (hvacMode === 'auto' && hasLow && hasHigh); if (modeSuggestsRange && (hasLow || hasHigh)){ let band = 1.0; // °C total band try { const b = Number(this._config?.range_band_c); if (Number.isFinite(b) && b > 0.2 && b <= 10) band = b; } catch {} const half = Math.max(0.2, band/2); const lowC = desiredC - half; const highC = desiredC + half; const data = { entity_id: eid, target_temp_low: this._serviceTempFromC(lowC), target_temp_high: this._serviceTempFromC(highC) }; await this._hass.callService('climate','set_temperature', data); return; } await this._hass.callService('climate','set_temperature', { entity_id: eid, temperature: this._serviceTempFromC(desiredC) }); } catch {} } // Convert a schedules object between units (returns deep clone) _convertSchedulesTemps(schedules, toUnit){ try { const clone = JSON.parse(JSON.stringify(schedules||{})); const toF = String(toUnit).toUpperCase()==='F'; const conv = (x)=>{ const n = Number(x); if (!Number.isFinite(n)) return x; return toF ? this._cToF(n) : this._fToC(n); }; const convertBlocks = (arr)=>{ if (!Array.isArray(arr)) return; for (const b of arr){ if (b && typeof b==='object' && 'temp' in b){ b.temp = conv(b.temp); } } }; for (const eid of Object.keys(clone||{})){ const row = clone[eid]; if (!row || typeof row!=='object') continue; if ('defaultTemp' in row) row.defaultTemp = conv(row.defaultTemp); convertBlocks(row.blocks); // Weekly try { const days = row?.weekly?.days||{}; for (const k of Object.keys(days)) convertBlocks(days[k]); } catch {} // Profiles try { const prof = row?.profiles||{}; for (const p of Object.keys(prof)){ convertBlocks(prof[p]?.blocks); } } catch {} // Holiday try { convertBlocks(row?.holiday?.blocks); } catch {} // Presence try { const pres = row?.presence||{}; for (const key of Object.keys(pres)){ convertBlocks(pres[key]?.blocks); } } catch {} // Seasonal (summer/winter) try { const seasons = row?.seasons || {}; for (const sk of Object.keys(seasons||{})) { const srow = seasons[sk]; if (!srow || typeof srow !== 'object') continue; if ('defaultTemp' in srow) srow.defaultTemp = conv(srow.defaultTemp); convertBlocks(srow.blocks); try { const days = srow?.weekly?.days||{}; for (const k of Object.keys(days)) convertBlocks(days[k]); } catch {} try { const wm = srow?.weekly_modes||{}; for (const mk of Object.keys(wm||{})) { const d2 = wm[mk]?.days||{}; for (const dk of Object.keys(d2)) convertBlocks(d2[dk]); } } catch {} try { const pres = srow?.presence||{}; for (const pk of Object.keys(pres||{})) convertBlocks(pres[pk]?.blocks); } catch {} } } catch {} } return clone; } catch { return schedules; } } // Heuristic for legacy stores: detect if schedule temps look like °F (typical °C schedules are < ~45) _maxTempInSchedules(schedules){ let maxV = -Infinity; const scanBlocks = (arr)=>{ if (!Array.isArray(arr)) return; for (const b of arr){ try { if (!b || typeof b !== 'object') continue; const v = Number(b.temp); if (Number.isFinite(v)) maxV = Math.max(maxV, v); } catch {} } }; try { const s = schedules || {}; for (const eid of Object.keys(s)){ const row = s[eid]; if (!row || typeof row !== 'object') continue; const d = Number(row.defaultTemp); if (Number.isFinite(d)) maxV = Math.max(maxV, d); scanBlocks(row.blocks); try { const days = row?.weekly?.days||{}; for (const k of Object.keys(days)) scanBlocks(days[k]); } catch {} try { const prof = row?.profiles||{}; for (const p of Object.keys(prof)) scanBlocks(prof[p]?.blocks); } catch {} try { scanBlocks(row?.holiday?.blocks); } catch {} try { const pres = row?.presence||{}; for (const key of Object.keys(pres)) scanBlocks(pres[key]?.blocks); } catch {} try { const seasons = row?.seasons || {}; for (const sk of Object.keys(seasons||{})) { const srow = seasons[sk]; if (!srow || typeof srow !== 'object') continue; const d = Number(srow.defaultTemp); if (Number.isFinite(d)) maxV = Math.max(maxV, d); scanBlocks(srow.blocks); try { const days = srow?.weekly?.days||{}; for (const k of Object.keys(days)) scanBlocks(days[k]); } catch {} try { const wm = srow?.weekly_modes||{}; for (const mk of Object.keys(wm||{})) { const d2 = wm[mk]?.days||{}; for (const dk of Object.keys(d2)) scanBlocks(d2[dk]); } } catch {} } } catch {} } } catch {} return Number.isFinite(maxV) ? maxV : -Infinity; } // Build payload for shared storage respecting selected unit for numbers _makeStoragePayload(includeSchedules=true){ try { try { this._syncSeasonalRowsToBucket(); } catch {} const base = (this._config && typeof this._config === 'object') ? this._config : {}; // Canonical shared storage is always °C to avoid drift when multiple clients use different display units. // (Display unit is controlled by settings.temp_unit and is independent of stored numeric values.) const wantF = false; let schedules = undefined; let weekdays = {}; let profiles = {}; const useSeparateProfiles = this._config?.storage_enabled && !!this._storageEntity('profiles'); if (includeSchedules) { const cloned = JSON.parse(JSON.stringify(this._schedules||{})); // Split weekday data into separate payload try { for (const eid of Object.keys(cloned||{})) { const row = cloned[eid]; if (!row || typeof row !== 'object') continue; const wk = {}; if (row.weekly) { wk.weekly = row.weekly; delete row.weekly; } if (row.weekly_modes) { wk.weekly_modes = row.weekly_modes; delete row.weekly_modes; } if (Object.keys(wk).length) weekdays[eid] = wk; const prof = {}; if (row.profiles && typeof row.profiles === 'object') prof.profiles = row.profiles; if (Object.prototype.hasOwnProperty.call(row, 'activeProfile')) prof.activeProfile = row.activeProfile; if (Object.keys(prof).length) { // Always keep profiles inside the schedules payload as a fallback, even when a dedicated profiles sensor is configured. // This prevents losing data if the profiles sensor is missing or fails to update. profiles[eid] = prof; } } } catch {} schedules = cloned; } // Convert numeric settings if storing as °F const toStore = (n)=>{ const v = Number(n); if (!Number.isFinite(v)) return n; return v; }; const awayCfg = this._config?.away || {}; const awayOut = { ...awayCfg }; // Guard: Away/Presence should not be active until at least one person is selected. // Keep enabled toggle; only disable advanced features when no persons are set. try { awayOut.persons = Array.isArray(awayOut.persons) ? awayOut.persons.filter(Boolean).map(String) : []; if (!awayOut.persons.length) { awayOut.advanced_enabled = false; } } catch {} if (typeof awayOut.target_c !== 'undefined') awayOut.target_c = toStore(awayOut.target_c); const settings = { // Rooms (used by Settings popup) entities: Array.isArray(this._config.entities) ? this._config.entities.filter(Boolean).map(String) : [], room_use_input_number: (()=>{ try { const ents = Array.isArray(this._config.entities) ? this._config.entities.filter(Boolean).map(String) : []; const raw = Array.isArray(this._config.room_use_input_number) ? this._config.room_use_input_number.slice() : []; while (raw.length < ents.length) raw.push(false); return ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return true; if (String(eid).startsWith('climate.')) return false; return !!raw[idx]; }); } catch { return []; } })(), room_use_temp_sensor: (()=>{ try { const ents = Array.isArray(this._config.entities) ? this._config.entities.filter(Boolean).map(String) : []; const raw = Array.isArray(this._config.room_use_temp_sensor) ? this._config.room_use_temp_sensor.slice() : []; while (raw.length < ents.length) raw.push(false); return ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return false; if (!String(eid).startsWith('climate.')) return false; return !!raw[idx]; }); } catch { return []; } })(), time_12h: this._config.time_12h, temp_unit: this._config.temp_unit, storage_temp_unit: 'C', time_source: this._config.time_source, row_height: Number(this._config.row_height ?? 64), default_temp: toStore(Number(this._config.default_temp||20)), min_temp: toStore(Number(this._config.min_temp ?? 5)), max_temp: toStore(Number(this._config.max_temp ?? 25)), weekdays_enabled: !!this._config.weekdays_enabled, seasonal_enabled: !!this._config.seasonal_enabled, seasonal_mode: (this._config.seasonal_mode === 'summer') ? 'summer' : 'winter', weekdays_mode: this._config.weekdays_mode || 'weekday_weekend', weekdays_view: this._config.weekdays_view || 'all_rooms_one_day', weekdays_view_switch_in_timeline: !!(this._config.weekdays_view_switch_in_timeline ?? false), weekdays_selected_room: this._config.weekdays_selected_room || '', per_room_defaults: !!this._config.per_room_defaults, away: awayOut, away_bypass: !!this._awayBypass, merges: this._config.merges, labels: this._config.labels, temp_sensors: this._config.temp_sensors, turn_on: this._config.turn_on, presence_sensor_enabled: !!(this._config.presence_sensor_enabled ?? false), presence_live_header: !!(this._config.presence_live_header ?? true), presence_sensors: (this._config.presence_sensors && typeof this._config.presence_sensors === 'object') ? this._config.presence_sensors : {}, presence_sensor_temps: (this._config.presence_sensor_temps && typeof this._config.presence_sensor_temps === 'object') ? this._config.presence_sensor_temps : {}, presence_sensor_delays: (this._config.presence_sensor_delays && typeof this._config.presence_sensor_delays === 'object') ? this._config.presence_sensor_delays : {}, presence_sensor_delay_units: (this._config.presence_sensor_delay_units && typeof this._config.presence_sensor_delay_units === 'object') ? this._config.presence_sensor_delay_units : {}, show_pause_button: !!(this._config.show_pause_button ?? true), pause_sensor_enabled: !!(this._config.pause_sensor_enabled ?? false), pause_sensor_entity: String(this._config.pause_sensor_entity || ''), pause_indef: !!this._pauseIndef, pause_until_ms: Number(this._pauseUntilMs||0), boiler_enabled: !!this._config.boiler_enabled, boiler_switch: String(this._config.boiler_switch || ''), boiler_switch_domain: String((this._config.boiler_switch_domain === 'input_boolean') ? 'input_boolean' : 'switch'), boiler_rooms: Array.isArray(this._config.boiler_rooms) ? this._config.boiler_rooms.filter(Boolean).map(String) : null, boiler_on_offset: (this._config.boiler_on_offset === null || this._config.boiler_on_offset === undefined) ? null : Number(this._config.boiler_on_offset), boiler_off_offset: (this._config.boiler_off_offset === null || this._config.boiler_off_offset === undefined) ? null : Number(this._config.boiler_off_offset), boiler_temp_sensor: String(this._config.boiler_temp_sensor || ''), boiler_min_temp: (this._config.boiler_min_temp === null || this._config.boiler_min_temp === undefined) ? null : Number(this._config.boiler_min_temp), boiler_max_temp: (this._config.boiler_max_temp === null || this._config.boiler_max_temp === undefined) ? null : Number(this._config.boiler_max_temp), boiler_multi_enabled: !!this._config.boiler_multi_enabled, boiler_room_settings: (this._config.boiler_room_settings && typeof this._config.boiler_room_settings === 'object') ? this._config.boiler_room_settings : {}, weekdays_enabled: !!this._config.weekdays_enabled, weekdays_mode: this._config.weekdays_mode || base.weekdays_mode || 'weekday_weekend', weekdays_view: this._config.weekdays_view || base.weekdays_view || 'all_rooms_one_day', weekdays_view_switch_in_timeline: !!(this._config.weekdays_view_switch_in_timeline ?? base.weekdays_view_switch_in_timeline ?? false), weekdays_selected_room: this._config.weekdays_selected_room || base.weekdays_selected_room || '', seasonal_enabled: !!this._config.seasonal_enabled, seasonal_mode: (this._config.seasonal_mode === 'summer') ? 'summer' : 'winter', presence_sensors: this._config.presence_sensors, presence_sensor_temps: (()=>{ try { const src = (this._config.presence_sensor_temps && typeof this._config.presence_sensor_temps === 'object') ? this._config.presence_sensor_temps : {}; const out = {}; for (const k of Object.keys(src||{})) { const v = Number(src[k]); if (Number.isFinite(v)) out[String(k)] = toStore(v); } return out; } catch { return {}; } })(), presence_sensor_delays: (()=>{ try { const src = (this._config.presence_sensor_delays && typeof this._config.presence_sensor_delays === 'object') ? this._config.presence_sensor_delays : {}; const out = {}; for (const k of Object.keys(src||{})) { const v = src?.[k]; if (!v || typeof v !== 'object') continue; const onS = Number(v.on_s); const offS = Number(v.off_s); const obj = {}; if (Number.isFinite(onS) && onS >= 0) obj.on_s = onS; if (Number.isFinite(offS) && offS >= 0) obj.off_s = offS; if (Object.keys(obj).length) out[String(k)] = obj; } return out; } catch { return {}; } })(), presence_sensor_delay_units: (()=>{ try { const src = (this._config.presence_sensor_delay_units && typeof this._config.presence_sensor_delay_units === 'object') ? this._config.presence_sensor_delay_units : {}; const out = {}; for (const k of Object.keys(src||{})) { const u = String(src[k]||'').toLowerCase().trim(); if (u === 'seconds' || u === 'minutes') out[String(k)] = u; } return out; } catch { return {}; } })(), open_window: this._config.open_window, boiler_enabled: !!this._config.boiler_enabled, boiler_switch: String(this._config.boiler_switch || ''), boiler_switch_domain: (()=>{ try { const sw = String(this._config.boiler_switch || ''); const dom = sw.split('.')[0]; if (dom === 'input_boolean' || dom === 'switch') return dom; const raw = String(this._config.boiler_switch_domain || '').toLowerCase().trim(); return (raw === 'input_boolean') ? 'input_boolean' : 'switch'; } catch { return 'switch'; } })(), boiler_rooms: Array.isArray(this._config.boiler_rooms) ? this._config.boiler_rooms.filter(Boolean).map(String) : null, boiler_on_offset: (this._config.boiler_on_offset === null || this._config.boiler_on_offset === undefined) ? null : toStore(this._config.boiler_on_offset), boiler_off_offset: (this._config.boiler_off_offset === null || this._config.boiler_off_offset === undefined) ? null : toStore(this._config.boiler_off_offset), boiler_temp_sensor: String(this._config.boiler_temp_sensor || ''), boiler_min_temp: (this._config.boiler_min_temp === null || this._config.boiler_min_temp === undefined) ? null : toStore(this._config.boiler_min_temp), boiler_max_temp: (this._config.boiler_max_temp === null || this._config.boiler_max_temp === undefined) ? null : toStore(this._config.boiler_max_temp), boiler_multi_enabled: !!this._config.boiler_multi_enabled, boiler_room_settings: (()=>{ try { const src = (this._config.boiler_room_settings && typeof this._config.boiler_room_settings === 'object') ? this._config.boiler_room_settings : {}; const out = {}; for (const k of Object.keys(src||{})) { const r = src[k]; if (!r || typeof r !== 'object') continue; const enabled = !!r.enabled; const sw = String(r.switch || ''); const dom = String(r.switch_domain || '').toLowerCase().trim(); const onOff = Number(r.on_offset); const offOff = Number(r.off_offset); const obj = { enabled, switch: sw, switch_domain: (dom === 'input_boolean') ? 'input_boolean' : 'switch' }; if (Number.isFinite(onOff)) obj.on_offset = toStore(onOff); if (Number.isFinite(offOff)) obj.off_offset = toStore(offOff); if (enabled || sw || Number.isFinite(onOff) || Number.isFinite(offOff) || dom) out[String(k)] = obj; } return out; } catch { return {}; } })(), show_pause_button: !!(this._config.show_pause_button ?? true), show_room_temp: !!(this._config.show_room_temp ?? true), pause_sensor_enabled: !!(this._config.pause_sensor_enabled ?? false), pause_sensor_entity: String(this._config.pause_sensor_entity || ''), presence_sensor_enabled: !!(this._config.presence_sensor_enabled ?? false), presence_live_header: !!(this._config.presence_live_header ?? true), auto_apply_enabled: !!this._config.auto_apply, apply_on_edit: !!this._config.apply_on_edit, apply_on_default_change: !!this._config.apply_on_default_change, backup_auto_enabled: !!this._config.backup_auto_enabled, backup_interval_days: Number(this._config.backup_interval_days||1), profiles_enabled: !!this._config.profiles_enabled, global_profile: this._globalProfile || null, pause_indef: !!this._pauseIndef, pause_until_ms: Number(this._pauseUntilMs||0), holidays_enabled: !!this._config.holidays_enabled, holidays_source: this._config.holidays_source || 'calendar', holidays_entity: this._config.holidays_entity || '', holidays_dates: Array.isArray(this._config.holidays_dates) ? this._config.holidays_dates : [], holidays_groups: Array.isArray(this._config.holidays_groups) ? this._config.holidays_groups : [], sync_mode: (this._config.storage_sync_mode||'instant'), sync_delay_min: Number(Math.max(0, Math.round((this._config.storage_sync_sec||0)/60))), sync_delay_sec: Number(this._config.storage_sync_sec||0) }; // Do not include holidays_enabled in generic payload unless user toggled it in this session try { if (!this._holidaysToggleTouched) delete settings.holidays_enabled; } catch {} // Do not include holidays source/entity/dates/groups unless explicitly edited in this session try { if (!this._holidaysSourceTouched) delete settings.holidays_source; if (!this._holidaysEntityTouched) delete settings.holidays_entity; if (!this._holidaysDatesTouched) delete settings.holidays_dates; if (!this._holidaysGroupsTouched) delete settings.holidays_groups; } catch {} // Do not include boiler_enabled unless user toggled it in this session try { if (!this._boilerToggleTouched) delete settings.boiler_enabled; } catch {} // Always include away settings to avoid losing away.enabled due touch-flag races. const colors = { color_ranges: this._config.color_ranges, color_global: !!this._config.color_global }; // When a dedicated colors sensor is configured, avoid duplicating colors inside settings payload if (this._config?.storage_enabled && this._storageEntity('colors')) { delete settings.color_ranges; delete settings.color_global; } else { settings.color_ranges = colors.color_ranges; settings.color_global = colors.color_global; } const out = { settings, colors }; if (includeSchedules) { out.schedules = schedules; out.weekdays = weekdays; out.profiles = profiles; } return out; } catch { return { schedules: this._schedules, settings: { temp_unit: this._config.temp_unit } }; } } _maxDisplay(){ const c = Number(this._config?.max_temp ?? 25); return this._toDisplayTemp(c); } _minDisplay(){ const c = Number(this._config?.min_temp ?? 5); return this._toDisplayTemp(c); } _rowDefaultTemp(row){ try { if (this._config?.per_room_defaults) { const v = Number(row?.defaultTemp); return Number.isFinite(v) ? v : Number(this._config?.default_temp ?? 20); } return Number(this._config?.default_temp ?? 20); } catch { return Number(this._config?.default_temp ?? 20); } } _todayFullName(){ try { const lang = ttGetLangFromHass(this._hass || this._lang || 'en'); const opts = { weekday: 'long' }; if (this._useHaTime() && this._hass?.config?.time_zone) Object.assign(opts, { timeZone: this._hass.config.time_zone }); const s = new Intl.DateTimeFormat(lang, opts).format(new Date()); return s.charAt(0).toUpperCase()+s.slice(1); } catch { const names = { mon:'Monday', tue:'Tuesday', wed:'Wednesday', thu:'Thursday', fri:'Friday', sat:'Saturday', sun:'Sunday' }; return names[this._todayKey()] || 'Today'; } } // --- Seasonal schedule helpers --- _seasonEnabled(){ try { return !!this._config?.seasonal_enabled; } catch { return false; } } _seasonKey(){ try { return (String(this._config?.seasonal_mode || 'winter') === 'summer') ? 'summer' : 'winter'; } catch { return 'winter'; } } _cloneSeasonDataFromRow(row){ try { return { blocks: Array.isArray(row?.blocks) ? JSON.parse(JSON.stringify(row.blocks)) : [], weekly: row?.weekly ? JSON.parse(JSON.stringify(row.weekly)) : undefined, weekly_modes: row?.weekly_modes ? JSON.parse(JSON.stringify(row.weekly_modes)) : undefined, presence: row?.presence ? JSON.parse(JSON.stringify(row.presence)) : undefined, }; } catch { return { blocks: [] }; } } _ensureSeasonStruct(row){ try { if (!row) return row; if (!row.seasons || typeof row.seasons !== 'object') row.seasons = {}; const ensureBucket = (key)=>{ if (!row.seasons[key] || typeof row.seasons[key] !== 'object') row.seasons[key] = {}; if (!Array.isArray(row.seasons[key].blocks)) row.seasons[key].blocks = []; }; ensureBucket('winter'); ensureBucket('summer'); return row; } catch { return row; } } _syncSeasonDataFromRow(row, seasonKey){ try { if (!row) return; this._ensureSeasonStruct(row); const key = (seasonKey === 'summer') ? 'summer' : 'winter'; row.seasons[key] = this._cloneSeasonDataFromRow(row); } catch {} } _loadSeasonDataIntoRow(row, seasonKey){ try { if (!row) return; this._ensureSeasonStruct(row); const key = (seasonKey === 'summer') ? 'summer' : 'winter'; const src = row.seasons?.[key] || {}; row.blocks = Array.isArray(src.blocks) ? JSON.parse(JSON.stringify(src.blocks)) : []; if (src.weekly) row.weekly = JSON.parse(JSON.stringify(src.weekly)); else delete row.weekly; if (src.weekly_modes) row.weekly_modes = JSON.parse(JSON.stringify(src.weekly_modes)); else delete row.weekly_modes; if (src.presence) row.presence = JSON.parse(JSON.stringify(src.presence)); else delete row.presence; } catch {} } _prepareSeasonalRowsOnEnable(){ try { if (!this._seasonEnabled()) return; for (const eid of (this._config?.entities || [])) { const row = this._schedules?.[eid]; if (!row) continue; this._ensureSeasonStruct(row); const hasData = (bucket)=>{ try { if (!bucket || typeof bucket !== 'object') return false; if (Array.isArray(bucket.blocks) && bucket.blocks.length) return true; if (bucket.weekly && typeof bucket.weekly === 'object') return true; if (bucket.weekly_modes && typeof bucket.weekly_modes === 'object') return true; } catch {} return false; }; const curData = this._cloneSeasonDataFromRow(row); if (!hasData(row.seasons?.winter)) row.seasons.winter = JSON.parse(JSON.stringify(curData)); if (!hasData(row.seasons?.summer)) row.seasons.summer = JSON.parse(JSON.stringify(curData)); } } catch {} } _applySeasonalModeToRows(force=false, prevSeasonOverride=null){ try { if (!this._seasonEnabled()) { this._seasonAppliedKey = null; return; } const next = this._seasonKey(); if (!force && this._seasonAppliedKey === next) return; const prev = prevSeasonOverride || this._seasonAppliedKey || next; for (const eid of (this._config?.entities || [])) { const row = this._schedules?.[eid]; if (!row) continue; this._ensureSeasonStruct(row); // Store current working data into previous season bucket if (prev) this._syncSeasonDataFromRow(row, prev); // Load target season into working fields this._loadSeasonDataIntoRow(row, next); } this._seasonAppliedKey = next; } catch {} } _setSeasonMode(next){ try { if (!this._seasonEnabled()) return; const prev = this._seasonKey(); const target = (String(next) === 'summer') ? 'summer' : 'winter'; if (target === prev) return; this._config.seasonal_mode = target; try { if (this._settingsDraft) this._settingsDraft.seasonal_mode = target; } catch {} this._applySeasonalModeToRows(true, prev); try { this._render(); } catch {} try { this._scheduleNextApply(); } catch {} try { this._debouncedSaveStore(); } catch {} } catch {} } _toggleSeasonMode(){ try { const next = (this._seasonKey() === 'summer') ? 'winter' : 'summer'; this._setSeasonMode(next); } catch {} } _syncSeasonalRowsToBucket(){ try { if (!this._seasonEnabled()) return; const key = this._seasonKey(); for (const eid of (this._config?.entities || [])) { const row = this._schedules?.[eid]; if (!row) continue; this._syncSeasonDataFromRow(row, key); } } catch {} } _weekdayFullName(dayKey){ try { const k = String(dayKey || '').toLowerCase(); const map = { mon: 'week.days_full.mon', tue: 'week.days_full.tue', wed: 'week.days_full.wed', thu: 'week.days_full.thu', fri: 'week.days_full.fri', sat: 'week.days_full.sat', sun: 'week.days_full.sun' }; const t = map[k] ? this._t(map[k]) : ''; if (t) return String(t); } catch {} try { const names = { mon:'Monday', tue:'Tuesday', wed:'Wednesday', thu:'Thursday', fri:'Friday', sat:'Saturday', sun:'Sunday' }; return names[String(dayKey||'').toLowerCase()] || this._todayFullName(); } catch { return this._todayFullName(); } } // Format ISO timestamp in a readable, localized way _fmtDateTimeIso(iso){ try { if (!iso) return ''; const d = new Date(iso); const lang = ttGetLangFromHass(this._hass || this._lang || 'en'); const opts = { dateStyle: 'medium', timeStyle: 'short' }; if (this._useHaTime() && this._hass?.config?.time_zone) opts.timeZone = this._hass.config.time_zone; return new Intl.DateTimeFormat(lang, opts).format(d); } catch { return String(iso||''); } } // --- Weekday helpers --- _weekdayNames(){ return ['mon','tue','wed','thu','fri','sat','sun']; } _hasAnyMainBlocks(){ try { for (const eid of (this._config?.entities || [])) { const row = this._schedules?.[eid]; if (row && Array.isArray(row.blocks) && row.blocks.length) return true; } } catch {} return false; } _promptWeekdayCopyMode(){ try { const m1 = this._t('week.mode.weekday_weekend') || 'Weekdays + Weekend'; const m2 = this._t('week.mode.weekday_sat_sun') || 'Weekdays + Saturday + Sunday'; const m3 = this._t('week.mode.all_7') || 'All days'; const msgT = this._t('week.copy_from_main.mode_prompt') || 'Choose weekday mode:\n1) {m1}\n2) {m2}\n3) {m3}\nEnter 1-3:'; const msg = msgT.replace('{m1}', m1).replace('{m2}', m2).replace('{m3}', m3); const defMode = String(this._config?.weekdays_mode || 'weekday_weekend'); const def = defMode === 'weekday_sat_sun' ? '2' : (defMode === 'all_7' ? '3' : '1'); const ans = prompt(msg, def); if (ans == null) return null; const a = String(ans).trim().toLowerCase(); if (a === '2' || a === 'weekday_sat_sun') return 'weekday_sat_sun'; if (a === '3' || a === 'all_7' || a === 'all7') return 'all_7'; if (a === '1' || a === 'weekday_weekend') return 'weekday_weekend'; return 'weekday_weekend'; } catch { return null; } } _copyMainToWeekdays(mode, target){ try { const m = (mode === 'weekday_sat_sun' || mode === 'all_7') ? mode : 'weekday_weekend'; const makeBlocks = (arr)=> (Array.isArray(arr) ? arr.map(b=>({ id: Math.random().toString(36).slice(2,9), startMin: b.startMin, endMin: b.endMin, temp: b.temp })) : []); const names = this._weekdayNames(); const t = String(target || '').toLowerCase().trim(); for (const eid of (this._config?.entities || [])) { const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureWeeklyStruct(row); const days = {}; for (const k of names) days[k] = []; const src = row.blocks || []; if (m === 'weekday_weekend') { const tgt = (t === 'weekend') ? ['sat','sun'] : ['mon','tue','wed','thu','fri']; for (const k of tgt) days[k] = makeBlocks(src); } else if (m === 'weekday_sat_sun') { if (t === 'sat' || t === 'sun') { days[t] = makeBlocks(src); } else { for (const k of ['mon','tue','wed','thu','fri']) days[k] = makeBlocks(src); } } else { const dk = names.includes(t) ? t : 'mon'; days[dk] = makeBlocks(src); } row.weekly_modes[m] = { days: JSON.parse(JSON.stringify(days)) }; row.weekly = { mode: m, days: JSON.parse(JSON.stringify(days)) }; this._schedules[eid] = row; } this._config.weekdays_mode = m; try { this._syncSeasonalRowsToBucket(); } catch {} } catch {} } _timelineDisplayDayKey(){ try { const enabled = !!this._config?.weekdays_enabled; const view = String(this._config?.weekdays_view || 'all_rooms_one_day'); if (enabled && view === 'all_rooms_one_day') { const names = this._weekdayNames(); const cur = String(this._weekdaysViewDayKey || '').toLowerCase(); if (names.includes(cur)) return cur; const today = this._todayKey(); this._weekdaysViewDayKey = today; return today; } } catch {} return this._todayKey(); } _setTimelineDisplayDayKey(dayKey){ try { const k = String(dayKey || '').toLowerCase(); if (this._weekdayNames().includes(k)) this._weekdaysViewDayKey = k; } catch {} } _weekdaysStorageDayKeyForMode(displayDayKey, mode){ const d = String(displayDayKey || 'mon').toLowerCase(); const isWk = ['mon','tue','wed','thu','fri'].includes(d); const m = String(mode || 'weekday_weekend'); if (m === 'weekday_weekend') return isWk ? 'mon' : 'sat'; if (m === 'weekday_sat_sun') return isWk ? 'mon' : d; // sat/sun return d; // all_7 } _todayKey(){ try { if (this._useHaTime() && this._hass?.config?.time_zone) { const tz = this._hass.config.time_zone; const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short' }).format(new Date()); const map = { Mon:'mon', Tue:'tue', Wed:'wed', Thu:'thu', Fri:'fri', Sat:'sat', Sun:'sun' }; return map[parts] || 'mon'; } } catch {} const d=new Date(); const idx=((d.getDay()+6)%7); return this._weekdayNames()[idx]; } _ensureWeeklyStruct(row){ if (!row.weekly) { row.weekly = { mode: this._config?.weekdays_mode || 'weekday_weekend', days: {} }; } const names=this._weekdayNames(); for (const k of names){ if (!Array.isArray(row.weekly.days[k])) row.weekly.days[k] = []; } // Maintain separate saved schedules per mode if (!row.weekly_modes) { row.weekly_modes = { weekday_weekend: { days: {} }, weekday_sat_sun: { days: {} }, all_7: { days: {} } }; // Seed current into active mode bucket try { const curMode = row.weekly?.mode || this._config?.weekdays_mode || 'weekday_weekend'; row.weekly_modes[curMode] = { days: JSON.parse(JSON.stringify(row.weekly?.days||{})) }; } catch {} } try { const modes = ['weekday_weekend','weekday_sat_sun','all_7']; for (const m of modes){ const bucket = row.weekly_modes[m] = row.weekly_modes[m] || { days: {} }; for (const k of names){ if (!Array.isArray(bucket.days[k])) bucket.days[k] = []; } } } catch {} return row; } _getBlocksForDay(row, dayKey){ if (!this._config?.weekdays_enabled || !row?.weekly) return row?.blocks || []; const days = row.weekly.days || {}; return Array.isArray(days[dayKey]) ? days[dayKey] : (row.blocks||[]); } _weeklyEditableKeys(mode){ const all = this._weekdayNames(); if (!mode) mode = 'weekday_weekend'; if (mode==='weekday_weekend') return ['weekdays','weekend']; if (mode==='weekday_sat_sun') return ['weekdays','sat','sun']; if (mode==='all_7') return all; return ['weekdays','weekend']; } _effectiveDayKey(sel){ if (sel==='weekdays') return 'mon'; if (sel==='weekend') return 'sat'; return sel || 'mon'; } // --- Profiles (named daily schedules) helpers --- _ensureProfilesStruct(row){ try { if (!row) return row; if (!row.profiles || typeof row.profiles !== 'object') row.profiles = {}; if (!('activeProfile' in row)) row.activeProfile = null; } catch {} return row; } // --- Holidays helpers --- _ensureHolidayStruct(row){ try { if (!row) return row; if (!row.holiday || !Array.isArray(row.holiday.blocks)) row.holiday = { blocks: [] }; } catch {} return row; } // --- Presence (advanced away) helpers --- _ensurePresenceStruct(row){ try { if (!row) return row; if (!row.presence || typeof row.presence !== 'object') row.presence = {}; } catch {} return row; } _presencePersons(){ try { return Array.isArray(this._config?.away?.persons) ? [...this._config.away.persons] : []; } catch { return []; } } _presenceComboKey(homeList, awayList){ try { const h = [...(homeList||[])].map(String).sort(); const a = [...(awayList||[])].map(String).sort(); return `H:${h.join(',')}|A:${a.join(',')}`; } catch { return 'H:|A:'; } } _presenceAllCombos(){ try { const persons = this._presencePersons().map(String); const n = persons.length; const out = []; for (let mask=0; mask < (1<>i)&1) ? home.push(persons[i]) : away.push(persons[i]); } // Skip the "all home" combination — when everyone is home, the main/weekdays // schedule should be used and advanced presence should not override. if (home.length === persons.length) continue; const key = this._presenceComboKey(home, away); const homeNames = home.map(e=> this._hass?.states?.[e]?.attributes?.friendly_name || (e.split('.')[1]||e)).join(', '); const awayNames = away.map(e=> this._hass?.states?.[e]?.attributes?.friendly_name || (e.split('.')[1]||e)).join(', '); let label; if (home.length === 0) label = this._t('presence.none_home'); else { const h = (this._t('presence.home') || 'Home: {names}').replace('{names}', homeNames); const a = (this._t('presence.away') || 'Away: {names}').replace('{names}', awayNames); label = `${h} • ${a}`; } out.push({ key, home:[...home], away:[...away], label }); } // Sort with "none home" first, then by length of home desc (all‑home is omitted) out.sort((x,y)=>{ if (x.home.length===0 && y.home.length!==0) return -1; if (y.home.length===0 && x.home.length!==0) return 1; return y.home.length - x.home.length; }); return out; } catch { return []; } } _activePresenceComboKey(){ try { if (!this._config?.away?.advanced_enabled) return null; const persons = this._presencePersons(); if (!persons.length) return null; const home=[], away=[]; for (const p of persons){ const st = this._hass?.states?.[p]; const s = String(st?.state||'').toLowerCase(); if (s==='home') home.push(p); else away.push(p); } // Apply optional delay before switching into (or out of) presence mode try { const delayMs = this._awayDelayThresholdMs(); if (delayMs > 0){ const anyAway = (home.length !== persons.length); const now = Date.now(); if (this._awayWant !== anyAway) { this._awayWant = anyAway; this._awayWantSinceMs = now; } const stable = (now - (this._awayWantSinceMs||0)) >= delayMs; if (!stable) return this._lastPresenceKey || null; } } catch {} // When everyone is home, do not use advanced presence at all — fall back to // the main/weekdays schedule. No need for an "all home" presence plan. if (home.length === persons.length) return null; const key = this._presenceComboKey(home, away); const combos = (this._config?.away?.combos) || {}; const meta = combos[key]; if (meta && meta.enabled) { this._lastPresenceKey = key; return key; } return null; } catch { return null; } } _todayISO(){ try { if (this._useHaTime() && this._hass?.config?.time_zone) { const tz = this._hass.config.time_zone; const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); // en-CA gives YYYY-MM-DD return String(parts); } } catch {} try { const d=new Date(); const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${dd}`; } catch { return ''; } } _isHolidayActive(){ try { if (!this._config?.holidays_enabled) return false; const src = this._config.holidays_source || 'calendar'; if (src === 'manual') { const list = Array.isArray(this._config.holidays_dates) ? this._config.holidays_dates : []; const today = this._todayISO(); return !!list.find(d=>String(d)===today); } // calendar/binary_sensor entity const eid = this._config.holidays_entity || ''; if (!eid) return false; const st = this._hass?.states?.[eid]; const s = String(st?.state||'').toLowerCase(); return s === 'on'; } catch { return false; } } // --- Merge helpers --- _groupPrimaryOf(eid){ const prims = this._config?.entities || []; if (prims.includes(eid)) return eid; const merges = this._config?.merges || {}; for (const p of prims){ const arr = merges[p]||[]; if (arr.includes(eid)) return p; } return eid; } _allTargetEntities(){ const out = new Set(); const merges = this._config?.merges || {}; for (const p of (this._config?.entities||[])){ out.add(p); for (const l of (merges[p]||[])) out.add(l); } return Array.from(out); } _desiredTempFor(eid, nowMin){ const primary = this._groupPrimaryOf(eid); const row = this._schedules[primary]; if (!row) return null; this._ensureHolidayStruct(row); this._ensurePresenceStruct(row); let blocks = row.blocks||[]; let usedPresence = false; let usedHoliday = false; let presenceKeyActive = null; let presenceHasBlocksForRoom = false; // Holidays override take highest precedence when enabled try { if (this._isHolidayActive() && row.holiday && Array.isArray(row.holiday.blocks) && row.holiday.blocks.length) { blocks = row.holiday.blocks || []; usedHoliday = true; } else { // fall through } } catch {} // Advanced presence overrides (if enabled and a combo is active) — below holidays, above profiles/weekdays try { presenceKeyActive = this._activePresenceComboKey && this._activePresenceComboKey(); if (presenceKeyActive) { const roomBlocks = row.presence && row.presence[presenceKeyActive] && Array.isArray(row.presence[presenceKeyActive].blocks) ? row.presence[presenceKeyActive].blocks : []; if (roomBlocks.length) { blocks = roomBlocks; usedPresence = true; presenceHasBlocksForRoom = true; } else { // Presence combo is active but this room has no presence schedule -> use a virtual all‑day Away block const awayC = Number(this._config?.away?.target_c ?? 17); const fallback = Number.isFinite(awayC) ? awayC : this._rowDefaultTemp(row); blocks = [{ id: '__presence_away__', startMin: 0, endMin: 1440, temp: fallback }]; usedPresence = true; // treat as presence so simple away clamp won't run later presenceHasBlocksForRoom = false; } } } catch {} // Profiles/Weekdays fallback if presence not used if (!usedPresence) { try { if (this._config?.profiles_enabled && !(this._isHolidayActive() && row?.holiday?.blocks?.length)) { this._ensureProfilesStruct(row); const ap = row.activeProfile; if (ap && row.profiles && Array.isArray(row.profiles[ap]?.blocks) && (row.profiles[ap].blocks||[]).length) { blocks = row.profiles[ap].blocks || []; } else if (this._config?.weekdays_enabled && row.weekly) { const dayKey = this._todayKey(); blocks = this._getBlocksForDay(row, dayKey); } } else if (this._config?.weekdays_enabled && row.weekly) { const dayKey = this._todayKey(); blocks = this._getBlocksForDay(row, dayKey); } } catch { /* fall back to default blocks */ } } const hit = (blocks||[]).find(b => nowMin >= b.startMin && nowMin < b.endMin); let want = Number(hit ? hit.temp : this._rowDefaultTemp(row)); // Apply away override if active // Rules: // - If advanced presence is enabled, the simple Away clamp must NEVER apply // (advanced replaces basic away entirely), regardless of whether a presence // schedule is currently used. // - If advanced presence is OFF, apply simple Away clamp only when we did not // use a presence schedule for this room (usedPresence=false). try { const advOn = !!(this._config?.away?.advanced_enabled); if (!advOn && !usedPresence && this._isAwayActive() && !this._awayBypass) { const targetC = Number(this._config?.away?.target_c ?? 17); if (Number.isFinite(targetC)) want = Math.min(want, targetC); } } catch {} // Per-room presence sensor override with optional on/off delays try { if (this._config?.presence_sensor_enabled) { const sEid = this._presenceSensorEntityFor ? this._presenceSensorEntityFor(primary) : ''; if (sEid) { const active = this._presenceSensorActiveFor ? !!this._presenceSensorActiveFor(primary) : false; if (active) { const tmap = (this._config?.presence_sensor_temps && typeof this._config.presence_sensor_temps === 'object') ? this._config.presence_sensor_temps : {}; const ov = Number(tmap?.[primary]); if (Number.isFinite(ov)) want = ov; } } } } catch {} return want; } // ---- Presence sensor (per-room) helpers ---- _presenceSensorEntityFor(primary){ try { if (!this._config?.presence_sensor_enabled) return ''; const map = (this._config?.presence_sensors && typeof this._config.presence_sensors === 'object') ? this._config.presence_sensors : {}; const sEid = String(map?.[primary] || '').trim(); if (sEid && sEid.startsWith('binary_sensor.')) return sEid; } catch {} return ''; } _presenceSensorDelayMsFor(primary){ try { const defaultS = 180; // 3 minutes const dmap = (this._config?.presence_sensor_delays && typeof this._config.presence_sensor_delays === 'object') ? this._config.presence_sensor_delays : {}; const o = (dmap?.[primary] && typeof dmap?.[primary] === 'object') ? dmap[primary] : {}; const onS0 = Number(o?.on_s); const offS0 = Number(o?.off_s); const onS = Number.isFinite(onS0) ? Math.max(0, onS0) : defaultS; const offS = Number.isFinite(offS0) ? Math.max(0, offS0) : defaultS; const onMs = onS * 1000; const offMs = offS * 1000; return { onMs, offMs }; } catch {} return { onMs: 0, offMs: 0 }; } _presenceSensorActiveFor(primary){ try { if (!this._config?.presence_sensor_enabled) return false; const sEid = this._presenceSensorEntityFor(primary); if (!sEid) return false; const st = this._hass?.states?.[sEid]; const s = String(st?.state || '').toLowerCase().trim(); const now = Date.now(); const changedMs = (()=>{ try { const raw = st?.last_changed || st?.last_updated || null; const ms = raw ? Date.parse(String(raw)) : NaN; return Number.isFinite(ms) ? ms : now; } catch { return now; } })(); const { onMs, offMs } = this._presenceSensorDelayMsFor(primary); try { if (!this._presenceSensorLatch) this._presenceSensorLatch = {}; } catch {} const latch0 = this._presenceSensorLatch?.[primary]; const latch = (latch0 && typeof latch0 === 'object') ? latch0 : { last: null, sinceMs: changedMs, active: false }; // Sync our "since" to HA's last_changed for accuracy if (latch.last !== s || Number(latch.sinceMs||0) !== Number(changedMs||0)) { latch.last = s; latch.sinceMs = changedMs; } const dur = now - Number(latch.sinceMs||now); if (s === 'on') { if (!latch.active) { latch.active = (dur >= onMs); } } else if (s === 'off') { if (latch.active) { latch.active = !(dur >= offMs); } } else { // Unknown/unavailable: fall back to schedule (disable override) latch.active = false; } this._presenceSensorLatch[primary] = latch; return !!latch.active; } catch {} return false; } _presenceSensorActiveSnapshot(){ const out = {}; try { if (!this._config?.presence_sensor_enabled) return out; const ents = Array.isArray(this._config?.entities) ? this._config.entities.filter(Boolean).map(String) : []; for (const primary of ents) { const sEid = this._presenceSensorEntityFor(primary); if (!sEid) continue; out[primary] = !!this._presenceSensorActiveFor(primary); } } catch {} return out; } _presenceSensorSnapChanged(a, b){ try { const aa = (a && typeof a === 'object') ? a : {}; const bb = (b && typeof b === 'object') ? b : {}; const keys = new Set([...(Object.keys(aa)), ...(Object.keys(bb))]); for (const k of keys) { if (!!aa[k] !== !!bb[k]) return true; } } catch {} return false; } _schedulePresenceDelayTimer(){ try { if (this._presenceDelayTimer) { clearTimeout(this._presenceDelayTimer); this._presenceDelayTimer = null; } } catch {} try { if (!this._config?.auto_apply) return; if (!this._config?.presence_sensor_enabled) return; if (!this._hass) return; if (!this._hasConfig) return; if (this._isPaused && this._isPaused()) return; const ents = Array.isArray(this._config?.entities) ? this._config.entities.filter(Boolean).map(String) : []; const now = Date.now(); let bestDue = null; for (const primary of ents) { const sEid = this._presenceSensorEntityFor(primary); if (!sEid) continue; const st = this._hass?.states?.[sEid]; const s = String(st?.state || '').toLowerCase().trim(); if (s !== 'on' && s !== 'off') continue; // Ensure latch is updated before computing due const active = !!this._presenceSensorActiveFor(primary); const { onMs, offMs } = this._presenceSensorDelayMsFor(primary); const changedMs = (()=>{ try { const raw = st?.last_changed || st?.last_updated || null; const ms = raw ? Date.parse(String(raw)) : NaN; return Number.isFinite(ms) ? ms : now; } catch { return now; } })(); let due = null; if (s === 'on' && !active && onMs > 0) due = changedMs + onMs; else if (s === 'off' && active && offMs > 0) due = changedMs + offMs; if (due != null && due > now) { if (bestDue == null || due < bestDue) bestDue = due; } } if (bestDue == null) return; const delay = Math.max(500, bestDue - Date.now()); this._presenceDelayTimer = setTimeout(async ()=>{ try { if (!this._config?.auto_apply) return; if (this._isPaused && this._isPaused()) return; if (this._config?.storage_enabled) { await this._nudgeBackgroundApplyNow(); } else { await this._applyCurrentSetpoints(true, true); } this._scheduleNextApply(); } catch {} try { this._schedulePresenceDelayTimer(); } catch {} }, delay); } catch {} } _awayDelayThresholdMs(){ try { const a=this._config?.away||{}; if (!a.delay_enabled) return 0; const v = Number(a.delay_value||0); const u = String(a.delay_unit||'minutes'); const ms = Math.max(0, Math.round(v)) * (u==='seconds'?1000:60000); return ms; } catch { return 0; } } _isAwayActive(){ try { const a = this._config?.away; if (!a || !a.enabled) return false; const persons = Array.isArray(a.persons) ? a.persons : []; if (!persons.length) return false; let want = true; // assume away until we find someone home for (const p of persons){ const st = this._hass?.states?.[p]; const s = (st?.state||'').toLowerCase(); if (s === 'home') { want = false; break; } } const delayMs = this._awayDelayThresholdMs(); if (!delayMs) { this._lastAwayActive = want; return want; } const now = Date.now(); if (this._awayWant !== want) { this._awayWant = want; this._awayWantSinceMs = now; } const stable = (now - (this._awayWantSinceMs||0)) >= delayMs; const prev = !!this._lastAwayActive; const out = stable ? want : prev; this._lastAwayActive = out; return out; } catch { return false; } } // ---- Pause helpers ---- _isPausedBySensor(){ try { if (!this._config?.pause_sensor_enabled) return false; const eid = String(this._config?.pause_sensor_entity || '').trim(); if (!eid || !eid.startsWith('binary_sensor.')) return false; const st = this._hass?.states?.[eid]; const s = String(st?.state || '').toLowerCase(); return s === 'on'; } catch { return false; } } _isPaused(){ try { if (this._isPausedBySensor()) return true; if (this._pauseIndef) return true; const until = Number(this._pauseUntilMs||0); if (Number.isFinite(until) && until > Date.now()) return true; } catch {} return false; } _resumeAtMs(){ const u = Number(this._pauseUntilMs||0); return (Number.isFinite(u) && u>0) ? u : null; } _scheduleResumeTimer(){ try { if (this._resumeTimer) clearTimeout(this._resumeTimer); if (!this._isPaused()) return; if (this._pauseIndef || this._isPausedBySensor()) return; // manual or sensor resume only const when = this._resumeAtMs(); if (!when) return; const delay = Math.max(500, when - Date.now()); this._resumeTimer = setTimeout(async ()=>{ // Resume reached: re-render UI and apply immediately once try { this._render(); } catch {} await this._applyCurrentSetpoints(true, true); this._scheduleNextApply(); }, delay); } catch {} } _formatPauseCountdown(){ try { const until = Number(this._pauseUntilMs||0); const now = Date.now(); if (!until || until <= now) return ''; let ms = until - now; const totalSec = Math.floor(ms/1000); const h = Math.floor(totalSec/3600); const m = Math.floor((totalSec%3600)/60); const s = totalSec%60; const pad = (n)=>String(n).padStart(2,'0'); if (h>0) return `${h}:${pad(m)}:${pad(s)}`; return `${m}:${pad(s)}`; } catch { return ''; } } _startCountdownTimer(){ try { if (this._countdownTimer) clearInterval(this._countdownTimer); const wrap = this.shadowRoot && this.shadowRoot.querySelector('.pause-wrap'); const etaEl = wrap && wrap.querySelector && wrap.querySelector('.pause-eta'); if (!this._isPaused() || this._pauseIndef || this._isPausedBySensor() || !etaEl) return; const tick = ()=>{ try { if (!this._isPaused() || this._pauseIndef) { clearInterval(this._countdownTimer); this._countdownTimer=null; etaEl.textContent=''; return; } etaEl.textContent = this._formatPauseCountdown(); } catch {} }; tick(); this._countdownTimer = setInterval(tick, 1000); } catch {} } _nextBoundaryDate(){ // HA timezone mode: compute delay in HA tz and return a local Date for the timeout if (this._useHaTime() && this._hass?.config?.time_zone) { const nowMin = this._getNowMin(); let bestT = null; let bestDelta = Infinity; for (const eid of (this._config.entities || [])){ const row = this._schedules[eid]; if (!row) continue; const times = []; // Prefer presence schedule when enabled and active; then profiles/weekdays let blocks = row.blocks||[]; let usedPresence=false; try { const key = this._activePresenceComboKey && this._activePresenceComboKey(); if (key && row.presence && Array.isArray(row.presence[key]?.blocks) && row.presence[key].blocks.length) { blocks = row.presence[key].blocks || []; usedPresence=true; } } catch {} if (!usedPresence) { try { if (this._config?.profiles_enabled) { this._ensureProfilesStruct(row); const ap = row.activeProfile; if (ap && row.profiles && Array.isArray(row.profiles[ap]?.blocks) && (row.profiles[ap].blocks||[]).length) { blocks = row.profiles[ap].blocks || []; } else if (this._config?.weekdays_enabled && row.weekly) { const dayKey = this._todayKey(); blocks = this._getBlocksForDay(row, dayKey) || []; } } else if (this._config?.weekdays_enabled && row.weekly) { const dayKey = this._todayKey(); blocks = this._getBlocksForDay(row, dayKey) || []; } } catch {} } for (const b of blocks) { times.push(b.startMin, b.endMin); } for (const t of times){ let delta = (t - nowMin + 1440) % 1440; if (delta === 0) delta = 1; if (delta < bestDelta){ bestDelta = delta; bestT = t; } } } // Always consider midnight rollover (00:00) { let deltaMid = (1440 - nowMin) % 1440; const tMid = 0; if (deltaMid === 0) deltaMid = 1; if (deltaMid < bestDelta) { bestDelta = deltaMid; bestT = tMid; } } // Use delta minutes (not the raw boundary minute) so a boundary that // equals nowMin (e.g. 08:00) never schedules a rapid 500ms loop. let delayMs; if (!Number.isFinite(bestDelta) || bestDelta === Infinity) { delayMs = Math.max(60000, ((1440 - nowMin) % 1440) * 60000); } else { delayMs = Math.max(60000, bestDelta * 60000); } return new Date(Date.now() + delayMs); } // Browser/local timezone mode (existing behavior) const now = new Date(); const nowMin = this._getNowMin(); let bestT = null; let bestDelta = Infinity; for (const eid of (this._config.entities || [])){ const row = this._schedules[eid]; if (!row) continue; const times = []; // Prefer presence schedule when enabled and active; then profiles/weekdays let blocks = row.blocks||[]; let usedPresence=false; try { const key = this._activePresenceComboKey && this._activePresenceComboKey(); if (key && row.presence && Array.isArray(row.presence[key]?.blocks) && row.presence[key].blocks.length) { blocks = row.presence[key].blocks || []; usedPresence=true; } } catch {} if (!usedPresence) { try { if (this._config?.profiles_enabled) { this._ensureProfilesStruct(row); const ap = row.activeProfile; if (ap && row.profiles && Array.isArray(row.profiles[ap]?.blocks) && (row.profiles[ap].blocks||[]).length) { blocks = row.profiles[ap].blocks || []; } else if (this._config?.weekdays_enabled && row.weekly) { const dayKey = this._todayKey(); blocks = this._getBlocksForDay(row, dayKey) || []; } } else if (this._config?.weekdays_enabled && row.weekly) { const dayKey = this._todayKey(); blocks = this._getBlocksForDay(row, dayKey) || []; } } catch {} } for (const b of blocks) { times.push(b.startMin, b.endMin); } for (const t of times){ let delta = (t - nowMin + 1440) % 1440; if (delta === 0) delta = 1; if (delta < bestDelta){ bestDelta = delta; bestT = t; } } } // Always consider midnight rollover (00:00) as a boundary so we re-apply // the next day's desired temperature even if today's schedule has no block // ending/starting at 00:00. This makes day change bullet-proof. { let deltaMid = (1440 - nowMin) % 1440; // minutes until midnight const tMid = 0; if (deltaMid === 0) deltaMid = 1; // avoid scheduling "now" if (deltaMid < bestDelta) { bestDelta = deltaMid; bestT = tMid; } } // Same safeguard as HA-time mode: schedule based on delta minutes. if (!Number.isFinite(bestDelta) || bestDelta === Infinity) { const ms = Math.max(60000, ((1440 - nowMin) % 1440) * 60000); return new Date(Date.now() + ms); } return new Date(Date.now() + Math.max(60000, bestDelta * 60000)); } // (removed next-boundary-per-row helper) _scheduleNextApply(){ if (this._transitionTimer) clearTimeout(this._transitionTimer); // If auto-apply is off completely, do nothing if (!this._config?.auto_apply) return; // Don't schedule until config is available if (!this._hasConfig) return; // If paused by binary sensor, don't schedule boundary timer; resume when sensor changes if (this._isPausedBySensor && this._isPausedBySensor()) { this._scheduleResumeTimer(); return; } // If paused indefinitely, don't schedule boundary timer; only resume via user action if (this._pauseIndef) { this._scheduleResumeTimer(); return; } // Choose earlier of boundary and pause-resume moment if set const boundary = this._nextBoundaryDate(); let targetMs = boundary.getTime(); const resumeMs = this._resumeAtMs(); const isResume = !!(resumeMs && resumeMs < targetMs); if (isResume) targetMs = resumeMs; const delay = Math.max(500, targetMs - Date.now()); this._transitionTimer = setTimeout(async () => { await this._applyCurrentSetpoints(true, isResume); this._scheduleNextApply(); }, delay); // Also make sure we have a standalone resume timer if paused this._scheduleResumeTimer(); } _setupBoilerControl(){ try { if (this._boilerTimer) { clearInterval(this._boilerTimer); this._boilerTimer = null; } if (!this._hass || !this._config) return; // Boiler control runs in frontend only when Sync is OFF. if (this._config?.storage_enabled) return; if (!this._config?.auto_apply) return; if (!this._config?.boiler_enabled) return; const multiOn = !!this._config?.boiler_multi_enabled; if (!multiOn) { const sw = String(this._config.boiler_switch || ''); if (!sw) return; const dom = String(sw).split('.')[0]; if (!(dom === 'switch' || dom === 'input_boolean')) return; } else { const map = (this._config.boiler_room_settings && typeof this._config.boiler_room_settings === 'object') ? this._config.boiler_room_settings : {}; const hasAny = Object.values(map||{}).some(r=>{ try { if (!r || !r.enabled) return false; const sw = String(r.switch || '').trim(); const dom = String(sw).split('.')[0]; return !!sw && (dom === 'switch' || dom === 'input_boolean'); } catch { return false; } }); if (!hasAny) return; } // Run once immediately + then every minute. try { this._boilerTick(); } catch {} this._boilerTimer = setInterval(() => { try { this._boilerTick(); } catch {} }, 60000); } catch {} } _boilerTick(){ try { if (!this._hass || !this._config) return; if (this._config?.storage_enabled) return; if (!this._config?.auto_apply) return; if (!this._config?.boiler_enabled) return; if (this._isPaused && this._isPaused()) return; const nowMin = this._getNowMin(); const allRooms = (Array.isArray(this._config.entities) ? this._config.entities : []) .map(e=>String(e||'')) .filter(e=>e.startsWith('climate.')); if (!allRooms.length) return; const multiOn = !!this._config?.boiler_multi_enabled; const clampOffset = (v)=>{ const n = Number(v); return Number.isFinite(n) ? Math.max(-10, Math.min(10, n)) : null; }; // Multi-boiler mode: group rooms by assigned boiler switch if (multiOn) { const roomCfgs = (this._config.boiler_room_settings && typeof this._config.boiler_room_settings === 'object') ? this._config.boiler_room_settings : {}; const groups = {}; for (const eid of allRooms) { const rc = roomCfgs?.[eid]; if (!rc || typeof rc !== 'object') continue; if (!rc.enabled) continue; const sw = String(rc.switch || '').trim(); if (!sw) continue; const dom = String(sw).split('.')[0]; if (!(dom === 'switch' || dom === 'input_boolean')) continue; const onOff = clampOffset(rc.on_offset); const offOff = clampOffset(rc.off_offset); const entry = { eid, onOff: (onOff === null ? Number(this._config?.boiler_on_offset ?? 0) : onOff), offOff: (offOff === null ? Number(this._config?.boiler_off_offset ?? 0) : offOff) }; if (!groups[sw]) groups[sw] = []; groups[sw].push(entry); } const switches = Object.keys(groups); if (!switches.length) return; for (const sw of switches) { const rooms = groups[sw] || []; if (!rooms.length) continue; let anyNeedHeat = false; let anyUnknown = false; let anySeen = false; let allOkForOff = true; for (const r of rooms) { const desiredC = Number(this._desiredTempFor ? this._desiredTempFor(r.eid, nowMin) : null); const curC = Number(this._roomCurrentTemperatureC ? this._roomCurrentTemperatureC(r.eid) : NaN); if (!Number.isFinite(desiredC) || !Number.isFinite(curC)) { anyUnknown = true; continue; } anySeen = true; if (curC < (desiredC - r.onOff)) { anyNeedHeat = true; break; } if (curC < (desiredC + r.offOff)) allOkForOff = false; } if (!anySeen) continue; let want = null; if (anyNeedHeat) want = 'on'; else if (!anyUnknown && allOkForOff) want = 'off'; else continue; const stSw = this._hass.states?.[sw]; const cur = String(stSw?.state || ''); if (want === 'on' && cur === 'on') continue; if (want === 'off' && cur === 'off') continue; const dom = String(sw).split('.')[0]; if (!(dom === 'switch' || dom === 'input_boolean')) continue; if (want === 'on') this._hass.callService(dom, 'turn_on', { entity_id: sw }); else this._hass.callService(dom, 'turn_off', { entity_id: sw }); } return; } // Single-boiler mode const sw = String(this._config.boiler_switch || ''); if (!sw) return; const dom0 = String(sw).split('.')[0]; if (!(dom0 === 'switch' || dom0 === 'input_boolean')) return; // New logic: compare each selected climate entity's current_temperature to // its scheduled target temperature for "now". // - If ANY room is below its scheduled target -> switch ON // - If ALL rooms are at/above their scheduled target -> switch OFF // If some rooms have missing data, we only turn OFF when we can confirm // ALL rooms are at/above target. const allSet = new Set(allRooms); const cfgRooms = this._config?.boiler_rooms; const roomEntities = Array.isArray(cfgRooms) ? cfgRooms.map(e=>String(e||'')).filter(e=>allSet.has(e) && e.startsWith('climate.')) : allRooms; if (!roomEntities.length) return; const onOff = (()=>{ const v = Number(this._config?.boiler_on_offset ?? 0); return Number.isFinite(v) ? v : 0; })(); const offOff = (()=>{ const v = Number(this._config?.boiler_off_offset ?? 0); return Number.isFinite(v) ? v : 0; })(); let anyNeedHeat = false; let anyUnknown = false; let anySeen = false; let allOkForOff = true; for (const eid of roomEntities) { const desiredC = Number(this._desiredTempFor ? this._desiredTempFor(eid, nowMin) : null); const curC = Number(this._roomCurrentTemperatureC ? this._roomCurrentTemperatureC(eid) : NaN); if (!Number.isFinite(desiredC) || !Number.isFinite(curC)) { anyUnknown = true; continue; } anySeen = true; if (curC < (desiredC - onOff)) { anyNeedHeat = true; break; } if (curC < (desiredC + offOff)) allOkForOff = false; } if (!anySeen) return; let want = null; if (anyNeedHeat) want = 'on'; else if (!anyUnknown && allOkForOff) want = 'off'; else return; const stSw = this._hass.states?.[sw]; const cur = String(stSw?.state || ''); if (want === 'on' && cur === 'on') return; if (want === 'off' && cur === 'off') return; const dom = String(sw).split('.')[0]; if (!(dom === 'switch' || dom === 'input_boolean')) return; if (want === 'on') this._hass.callService(dom, 'turn_on', { entity_id: sw }); else this._hass.callService(dom, 'turn_off', { entity_id: sw }); } catch {} } async _applyCurrentSetpoints(force=false, reconcile=false){ if (!this._config?.auto_apply || !this._hass) return; if (!this._hasConfig) return; if (this._config?.storage_enabled) return; if (this._isPaused()) return; const nowMin = this._getNowMin(); const targets = this._allTargetEntities(); for (const eid of targets){ // If this call is from boundary timer (force===true), only apply for entities that have boundary now. // On resume from pause (reconcile=true), reconcile across all entities. if (force && !reconcile) { const primary = this._groupPrimaryOf(eid); const row = this._schedules[primary]; if (!row || !this._entityHasBoundaryNow(row, nowMin)) { continue; } } let desired = this._desiredTempFor(eid, nowMin); const mx = this._config?.max_temp ?? 25; const mn = this._config?.min_temp ?? 5; if (Number.isFinite(mx)) desired = Math.min(desired, mx); if (Number.isFinite(mn)) desired = Math.max(desired, mn); if (desired == null || Number.isNaN(desired)) continue; const last = this._lastApplied[eid] || {}; // Only send when desired setpoint changes for this entity. // Prevents overriding manual changes in other rooms. if (!reconcile && Number.isFinite(last.temp) && Math.abs(Number(last.temp) - desired) < 0.05) continue; const st = this._hass.states?.[eid]; const cur = Number(st?.attributes?.temperature ?? st?.attributes?.target_temperature ?? st?.attributes?.target_temp); const willSendF = this._isF(); const haIsF = this._haIsF(); let curU = cur; if (Number.isFinite(curU)) { if (willSendF && !haIsF) curU = this._cToF(curU); else if (!willSendF && haIsF) curU = this._fToC(curU); } const sendTemp = this._serviceTempFromC(desired); if (Number.isFinite(curU) && Math.abs(curU - sendTemp) < 0.05){ this._lastApplied[eid] = { min: nowMin, temp: desired }; continue; } try { await this._applySetpointForEntity(eid, desired); this._lastApplied[eid] = { min: nowMin, temp: desired }; } catch (e) {} } } // ---------- "Apply on change" helper ---------- _desiredNowSnapshot(){ const nowMin = this._getNowMin(); const snap = {}; for (const eid of this._allTargetEntities()) snap[eid] = this._desiredTempFor(eid, nowMin); return snap; } async _applyIfDesiredChanged(beforeSnap){ if (!this._config?.auto_apply) return; if (this._isPaused()) return; const after = this._desiredNowSnapshot(); const nowMin = this._getNowMin(); if (this._config?.storage_enabled) { return; } for (const eid of Object.keys(after)){ let a = after[eid]; const b = beforeSnap[eid]; if (a == null) continue; const mx=this._config?.max_temp ?? 25; const mn=this._config?.min_temp ?? 5; if (Number.isFinite(mx)) a = Math.min(a, mx); if (Number.isFinite(mn)) a = Math.max(a, mn); if (b == null || Math.abs(a - b) > 0.049){ try { await this._applySetpointForEntity(eid, a); this._lastApplied[eid] = { min: nowMin, temp: a }; } catch(e){} } } } // Ping backend to apply now without waiting for delayed sync async _nudgeBackgroundApplyNow(){ try { if (!this._hass || !this._config?.storage_enabled) return; const _awayCfg = this._config?.away || {}; const _awayOut = { ..._awayCfg }; const payload = this._makeStoragePayload(false); // Always include away settings to avoid losing away.enabled due touch-flag races. // set_store replaces the full settings object, so merge with current backend // settings first to avoid dropping unrelated keys (e.g. holidays_*). let mergedSettings = { ...payload.settings, away: _awayOut }; try { const state = await this._apiFetchState(); const base = (state && typeof state === 'object' && state.settings && typeof state.settings === 'object') ? state.settings : {}; mergedSettings = { ...base, ...mergedSettings }; } catch {} payload.settings = mergedSettings; const p = { settings: payload.settings, force: true }; if (this._config?.instance_enabled) { p.instance_id = this._instanceId(); p.activate = true; } await this._hass.callService('thermostat_timeline','set_store', p); } catch {} } // Apply a setpoint immediately for the given entity (and its merged entities) // using the provided temperature (in °C). Honours away override and min/max. async _applyImmediateForEntity(entity, tempC, nowMinOverride=null){ try { if (!this._config?.auto_apply || !this._config?.apply_on_edit || !this._hass) return; // When shared storage is enabled, integration handles the apply; card should not send if (this._config?.storage_enabled) return; if (this._isPaused()) return; let desired = Number(tempC); // Away override (disabled when advanced presence is enabled) try { const advOn = !!(this._config?.away?.advanced_enabled); if (!advOn && this._isAwayActive()) { const a = Number(this._config?.away?.target_c ?? 17); if (Number.isFinite(a)) desired = Math.min(desired, a); } } catch {} // Clamp to min/max const mx = this._config?.max_temp ?? 25; const mn = this._config?.min_temp ?? 5; if (Number.isFinite(mx)) desired = Math.min(desired, mx); if (Number.isFinite(mn)) desired = Math.max(desired, mn); const nowMin = Number.isFinite(nowMinOverride) ? nowMinOverride : this._getNowMin(); const dom = String(entity||'').split('.')[0] || ''; const targets = (dom === 'climate') ? [entity, ...(this._config?.merges?.[entity] || [])] : [entity]; for (const eid of targets){ try { const __eid = eid; if (!(typeof __eid==='string' && __eid.includes('.') && this._hass?.states?.[__eid])) continue; const dom2 = __eid.split('.')[0]; // Avoid redundant command if already at desired let curU = NaN; try { const st = this._hass.states[__eid]; if (dom2 === 'climate') { const cur = Number(st?.attributes?.temperature ?? st?.attributes?.target_temperature ?? st?.attributes?.target_temp); const willSendF = this._isF(); const haIsF = this._haIsF(); curU = cur; if (Number.isFinite(curU)) { if (willSendF && !haIsF) curU = this._cToF(curU); else if (!willSendF && haIsF) curU = this._fToC(curU); } } else if (dom2 === 'input_number') { const raw = Number(st?.state); curU = raw; } } catch {} const sendTemp = this._serviceTempFromC(desired); if (Number.isFinite(curU) && Math.abs(curU - sendTemp) < 0.049) { this._lastApplied[__eid] = { min: nowMin, temp: desired }; continue; } await this._applySetpointForEntity(__eid, desired); this._lastApplied[__eid] = { min: nowMin, temp: desired }; } catch {} } } catch {} } _entityHasBoundaryNow(row, nowMin){ try { let blocks = row.blocks||[]; // Prefer presence schedule when enabled and active try { const key = this._activePresenceComboKey && this._activePresenceComboKey(); if (key && row.presence && Array.isArray(row.presence[key]?.blocks) && row.presence[key].blocks.length) { blocks = row.presence[key].blocks || []; } else if (this._config?.weekdays_enabled && row.weekly) { const dayKey = this._todayKey(); blocks = this._getBlocksForDay(row, dayKey) || []; } } catch { if (this._config?.weekdays_enabled && row.weekly){ const dayKey = this._todayKey(); blocks = this._getBlocksForDay(row, dayKey) || []; } } // Allow a 1-minute grace window because we intentionally schedule the // timer a tiny bit after the exact boundary to avoid races. Otherwise we // might miss the equality check and skip applying at the boundary. const prev = ((nowMin - 1) % 1440 + 1440) % 1440; for (const b of (blocks||[])){ const s = Number(b.startMin), e = Number(b.endMin); if (Number.isFinite(s) && (s === nowMin || s === prev)) return true; if (Number.isFinite(e) && (e === nowMin || e === prev)) return true; } } catch {} return false; } // ---------- UI ---------- _init() { this.shadowRoot.innerHTML = `
`; this._applyCardI18n(); // Apply hide-popups class if requested try { const card = this.shadowRoot.querySelector('ha-card'); if (this._disablePopups && card) card.classList.add('hide-popups'); } catch {} this._ensureSchedules(); this._startUiTimer(); // Show onboarding for first-time users (disabled while popups are turned off) try { if (!this._disablePopups) this._maybeOpenOnboard(); } catch {} // Wire pause overlay actions try { const ov = this.shadowRoot.querySelector('.overlay-pause'); const b1 = this.shadowRoot.querySelector('.pause-1h'); const b5 = this.shadowRoot.querySelector('.pause-5h'); const bc = this.shadowRoot.querySelector('.pause-custom'); const bi = this.shadowRoot.querySelector('.pause-indef'); const br = this.shadowRoot.querySelector('.pause-resume'); const bx = this.shadowRoot.querySelector('.pause-close'); b1?.addEventListener('click', ()=>{ this._setPauseForMinutes(60); }); b5?.addEventListener('click', ()=>{ this._setPauseForMinutes(300); }); bc?.addEventListener('click', ()=>{ try { const txt = prompt(this._t('pause.prompt_minutes') || 'Enter duration (minutes):', '60'); const m = Number(txt); if (Number.isFinite(m) && m > 0) this._setPauseForMinutes(m); } catch {} }); bi?.addEventListener('click', ()=>{ this._setPauseIndef(); }); br?.addEventListener('click', ()=>{ this._resumeNow(); }); bx?.addEventListener('click', ()=>{ try { ov?.classList.remove('open'); } catch {} }); } catch {} } _renderPauseStatus(){ try { const st = this.shadowRoot && this.shadowRoot.querySelector('.pause-status'); if (!st) return; if (!this._isPaused()) { st.textContent = ''; return; } if (this._pauseIndef) { st.textContent = this._t('pause.active_indef'); return; } const until = new Date(Number(this._pauseUntilMs||0)); // Format time according to card preference const hh = until.getHours(), mm = until.getMinutes(); let label; if (this._config?.time_12h) { const am = hh < 12; let h = hh % 12; if (h===0) h=12; label = `${String(h).padStart(2,'0')}:${String(mm).padStart(2,'0')} ${am?'AM':'PM'}`; } else { label = `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`; } const msgTmpl = this._t('pause.active_until'); st.textContent = (msgTmpl || 'Paused until {time}').replace('{time}', label); } catch {} } // --- Sync countdown helpers (delayed sync UI) --- _formatSyncCountdown(){ try { const leftMs = Math.max(0, Number(this._storeDelayDue||0) - Date.now()); const s = Math.ceil(leftMs/1000); const mm = Math.floor(s/60), ss = s%60; return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`; } catch { return '00:00'; } } _startSyncTimer(){ if (this._syncTimer) return; try { this._syncTimer = setInterval(()=>{ try { if (!this._inlineEditing && !this._editing) this._render(); } catch {} }, 1000); } catch {} } _stopSyncTimer(){ try { if (this._syncTimer) clearInterval(this._syncTimer); } catch {} this._syncTimer = null; } // Persist/restore delayed sync due time across hard refresh _syncDueKey(){ return `tt_sync_due:${this._instanceId()}`; } _draftKey(){ return `tt_sync_draft:${this._instanceId()}`; } _localStoreKeyFor(iid){ const id = this._normInstanceId(iid); if (!id) return 'thermostat_timeline_store'; return `thermostat_timeline_store:${id}`; } _syncDueKeyFor(iid){ return `tt_sync_due:${this._normInstanceId(iid)}`; } _draftKeyFor(iid){ return `tt_sync_draft:${this._normInstanceId(iid)}`; } _copyInstanceLocalKeys(oldId, newId){ try { const oldI = this._normInstanceId(oldId); const newI = this._normInstanceId(newId); if (!oldI || !newI || oldI === newI) return false; const oldStoreKey = this._localStoreKeyFor(oldI); const newStoreKey = this._localStoreKeyFor(newI); const oldStore = localStorage.getItem(oldStoreKey) || ''; const newStore = localStorage.getItem(newStoreKey) || ''; if (oldStore && newStore && newStore.length > 2 && oldStore !== newStore) { const ok = confirm('Det nye ID har allerede data. Overskriv?'); if (!ok) return false; } if (oldStore) { try { localStorage.setItem(newStoreKey, oldStore); } catch {} } // Move pending sync/draft keys (best-effort) try { const okDue = this._syncDueKeyFor(oldI); const nkDue = this._syncDueKeyFor(newI); const due = localStorage.getItem(okDue); if (due != null) { try { localStorage.setItem(nkDue, String(due)); } catch {} } } catch {} try { const okDr = this._draftKeyFor(oldI); const nkDr = this._draftKeyFor(newI); const dr = localStorage.getItem(okDr); if (dr != null) { try { localStorage.setItem(nkDr, String(dr)); } catch {} } } catch {} return true; } catch { return false; } } _moveInstanceLocalKeys(oldId, newId){ try { const oldI = this._normInstanceId(oldId); const newI = this._normInstanceId(newId); if (!oldI || !newI || oldI === newI) return false; const ok = this._copyInstanceLocalKeys(oldI, newI); if (!ok) return false; try { localStorage.removeItem(this._localStoreKeyFor(oldI)); } catch {} try { localStorage.removeItem(this._syncDueKeyFor(oldI)); } catch {} try { localStorage.removeItem(this._draftKeyFor(oldI)); } catch {} return true; } catch { return false; } } _clearAllLocalScheduleData(){ // Wipes ALL local/browser keys related to thermostat timeline across all instances. try { const toDel = new Set(); try { toDel.add('thermostat_timeline_store'); } catch {} try { toDel.add('thermostat_timeline_onboard_seen_v1'); } catch {} try { toDel.add('tt_defer_holiday_open'); } catch {} const prefixes = [ 'thermostat_timeline_store:', 'tt_sync_due:', 'tt_sync_draft:', 'tt_instance_cfg:', ]; // Broad safety net: these prefixes are reserved for this card/integration. const broadPrefixes = [ 'thermostat_timeline', 'tt_', ]; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (!k) continue; if (k === 'thermostat_timeline_store') { toDel.add(k); continue; } for (const p of prefixes) { if (k.startsWith(p)) { toDel.add(k); break; } } // Also catch any remaining thermostat_timeline/tt_* keys (UI flags etc.) for (const p of broadPrefixes) { if (k.startsWith(p)) { toDel.add(k); break; } } } for (const k of toDel) { try { localStorage.removeItem(k); } catch {} } } catch {} } async _clearAllDataEverywhereConfirmed(){ // Full wipe: local/browser + backend store (if available) + in-memory state. // Confirmation must be handled by caller. try { // Stop any pending delayed-sync timers so cleared data can't be written back. try { if (this._storeDelayTimer) clearTimeout(this._storeDelayTimer); } catch {} this._storeDelayTimer = null; try { this._storeDelayDue = 0; } catch {} try { this._stopSyncTimer(); } catch {} // Wipe local browser copy (ALL instances + pending sync/draft) try { this._clearAllLocalScheduleData(); } catch {} // Mark a hard reset so subsequent loads won't re-import legacy/local drafts. try { localStorage.setItem(this._hardResetKey(), String(Date.now())); } catch {} // Also clear room list and related config (rooms are configuration, not schedules) try { this._config.entities = []; this._config.room_use_input_number = []; this._config.room_use_temp_sensor = []; this._config.merges = {}; this._config.labels = {}; this._config.temp_sensors = {}; // Reset instance config so the card starts in non-instance mode. this._config.instance_enabled = false; this._config.instance_id = ''; this._config.instance_uid = ''; // Reset title this._config.title = ''; } catch {} // Also wipe in-memory schedules so UI reflects deletion immediately try { this._schedules = {}; this._config.color_ranges = {}; this._profilesSelected = null; this._profilesDraft = null; this._profilesDirty = false; this._profilesEditingNew = false; try { this._openRows = new Set(); } catch {} } catch {} if (this._hass) { // Hard reset (deletes .storage files and recreates them empty) let ok = false; try { await this._hass.callService('thermostat_timeline', 'factory_reset', {}); ok = true; } catch {} // Backwards compatible fallback if (!ok) { try { await this._hass.callService('thermostat_timeline', 'clear_store', {}); ok = true; } catch {} if (!ok) { try { await this._hass.callService('thermostat_timeline', 'clear', {}); } catch {} } try { await this._hass.callService('thermostat_timeline', 'delete_backup', {}); } catch {} } if (!ok) { try { alert('Kunne ikke rydde data i Home Assistant (service-kald fejlede). Tjek Home Assistant log.'); } catch {} } } // Repaint editor/live cards try { this._emit(true); } catch {} try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} // If settings popup is open, refresh its draft so it doesn't show stale values try { this._initSettingsPopupDraft(); } catch {} try { this._renderSettingsPopupTabs(); } catch {} try { this._renderSettingsPopupTab(); } catch {} return true; } catch { return false; } } _clearAllLocalOnlyConfirmed(){ // Wipes local/browser thermostat timeline data only. try { try { this._clearAllLocalScheduleData(); } catch {} try { localStorage.setItem(this._hardResetKey(), String(Date.now())); } catch {} try { this._schedules = {}; this._ensureSchedules(); } catch {} try { this._emit(true); } catch {} try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} try { this._initSettingsPopupDraft(); } catch {} try { this._renderSettingsPopupTab(); } catch {} return true; } catch { return false; } } _persistDraftNow(){ try { this._syncSeasonalRowsToBucket(); } catch {} try { localStorage.setItem(this._draftKey(), JSON.stringify({ ts: Date.now(), schedules: this._schedules })); } catch {} } _tryRestoreDraftRecent(maxAgeMs=15*60*1000){ try { const raw = localStorage.getItem(this._draftKey()) || ''; if (!raw) return false; const parsed = JSON.parse(raw); const ts = Number(parsed?.ts); if (!Number.isFinite(ts) || (Date.now()-ts) > maxAgeMs) return false; const sch = parsed?.schedules && parsed.schedules.schedules ? parsed.schedules.schedules : (parsed?.schedules || parsed || {}); if (sch && typeof sch === 'object') { this._schedules = sch; try { this._seasonAppliedKey = null; } catch {} this._ensureSchedules(); return true; } } catch {} return false; } _restoreSyncDue(){ try { if (!this._config?.storage_enabled || this._config?.storage_sync_mode !== 'delay') return; // Prefer value from storage (already read in _loadStore); fallback to localStorage if (Number.isFinite(this._storeDelayDue) && this._storeDelayDue > Date.now()) { // Restore any recent local draft so UI shows latest edits try { this._tryRestoreDraftRecent(); } catch {} const remaining = Math.max(500, this._storeDelayDue - Date.now()); if (this._storeDelayTimer) { try { clearTimeout(this._storeDelayTimer); } catch {} } this._storeDelayTimer = setTimeout(()=>{ this._storeDelayTimer = null; this._writeStoreNow(); }, remaining); this._startSyncTimer(); return; } const key = this._syncDueKey(); const raw = localStorage.getItem(key) || ''; const due = Number(raw); if (!Number.isFinite(due)) return; if (due <= Date.now()) { try { localStorage.removeItem(key); } catch {} return; } // Restore recent draft too try { this._tryRestoreDraftRecent(); } catch {} this._storeDelayDue = due; const remaining = Math.max(500, due - Date.now()); if (this._storeDelayTimer) { try { clearTimeout(this._storeDelayTimer); } catch {} } this._storeDelayTimer = setTimeout(()=>{ this._storeDelayTimer = null; this._writeStoreNow(); }, remaining); this._startSyncTimer(); } catch {} } async _writeStoreNow(){ if (!this._config?.storage_enabled) return; this._saving = true; const key = this._syncDueKey(); try { // Build away for backend using current persisted config values. const _awayCfg = this._config?.away || {}; const _awayOut = { ..._awayCfg }; let payload = this._makeStoragePayload(true); payload.settings = { ...payload.settings, away: _awayOut }; // Merge with current backend settings to avoid clearing omitted keys (e.g., labels) on delayed write try { const state = await this._apiFetchState(); const base = (state && typeof state === 'object' && state.settings && typeof state.settings === 'object') ? state.settings : {}; const incoming = payload.settings || {}; const merged = { ...base, ...incoming }; const keepIfEmpty = (key)=>{ try { const inc = incoming[key]; const hasInc = inc && typeof inc === 'object' && Object.keys(inc).length > 0; const baseVal = base[key]; const hasBase = baseVal && typeof baseVal === 'object' && Object.keys(baseVal).length > 0; if (!hasInc && hasBase) merged[key] = baseVal; } catch {} }; // Respect explicit label edits: allow an empty object to clear labels when touched if (!this._labelsTouched) keepIfEmpty('labels'); keepIfEmpty('merges'); keepIfEmpty('temp_sensors'); payload = { ...payload, settings: merged }; } catch {} // Push all data to the integration via set_store service (writes to file) if (this._config?.instance_enabled) { payload = { ...payload, instance_id: this._instanceId(), activate: true }; } await this._hass.callService("thermostat_timeline", "set_store", payload); try { this._broadcastStoreUpdated(this._localStoreKey()); } catch {} } catch (e) {} finally { setTimeout(() => { this._saving = false; this._syncJustUntil = Date.now() + 2500; this._storeDelayDue = 0; try { localStorage.removeItem(key); } catch {} this._startSyncTimer(); this._render(); try { this._labelsTouched = false; } catch {} }, 600); } } _setPauseForMinutes(mins){ try { const ms = Math.max(1, Math.round(Number(mins)||0)) * 60000; this._pauseIndef = false; this._pauseUntilMs = Date.now() + ms; // Persist to storage/local this._debouncedSaveStore(); this._render(); this._renderPauseStatus(); // Reschedule apply/resume this._scheduleNextApply(); // Notify backend immediately when shared storage is on try { if (this._config?.storage_enabled) this._saveStore(); } catch {} // Close overlay try { this.shadowRoot.querySelector('.overlay-pause')?.classList.remove('open'); } catch {} } catch {} } _setPauseIndef(){ try { this._pauseIndef = true; this._pauseUntilMs = 0; this._debouncedSaveStore(); this._render(); this._renderPauseStatus(); this._scheduleNextApply(); try { if (this._config?.storage_enabled) this._saveStore(); } catch {} try { this.shadowRoot.querySelector('.overlay-pause')?.classList.remove('open'); } catch {} } catch {} } _resumeNow(){ try { this._pauseIndef = false; this._pauseUntilMs = 0; this._debouncedSaveStore(); this._render(); this._scheduleNextApply(); // Apply immediately to match plan "now" this._applyCurrentSetpoints(true, true); try { if (this._config?.storage_enabled) this._saveStore(); } catch {} try { this.shadowRoot.querySelector('.overlay-pause')?.classList.remove('open'); } catch {} } catch {} } // If the editor asked to open the Holiday modal after the user pressed Save, // read the flag from localStorage and open once, then clear the flag. _maybeOpenDeferredHoliday(){ try { const flag = localStorage.getItem('tt_defer_holiday_open'); if (flag === '1') { localStorage.removeItem('tt_defer_holiday_open'); const first = (this._config?.entities||[])[0] || null; this._openHolidayEditor(first); } } catch {} } _ensureSchedules() { for (const eid of this._config.entities) { if (!this._schedules[eid]) this._schedules[eid] = { defaultTemp: this._config.default_temp, blocks: [] }; // Always maintain structures so state is preserved if toggles are flipped later this._ensureProfilesStruct(this._schedules[eid]); this._ensureHolidayStruct(this._schedules[eid]); this._ensurePresenceStruct(this._schedules[eid]); if (this._config?.seasonal_enabled) this._ensureSeasonStruct(this._schedules[eid]); } // Apply seasonal mode and ensure weekly structure for the active season try { this._applySeasonalModeToRows(); } catch {} if (this._config?.weekdays_enabled) { for (const eid of this._config.entities) { this._ensureWeeklyStruct(this._schedules[eid]); } } } _startUiTimer() { if (this._uiTimer) clearInterval(this._uiTimer); this._uiTimer = setInterval(() => { if (!this._inlineEditing && !this._editing) this._render(); }, this._config.now_update_ms); } _startStoreWatchTimer(){ try { if (this._storeWatchTimer) clearInterval(this._storeWatchTimer); } catch {} if (!this._config?.storage_enabled) { return; } const ms = Math.max(800, Number(this._storeWatchMs||0) || 10000); this._storeWatchTimer = setInterval(()=>{ if (this._storeWatchBusy) return; this._storeWatchBusy = true; (async () => { try { let verSched = NaN, verSet = NaN, verCol = NaN, verWeek = NaN, verProf = NaN; let haveVersions = false; // Always use API for version checking - file-based storage only if (this._apiSupported()) { const ver = await this._apiFetchVersion(); if (ver && typeof ver === 'object') { verSched = Number(ver.version || 0); verSet = Number(ver.settings_version || verSched || 0); verCol = Number(ver.colors_version || verSet || verSched || 0); verWeek = Number(ver.weekday_version || verCol || verSet || verSched || 0); verProf = Number(ver.profile_version || verWeek || verCol || verSet || verSched || 0); // Check for global profile changes from API data const s = ver.settings || {}; const newGP = (typeof s.global_profile === 'string' && s.global_profile) ? String(s.global_profile) : null; const newPE = typeof s.profiles_enabled === 'boolean' ? !!s.profiles_enabled : this._config?.profiles_enabled; let changed = false; if (newGP !== this._globalProfile) { this._globalProfile = newGP; changed = true; } if (newPE !== this._config.profiles_enabled) { this._config.profiles_enabled = newPE; changed = true; } if (changed) { try { for (const id of (this._config?.entities||[])){ const r = this._schedules[id]; if (!r) continue; this._ensureProfilesStruct(r); if (!this._globalProfile) { r.activeProfile = null; continue; } r.activeProfile = (r.profiles && r.profiles[this._globalProfile]) ? this._globalProfile : null; } } catch {} if (!this._inlineEditing && !this._editing) this._render(); this._scheduleNextApply(); } haveVersions = true; } } if (!haveVersions) return; const schedChanged = this._config?.storage_enabled && !this._saving && !Number.isNaN(verSched) && verSched !== this._lastVersion; const settingsChanged = this._config?.storage_enabled && !this._saving && !Number.isNaN(verSet) && verSet !== this._lastSettingsVersion; const colorsChanged = this._config?.storage_enabled && !this._saving && !Number.isNaN(verCol) && verCol !== this._lastColorsVersion; const weekdaysChanged = this._config?.storage_enabled && !this._saving && !Number.isNaN(verWeek) && verWeek !== this._lastWeekdayVersion; const profilesChanged = this._config?.storage_enabled && !this._saving && !Number.isNaN(verProf) && verProf !== this._lastProfileVersion; if (schedChanged || settingsChanged || colorsChanged || weekdaysChanged || profilesChanged){ if (this._isSettingsPopupOpen && this._isSettingsPopupOpen()) return; this._lastVersion = verSched; this._lastSettingsVersion = verSet; this._lastColorsVersion = verCol; this._lastWeekdayVersion = verWeek; this._lastProfileVersion = verProf; this._loadStore(false).then(()=>{ this._ensureSchedules(); if (!this._inlineEditing && !this._editing) this._render(); this._scheduleNextApply(); }); } } catch {} finally { this._storeWatchBusy = false; } })(); }, ms); } _render() { // Do not re-render while the user is actively typing in inline inputs (to avoid caret/selection loss) if (this._suspendRender) return; const qs = (s) => this.shadowRoot.querySelector(s); const titleEl = qs('.title'); const dayFull = qs('.weekday-full'); const rowsEl = qs('.rows'); const scaleEl = qs('.scale-inner'); const headerEl = qs('.header'); if (!rowsEl || !scaleEl || !titleEl) return; try { this._applySeasonalModeToRows(); } catch {} // Helper: build "All rooms" / "All days" view switch const makeWeekViewSeg = (extraClass='')=>{ try { const isWeekEnabled = !!this._config?.weekdays_enabled; const allowInlineViewSwitch = !!(this._config?.weekdays_view_switch_in_timeline); const isHoliday = !!(this._isHolidayActive && this._isHolidayActive()); if (!isWeekEnabled || !allowInlineViewSwitch || isHoliday) return null; const fullRooms = this._t('week.view.rooms_one_day') || 'All rooms • one day'; const fullDays = this._t('week.view.days_one_room') || 'All days • one room'; const short = (txt)=>{ try { const s = String(txt || '').split('•')[0].trim(); return s || String(txt || ''); } catch { return String(txt || ''); } }; const setView = (mode)=>{ try { if (!this._config?.weekdays_enabled) return; const next = (mode === 'one_room_all_days') ? 'one_room_all_days' : 'all_rooms_one_day'; const cur = String(this._config?.weekdays_view || 'all_rooms_one_day'); if (cur === next) return; this._config.weekdays_view = next; // Keep selected room consistent try { const ents = Array.isArray(this._config?.entities) ? this._config.entities.filter(Boolean).map(String) : []; if (next === 'one_room_all_days') { const want = String(this._config?.weekdays_selected_room || ''); this._config.weekdays_selected_room = (want && ents.includes(want)) ? want : (ents[0] || ''); } else { this._config.weekdays_selected_room = ''; try { this._setTimelineDisplayDayKey(this._todayKey()); } catch {} } } catch {} this._render(); try { this._debouncedSaveStore(); } catch {} } catch {} }; const seg = document.createElement('div'); seg.className = 'sp-week-view-inline' + (extraClass ? (' ' + extraClass) : ''); seg.setAttribute('role','group'); seg.setAttribute('aria-label', this._t('week.title') || 'Weekdays'); const bRooms = document.createElement('button'); bRooms.type = 'button'; bRooms.textContent = short(fullRooms); bRooms.title = String(fullRooms || ''); bRooms.classList.toggle('active', String(this._config?.weekdays_view || 'all_rooms_one_day') === 'all_rooms_one_day'); bRooms.addEventListener('click', ()=>setView('all_rooms_one_day')); const bDays = document.createElement('button'); bDays.type = 'button'; bDays.textContent = short(fullDays); bDays.title = String(fullDays || ''); bDays.classList.toggle('active', String(this._config?.weekdays_view || 'all_rooms_one_day') === 'one_room_all_days'); bDays.addEventListener('click', ()=>setView('one_room_all_days')); seg.append(bRooms, bDays); return seg; } catch { return null; } }; // Determine first active profile (if any) for header pill let _activeProfileName = null; try { if (this._config?.profiles_enabled) { for (const eid of (this._config?.entities || [])) { const row = this._schedules?.[eid]; if (!row) continue; this._ensureProfilesStruct(row); const ap = row.activeProfile || null; if (ap) { _activeProfileName = String(ap); break; } } } } catch {} { const isDef = (txt)=>{ try { const s = String(txt || '').trim(); for (const k of Object.keys(TT_I18N || {})){ const v = TT_I18N[k] && TT_I18N[k]['card.title_default']; if (v && String(v).trim() === s) return true; } } catch {} return false; }; const shown = (!this._config.title || isDef(this._config.title)) ? this._t('card.title_default') : this._config.title; titleEl.innerHTML = ''; const tspan = document.createElement('span'); tspan.className = 'title-text'; tspan.textContent = shown; titleEl.append(tspan); // Sync countdown/dots (left side, next to title) try { // Render only when shared storage enabled const useDelay = !!(this._config?.storage_enabled) && (this._config?.storage_sync_mode === 'delay'); const due = Number(this._storeDelayDue||0); const justDone = Number(this._syncJustUntil||0) > Date.now(); const running = !!this._saving; if (useDelay && (running || justDone || (due > Date.now()))) { const pill = document.createElement('span'); pill.className = 'profile-pill sync-pill'; const icon = document.createElement('ha-icon'); const txt = document.createElement('span'); txt.style.marginLeft = '6px'; if (running) { icon.setAttribute('icon','mdi:sync'); pill.classList.add('primary'); txt.textContent = this._t('sync.running'); } else if (justDone) { icon.setAttribute('icon','mdi:check-circle-outline'); pill.classList.add('success'); txt.textContent = this._t('sync.completed'); } else { icon.setAttribute('icon','mdi:timer-outline'); pill.classList.add('ghost'); txt.textContent = this._formatSyncCountdown(); this._startSyncTimer(); } pill.append(icon, txt); titleEl.append(pill); if (!running && !justDone && !(due> Date.now())) this._stopSyncTimer(); } else { this._stopSyncTimer(); } } catch {} if (_activeProfileName) { const p = document.createElement('span'); p.className = 'profile-pill success'; const prefix = this._t('profiles.active_label') || 'Manual profile active:'; p.textContent = `${prefix} ${_activeProfileName}`; titleEl.append(p); } } // (Profile pill moved next to title; weekday center shows only day name now) // Center header: show current weekday name when weekday mode is enabled try { if (dayFull) { let label = ''; // Prefer a Holiday label if holidays are active try { if (this._isHolidayActive && this._isHolidayActive()) { label = this._t('holidays.button') || 'Holidays'; } } catch {} if (!label && this._config?.weekdays_enabled) { // Localized full weekday, e.g., "Mandag" (or browsed day in weekdays view) const viewDayKey = this._timelineDisplayDayKey(); const dayName = this._weekdayFullName(viewDayKey) || this._todayFullName(); // If viewing all days for a single room, append room name for clarity try { const perRoomAllDays = String(this._config?.weekdays_view||'all_rooms_one_day') === 'one_room_all_days'; if (perRoomAllDays) { const ents = (this._config?.entities||[]); const roomEid = (this._config?.weekdays_selected_room && ents.includes(this._config.weekdays_selected_room)) ? this._config.weekdays_selected_room : (ents[0]||''); const roomLabel = this._config?.labels?.[roomEid] || this._prettyName(roomEid); label = `${roomLabel}`.trim(); } else { label = dayName; } } catch { label = dayName; } } if (label) { dayFull.innerHTML = ''; const ents = (this._config?.entities || []); const isWeekEnabled = !!this._config?.weekdays_enabled; const viewMode = String(this._config?.weekdays_view || 'all_rooms_one_day'); const mkArrow = (dir)=>{ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'wd-arrow'; btn.textContent = dir < 0 ? '◀' : '▶'; btn.style.cssText = 'background:none; border:none; color:var(--primary-text-color); cursor:pointer; padding:0 4px; font-size:inherit;'; return btn; }; let leftNavBtn = null; let rightNavBtn = null; // In "Alle rum • én dag" view: arrows browse days (Mon..Sun) if (isWeekEnabled && viewMode === 'all_rooms_one_day') { const prevBtn = mkArrow(-1); prevBtn.title = 'Previous day'; prevBtn.addEventListener('click', () => { const names = this._weekdayNames(); const cur = this._timelineDisplayDayKey(); const idx = Math.max(0, names.indexOf(cur)); const newIdx = (idx - 1 + names.length) % names.length; this._setTimelineDisplayDayKey(names[newIdx]); this._render(); }); leftNavBtn = prevBtn; const nextBtn = mkArrow(1); nextBtn.title = 'Next day'; nextBtn.addEventListener('click', () => { const names = this._weekdayNames(); const cur = this._timelineDisplayDayKey(); const idx = Math.max(0, names.indexOf(cur)); const newIdx = (idx + 1) % names.length; this._setTimelineDisplayDayKey(names[newIdx]); this._render(); }); rightNavBtn = nextBtn; } // In "Alle dage • ét rum" view: keep room navigation arrows (only if multiple rooms) else if (isWeekEnabled && viewMode === 'one_room_all_days' && ents.length > 1) { const roomEid = (this._config?.weekdays_selected_room && ents.includes(this._config.weekdays_selected_room)) ? this._config.weekdays_selected_room : (ents[0]||''); const roomIdx = ents.indexOf(roomEid); const prevBtn = mkArrow(-1); prevBtn.title = 'Previous room'; prevBtn.addEventListener('click', () => { const newIdx = roomIdx <= 0 ? ents.length - 1 : roomIdx - 1; this._config.weekdays_selected_room = ents[newIdx]; this._render(); }); leftNavBtn = prevBtn; const nextBtn = mkArrow(1); nextBtn.title = 'Next room'; nextBtn.addEventListener('click', () => { const newIdx = roomIdx >= ents.length - 1 ? 0 : roomIdx + 1; this._config.weekdays_selected_room = ents[newIdx]; this._render(); }); rightNavBtn = nextBtn; } const leftWrap = document.createElement('div'); leftWrap.className = 'wd-left'; const centerWrap = document.createElement('div'); centerWrap.className = 'wd-center'; const rightWrap = document.createElement('div'); rightWrap.className = 'wd-right'; // Mobile-only: show view switch on the weekday row (left-aligned) try { const segMobile = makeWeekViewSeg('sp-week-view-mobile'); if (segMobile) leftWrap.appendChild(segMobile); } catch {} const labelSpan = document.createElement('span'); labelSpan.textContent = label; // Keep arrows tight to the label (centered as a group) if (leftNavBtn) centerWrap.appendChild(leftNavBtn); centerWrap.appendChild(labelSpan); if (rightNavBtn) centerWrap.appendChild(rightNavBtn); dayFull.append(leftWrap, centerWrap, rightWrap); dayFull.style.display = ''; } else { dayFull.textContent = ''; dayFull.style.display = 'none'; } } } catch {} // Scale ticks scaleEl.innerHTML = ''; const compact = this._isCompactScale(); for (let i = 0; i <= 24; i++) { if (compact && i % 2 !== 0) continue; const t = document.createElement('div'); t.className = 'tick ' + (i % 2 === 0 ? 'full' : 'short'); t.style.left = `${(i / 24) * 100}%`; if (i % 2 === 0 || !compact) { const l = document.createElement('label'); if (this._config?.time_12h){ const p = this._timeParts(i===24?0:i*60); l.innerHTML = `${p.main}${p.mer||''}`; } else { l.textContent = (i === 24) ? this._label(0) : this._label(i*60); } t.append(l); } scaleEl.append(t); } // Rows rowsEl.innerHTML = ''; // Right header tools: global pause toggle (absolute top-right) try { if (headerEl) { const spacer = headerEl.querySelector('.spacer') || headerEl; let wrap = headerEl.querySelector('.pause-wrap'); if (!wrap) { wrap = document.createElement('div'); wrap.className = 'pause-wrap'; spacer.append(wrap); } else if (wrap.parentNode !== spacer) { try { spacer.append(wrap); } catch {} } wrap.innerHTML = ''; // Desktop: show view switch in top row (hidden on mobile) try { const segDesktop = makeWeekViewSeg('sp-week-view-desktop'); if (segDesktop) wrap.append(segDesktop); } catch {} // Settings button (top-right) try { if (!this._disablePopups) { const sbtn = document.createElement('button'); sbtn.className = 'settings-btn'; sbtn.type = 'button'; try { sbtn.dataset.role = 'settingsbtn'; } catch {} const lab = this._t('editor.tabs.settings') || 'Settings'; sbtn.setAttribute('title', lab); sbtn.setAttribute('aria-label', lab); sbtn.innerHTML = ''; sbtn.addEventListener('click', ()=>{ try { this._openSettingsPopup(); } catch {} }); wrap.append(sbtn); } } catch {} // Seasonal switch (Summer/Winter) try { if (this._config?.seasonal_enabled) { const cur = this._seasonKey(); const label = this._t(cur === 'summer' ? 'season.mode.summer' : 'season.mode.winter') || (cur === 'summer' ? 'Summer' : 'Winter'); const next = (cur === 'summer') ? 'winter' : 'summer'; const nextLabel = this._t(next === 'summer' ? 'season.mode.summer' : 'season.mode.winter') || (next === 'summer' ? 'Summer' : 'Winter'); const sb = document.createElement('button'); sb.className = 'profiles-btn primary season-btn'; sb.type = 'button'; sb.textContent = label; sb.setAttribute('title', `${label} → ${nextLabel}`); sb.setAttribute('aria-label', `${label} → ${nextLabel}`); sb.addEventListener('click', ()=>{ try { this._setSeasonMode(next); } catch {} }); wrap.append(sb); } } catch {} if (this._config?.show_pause_button ?? true) { const btn = document.createElement('button'); btn.className = 'pause-btn' + (this._isPaused() ? ' paused' : ''); btn.setAttribute('title', this._isPaused() ? this._t('pause.active_indef') : this._t('pause.menu_title')); btn.innerHTML = ''; btn.addEventListener('click', ()=>{ try { const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-pause'); if (ov) ov.classList.add('open'); this._renderPauseStatus(); } catch {} }); // Build optional bubble to the left const paused = this._isPaused(); const until = Number(this._pauseUntilMs||0); const showCountdown = paused && !this._pauseIndef && until > Date.now(); if (paused) { const eta = document.createElement('span'); eta.className = 'pause-eta'; eta.textContent = showCountdown ? this._formatPauseCountdown() : (this._t('pause.off_indef')); wrap.append(eta); wrap.append(btn); if (showCountdown) this._startCountdownTimer(); else if (this._countdownTimer) { try { clearInterval(this._countdownTimer); } catch {} this._countdownTimer = null; } } else { wrap.append(btn); if (this._countdownTimer) { try { clearInterval(this._countdownTimer); } catch {} this._countdownTimer = null; } } } // Global Away bypass button (visible only when Away is active: nobody home) try { const advOn = !!(this._config?.away?.advanced_enabled); const awayActive = !!this._isAwayActive(); if (this._config?.away?.enabled && !advOn && awayActive) { const ab = document.createElement('button'); ab.className = 'profiles-btn away-bypass-btn'; // Make it red like a warning action try { ab.style.borderColor = 'var(--error-color)'; ab.style.background = 'var(--error-color)'; ab.style.color = 'var(--text-primary-color, #fff)'; } catch {} ab.textContent = this._awayBypass ? (this._t('away.resume_away') || 'Resume Away temp') : (this._t('away.resume_schedule') || 'Resume schedule'); ab.addEventListener('click', async ()=>{ try { this._awayBypass = !this._awayBypass; try { await this._saveStore(); } catch {} try { await this._applyCurrentSetpoints(true); } catch {} try { if (this._config?.storage_enabled) this._nudgeBackgroundApplyNow(); } catch {} this._scheduleNextApply(); ab.textContent = this._awayBypass ? (this._t('away.resume_away') || 'Resume Away temp') : (this._t('away.resume_schedule') || 'Resume schedule'); } catch {} }); const pauseBtn = wrap.querySelector('.pause-btn'); if (pauseBtn && pauseBtn.parentNode === wrap) { wrap.insertBefore(ab, pauseBtn); } else { wrap.append(ab); } } else { // Remove if not applicable const old = wrap.querySelector('.away-bypass-btn'); if (old) old.remove(); } } catch {} // Presence live status for Away persons try { const persons = (this._presencePersons && typeof this._presencePersons === 'function') ? this._presencePersons().filter(Boolean) : []; const awayOn = !!(this._config?.away?.enabled); const advOn = !!(this._config?.away?.advanced_enabled); const wantLive = (awayOn || advOn) && !!(this._config?.presence_live_header ?? true); if (persons.length && wantLive) { let live = wrap.querySelector('.presence-live'); if (!live) { live = document.createElement('div'); live.className = 'presence-live'; const pauseBtn = wrap.querySelector('.pause-btn'); if (pauseBtn && pauseBtn.parentNode === wrap) wrap.insertBefore(live, pauseBtn); else wrap.append(live); } live.innerHTML = ''; for (const p of persons) { const st = this._hass?.states?.[p]; const state = String(st?.state || 'unknown'); const friendly = st?.attributes?.friendly_name || (String(p||'').split('.')[1] || p); const chip = document.createElement('span'); chip.className = 'presence-chip ' + (state.toLowerCase() === 'home' ? 'home' : 'away'); chip.setAttribute('title', `${friendly}: ${state}`); const dot = document.createElement('span'); dot.className = 'presence-dot'; const name = document.createElement('span'); name.className = 'presence-name'; name.textContent = friendly; chip.append(dot, name); live.append(chip); } } else { // Remove if disabled const live = wrap.querySelector('.presence-live'); if (live) live.remove(); } } catch {} // Collapsible header tools (Presence / Profiles / Weekdays) try { let htools = wrap.querySelector('.head-tools'); if (!htools) { htools = document.createElement('div'); htools.className = 'head-tools toolbar' + (this._headToolsOpen ? ' open' : ''); wrap.append(htools); } htools.innerHTML = ''; const toggleBtn = document.createElement('button'); toggleBtn.className='tools-toggle'; toggleBtn.type='button'; toggleBtn.setAttribute('aria-label','Toggle header tools'); toggleBtn.textContent = this._headToolsOpen ? '✕' : '❮'; const toolsWrap = document.createElement('div'); toolsWrap.className = 'tools-wrap'; const hasPending = !!this._pendingSaveAfterEdit; const recentlySaved = (this._syncJustUntil || 0) > Date.now(); const showButton = hasPending || this._saving || recentlySaved; // Explicit Save button that writes all pending changes to storage; always visible (outside fold), only when pending changes exist let saveBtn = null; try { saveBtn = document.createElement('button'); saveBtn.className = 'profiles-btn success'; saveBtn.type = 'button'; saveBtn.style.marginRight = '6px'; const idleLabel = this._t('ui.save') || 'Save'; const savingLabel = this._t('sync.running') || 'Saving…'; const doneLabel = this._t('sync.completed') || 'Saved'; const setState = (state)=>{ if (state==='saving') { saveBtn.disabled = true; saveBtn.textContent = savingLabel; } else if (state==='done') { saveBtn.disabled = false; saveBtn.textContent = doneLabel; } else { saveBtn.disabled = false; saveBtn.textContent = idleLabel; } }; setState(this._saving ? 'saving' : (recentlySaved ? 'done' : 'idle')); if (!showButton) saveBtn.style.display = 'none'; saveBtn.addEventListener('click', async ()=>{ try { setState('saving'); const savePromise = this._saveStore(true); const delayPromise = new Promise(r => setTimeout(r, 800)); await Promise.all([savePromise, delayPromise]); setState('done'); setTimeout(()=>{ setState('idle'); this._render(); }, 1000); } catch { setState('idle'); } }); } catch {} // Presence button if (this._config?.away?.advanced_enabled) { const prb = document.createElement('button'); prb.className='profiles-btn primary'; prb.type='button'; prb.textContent=this._t('presence.button'); prb.addEventListener('click', ()=>{ const first=(this._config?.entities||[])[0]||null; if (first) this._openPresenceEditor(first); }); toolsWrap.append(prb); } // Profiles button const wantProfilesBtn = !!(this._config?.profiles_enabled || this._getLocalProfilesEnabled()); if ((this._config?.entities||[]).length && wantProfilesBtn) { const pbtn = document.createElement('button'); pbtn.className='profiles-btn primary'; pbtn.type='button'; pbtn.textContent=this._t('profiles.button'); pbtn.addEventListener('click', ()=>{ const first=(this._config?.entities||[])[0]||null; if (first) this._openProfilesEditor(first); }); toolsWrap.append(pbtn); } // Weekdays button (header) if (this._config?.weekdays_enabled && (this._config?.entities||[]).length) { const wbtn = document.createElement('button'); wbtn.className='profiles-btn primary'; wbtn.type='button'; wbtn.textContent = this._t('week.button'); wbtn.addEventListener('click', ()=>{ const first=(this._config?.entities||[])[0]; if (first) this._openWeeklyEditor(first); }); toolsWrap.append(wbtn); // When viewing all days for one room, show a small room selector try { const perDayView = String(this._config?.weekdays_view||'all_rooms_one_day')==='one_room_all_days'; if (perDayView) { const sel = document.createElement('select'); sel.className = 'weekroom-select'; sel.style.height = '32px'; sel.style.border = '1px solid var(--divider-color)'; sel.style.borderRadius = '8px'; sel.style.background = 'var(--card-background-color)'; sel.style.color = 'var(--primary-text-color)'; sel.style.marginLeft = '6px'; const ents = (this._config?.entities || []); for (const eid of ents){ const opt=document.createElement('option'); const label = this._config?.labels?.[eid] || (this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1]||eid)); opt.value=eid; opt.textContent=label; sel.append(opt); } const want = this._config?.weekdays_selected_room || ents[0] || ''; sel.value = want; sel.addEventListener('change', (e)=>{ const v=String(e.target.value||''); this._config.weekdays_selected_room = v; this._render(); }); toolsWrap.append(sel); } } catch {} } // Toggle behavior toggleBtn.addEventListener('click', ()=>{ this._headToolsOpen = !this._headToolsOpen; if (this._headToolsOpen) { htools.classList.add('open'); toggleBtn.textContent='✕'; requestAnimationFrame(()=>{ try { toolsWrap.style.maxWidth=(toolsWrap.scrollWidth+2)+'px'; } catch {} }); } else { try { toolsWrap.style.maxWidth=(toolsWrap.scrollWidth+2)+'px'; } catch {}; htools.classList.remove('open'); toggleBtn.textContent='❮'; requestAnimationFrame(()=>{ toolsWrap.style.maxWidth='0px'; }); } }); // Place Save (if any) before collapsible tools, then toggle on the far right if (saveBtn) htools.append(saveBtn); htools.append(toolsWrap, toggleBtn); // Hide toggle button if toolsWrap is empty if (toolsWrap.children.length === 0) { toggleBtn.style.display = 'none'; } // Ensure initial width reflects current open state try { if (this._headToolsOpen) { requestAnimationFrame(()=>{ try { toolsWrap.style.maxWidth = (toolsWrap.scrollWidth + 2) + 'px'; } catch {} }); } else { toolsWrap.style.maxWidth = '0px'; } } catch {} } catch {} // Ensure pause button is the rightmost item in the header try { const pauseBtn = wrap.querySelector('.pause-btn'); if (pauseBtn) wrap.appendChild(pauseBtn); const settingsBtn = wrap.querySelector('.settings-btn'); if (settingsBtn) wrap.appendChild(settingsBtn); } catch {} } } catch {} const nowMin = this._getNowMin(); const nowPct = (nowMin / 1440) * 100; const showTop = this._config.show_top_now; const extendPx = this._config.now_extend_px; // Optional: render all selected weekdays for one room let __weeklyBuilt = false; try { const perRoomAllDays = !!(this._config?.weekdays_enabled) && String(this._config?.weekdays_view||'all_rooms_one_day')==='one_room_all_days'; if (perRoomAllDays) { const ents = (this._config?.entities||[]); const roomEid = (this._config?.weekdays_selected_room && ents.includes(this._config.weekdays_selected_room)) ? this._config.weekdays_selected_room : (ents[0]||null); if (roomEid) { const row = this._schedules[roomEid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureWeeklyStruct(row); const mode = row.weekly?.mode || this._config?.weekdays_mode || 'weekday_weekend'; const groupKeys = mode==='weekday_weekend' ? ['weekdays','weekend'] : (mode==='weekday_sat_sun' ? ['weekdays','sat','sun'] : ['mon','tue','wed','thu','fri','sat','sun']); const labelFor = (k)=>{ const map = { weekdays:'week.group.weekdays', weekend:'week.group.weekend', mon:'week.days_full.mon', tue:'week.days_full.tue', wed:'week.days_full.wed', thu:'week.days_full.thu', fri:'week.days_full.fri', sat:'week.days_full.sat', sun:'week.days_full.sun' }; return this._t(map[k]) || k; }; const effKey = (k)=> (k==='weekdays') ? 'mon' : (k==='weekend' ? 'sat' : k); const getBlocks = (dk)=>{ try { const src = row.weekly_modes?.[mode]?.days || {}; const arr = src?.[dk]; if (Array.isArray(arr)) return arr; } catch {} try { const arr2 = row.weekly?.days?.[dk]; if (Array.isArray(arr2)) return arr2; } catch {} return []; }; for (const gk of groupKeys){ const rowEl = document.createElement('div'); rowEl.className='row'; const head = document.createElement('div'); head.className='row-head'; const meta = document.createElement('div'); meta.className='meta'; const name = document.createElement('span'); name.textContent = labelFor(gk); meta.append(name); head.append(meta); // Add toolbar with Add and Copy buttons for weekday view const toolbar = document.createElement('div'); toolbar.className = 'toolbar' + (this._toolsOpen[roomEid + ':' + effKey(gk)] ? ' open' : ''); toolbar.style.minHeight = '32px'; const toolsWrap = document.createElement('div'); toolsWrap.className = 'tools-wrap'; // Toggle button for collapsible toolbar const toggleBtn = document.createElement('button'); toggleBtn.className = 'tools-toggle'; toggleBtn.type = 'button'; toggleBtn.setAttribute('aria-label', 'Toggle actions'); toggleBtn.textContent = this._toolsOpen[roomEid + ':' + effKey(gk)] ? '✕' : '❮'; toggleBtn.addEventListener('click', () => { const toolKey = roomEid + ':' + effKey(gk); const willOpen = !this._toolsOpen[toolKey]; this._toolsOpen[toolKey] = willOpen; if (willOpen) { try { toolsWrap.style.maxWidth = '0px'; } catch {} toolbar.classList.add('open'); toggleBtn.textContent = '✕'; requestAnimationFrame(() => { try { const w = toolsWrap.scrollWidth || 0; toolsWrap.style.maxWidth = (w + 2) + 'px'; } catch {} }); } else { try { const w = toolsWrap.scrollWidth || 0; toolsWrap.style.maxWidth = (w + 2) + 'px'; } catch {} toolbar.classList.remove('open'); toggleBtn.textContent = '❮'; requestAnimationFrame(() => { try { toolsWrap.style.maxWidth = '0px'; } catch {} }); } }); // Add block button const addBtn = document.createElement('button'); addBtn.className = 'btn primary'; addBtn.textContent = '+ ' + this._t('ui.add_block'); addBtn.addEventListener('click', () => { this._weeklyEntity = roomEid; // Keep group/day key so editor respects current weekday mode. // E.g. weekday_weekend: editing on Sunday should edit the "weekend" group. this._ensureWeeklyStruct(row); const mode = row.weekly?.mode || this._config?.weekdays_mode || 'weekday_weekend'; this._weeklyDayKey = gk; // Build draft from active mode bucket (weekly_modes is the primary source). try { const src = row.weekly_modes?.[mode]?.days || row.weekly?.days || {}; const names = this._weekdayNames(); const draftDays = JSON.parse(JSON.stringify(src)); for (const k of names){ if (!Array.isArray(draftDays[k])) draftDays[k] = []; } this._weeklyDraft = { mode, days: draftDays }; // Include modes so edit/delete can fully reconcile sources. try { this._weeklyDraft._weeklyModes = JSON.parse(JSON.stringify(row.weekly_modes || {})); } catch {} } catch { this._weeklyDraft = JSON.parse(JSON.stringify(row.weekly)); try { this._weeklyDraft.mode = this._weeklyDraft.mode || mode; } catch {} try { this._weeklyDraft._weeklyModes = JSON.parse(JSON.stringify(row.weekly_modes || {})); } catch {} } this._autoSaveWeeklyFromTimeline = true; this._openWeeklyBlockEditor(null, 0); }); toolsWrap.append(addBtn); // Copy/Paste button const copyBtn = document.createElement('button'); copyBtn.className = 'btn ghost'; const updateCopyBtn = ()=>{ if (!this._copyFrom) { copyBtn.textContent = this._t('ui.copy_plan'); copyBtn.classList.remove('success', 'danger'); copyBtn.classList.add('ghost'); } else if (this._copyFrom === roomEid + ':' + effKey(gk)) { copyBtn.textContent = this._t('ui.cancel'); copyBtn.classList.remove('success', 'ghost'); copyBtn.classList.add('danger'); } else { copyBtn.textContent = this._t('ui.paste_plan'); copyBtn.classList.remove('ghost', 'danger'); copyBtn.classList.add('success'); } }; updateCopyBtn(); copyBtn.addEventListener('click', async () => { const dayKey = effKey(gk); const eid_day = roomEid + ':' + dayKey; if (this._copyFrom && this._copyFrom === eid_day) { this._copyFrom = null; this._copyPayload = null; this._render(); return; } if (this._copyFrom && this._copyPayload) { try { const src = this._copyPayload; this._ensureWeeklyStruct(row); // Generate new IDs for copied blocks and paste them const pastedBlocks = src.map(b=>({ ...b, id: Math.random().toString(36).slice(2,9) })); // Paste to the same location where blocks come from // Check if blocks come from weekly_modes (first priority in getBlocks) const srcModes = row.weekly_modes?.[mode]?.days || {}; if (srcModes?.[dayKey]) { // Blocks are in weekly_modes, paste there if (!row.weekly_modes[mode]) row.weekly_modes[mode] = { days: {} }; row.weekly_modes[mode].days[dayKey] = pastedBlocks; } else { // Blocks are in weekly or new, paste to weekly row.weekly.days[dayKey] = pastedBlocks; } await this._saveStore(); this._render(); } catch(e){} return; } try { const dayBlocks = getBlocks(dayKey); const payload = dayBlocks.map(b=>({ startMin:b.startMin, endMin:b.endMin, temp:b.temp })); this._copyFrom = eid_day; this._copyPayload = payload; this._render(); } catch(e){} }); toolsWrap.append(copyBtn); toolbar.append(toggleBtn); toolbar.append(toolsWrap); head.append(toolbar); rowEl.append(head); const track = document.createElement('div'); track.className='track'; track.style.setProperty('--row-height', `${this._config.row_height}px`); const dkey = effKey(gk); const blocks = getBlocks(dkey); for (const b of blocks){ const pctStart = (b.startMin / 1440) * 100; const pctWidth = ((b.endMin - b.startMin) / 1440) * 100; const bl = document.createElement('div'); bl.className='block'; bl.style.left=`${pctStart}%`; bl.style.width=`${pctWidth}%`; try { const clr = this._colorFor(roomEid, b.temp); if (clr){ bl.style.background = clr; bl.style.borderColor = clr; const txt=this._contrastTextColor(clr); if (txt) bl.style.color = txt; } } catch {} UiHelper.genBlockPill( this, bl, `${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}` ); // Add double-click to edit block bl.addEventListener('dblclick', (e) => { try { e.preventDefault(); e.stopPropagation(); } catch {} this._weeklyEntity = roomEid; // Keep group key (gk) so the editor knows whether it's weekdays/weekend vs specific day. this._ensureWeeklyStruct(row); const mode = row.weekly?.mode || this._config?.weekdays_mode || 'weekday_weekend'; this._weeklyDayKey = gk; try { const src = row.weekly_modes?.[mode]?.days || row.weekly?.days || {}; const names = this._weekdayNames(); const draftDays = JSON.parse(JSON.stringify(src)); for (const k of names){ if (!Array.isArray(draftDays[k])) draftDays[k] = []; } this._weeklyDraft = { mode, days: draftDays }; try { this._weeklyDraft._weeklyModes = JSON.parse(JSON.stringify(row.weekly_modes || {})); } catch {} } catch { this._weeklyDraft = JSON.parse(JSON.stringify(row.weekly)); try { this._weeklyDraft.mode = this._weeklyDraft.mode || mode; } catch {} try { this._weeklyDraft._weeklyModes = JSON.parse(JSON.stringify(row.weekly_modes || {})); } catch {} } this._autoSaveWeeklyFromTimeline = true; this._openWeeklyBlockEditor(b.id); }); // Touch/iPad: detect double-tap bl.addEventListener('touchend', (e) => { const now = Date.now(); const last = this._lastWeeklyTap || { t: 0, target: null }; if (last.target === bl && (now - last.t) < 350) { try { e.preventDefault(); e.stopPropagation(); } catch {} this._lastWeeklyTap = { t: 0, target: null }; this._weeklyEntity = roomEid; this._ensureWeeklyStruct(row); const mode = row.weekly?.mode || this._config?.weekdays_mode || 'weekday_weekend'; this._weeklyDayKey = gk; try { const src = row.weekly_modes?.[mode]?.days || row.weekly?.days || {}; const names = this._weekdayNames(); const draftDays = JSON.parse(JSON.stringify(src)); for (const k of names){ if (!Array.isArray(draftDays[k])) draftDays[k] = []; } this._weeklyDraft = { mode, days: draftDays }; try { this._weeklyDraft._weeklyModes = JSON.parse(JSON.stringify(row.weekly_modes || {})); } catch {} } catch { this._weeklyDraft = JSON.parse(JSON.stringify(row.weekly)); try { this._weeklyDraft.mode = this._weeklyDraft.mode || mode; } catch {} try { this._weeklyDraft._weeklyModes = JSON.parse(JSON.stringify(row.weekly_modes || {})); } catch {} } this._autoSaveWeeklyFromTimeline = true; this._openWeeklyBlockEditor(b.id); } else { this._lastWeeklyTap = { t: now, target: bl }; setTimeout(() => { if (this._lastWeeklyTap?.target === bl) this._lastWeeklyTap = { t: 0, target: null }; }, 350); } }); track.append(bl); } rowEl.append(track); rowsEl.append(rowEl); } __weeklyBuilt = true; } } } catch {} // Default: render one row per room if (!__weeklyBuilt) { for (const eid of this._config.entities) { const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; const rowEl = document.createElement('div'); rowEl.className = 'row'; const head = document.createElement('div'); head.className = 'row-head'; const meta = document.createElement('div'); meta.className = 'meta'; const name = document.createElement('span'); // Brug brugerdefineret label hvis sat i config.labels, ellers fallback til friendly_name/_prettyName const customLabel = (this._config.labels && this._config.labels[eid]) ? this._config.labels[eid] : null; name.textContent = customLabel || this._prettyName(eid); meta.append(name); // Per-room Default °C input (optional) try { if (this._config?.per_room_defaults) { const inp = document.createElement('input'); inp.type = 'number'; inp.step = '0.5'; try { inp.min = String(this._minDisplay()); inp.max = String(this._maxDisplay()); } catch {} const base = Number(row.defaultTemp ?? this._config.default_temp ?? 20); inp.value = String(this._toDisplayTemp(base)); inp.title = this._t('editor.default_c').replace('°C', this._unitSymbol()); inp.addEventListener('change', async () => { const before = this._config.apply_on_default_change ? this._desiredNowSnapshot() : null; let raw = String(inp.value||'').replace(',','.'); let vDisp = Number(raw); let vC = Number.isFinite(vDisp) ? this._fromDisplayTemp(vDisp) : (row.defaultTemp ?? this._config.default_temp ?? 20); const mx = Number(this._config?.max_temp ?? 25); const mn = Number(this._config?.min_temp ?? 5); vC = Math.max(mn, Math.min(mx, vC)); row.defaultTemp = vC; try { inp.value = String(this._toDisplayTemp(vC)); } catch {} this._debouncedSaveStore(); if (before) { try { await this._applyIfDesiredChanged(before); } catch {} } this._scheduleNextApply(); }); meta.append(inp); } } catch {} // inline default temperature input removed from timeline to keep UI clean // Current room temperature bubble: show climate.current_temperature // If room is merged with additional thermostats, show the average temperature try { if (this._config?.show_room_temp ?? true) { const curC = this._roomCurrentTemperatureC ? this._roomCurrentTemperatureC(eid) : NaN; if (Number.isFinite(curC)) { const vDisp = this._toDisplayTemp(curC); const sb = document.createElement('span'); sb.className = 'sensor-bubble'; sb.textContent = `${this._t('ui.current_temp')} ${String(Math.round(Number(vDisp) * 10) / 10)} ${this._unitSymbol()}`; meta.append(sb); } } } catch {} // (Global Away bypass button moved to header; no per-room button) // (manual indicator removed) head.append(meta); // Collapsible toolbar: tools slide out from a small arrow button const toolbar = document.createElement('div'); toolbar.className = 'toolbar' + (this._toolsOpen[eid] ? ' open' : ''); // Keep header height stable regardless of open/closed toolbar.style.minHeight = '32px'; const toolsWrap = document.createElement('div'); toolsWrap.className = 'tools-wrap'; const toggleBtn = document.createElement('button'); toggleBtn.className = 'tools-toggle'; toggleBtn.type = 'button'; toggleBtn.setAttribute('aria-label', 'Toggle actions'); toggleBtn.textContent = this._toolsOpen[eid] ? '✕' : '❮'; toggleBtn.addEventListener('click', () => { const bar = toggleBtn.parentElement; const wrap = toolsWrap; const willOpen = !this._toolsOpen[eid]; this._toolsOpen[eid] = willOpen; if (willOpen) { // prepare then expand try { wrap.style.maxWidth = '0px'; } catch {} bar && bar.classList.add('open'); toggleBtn.textContent = '✕'; requestAnimationFrame(() => { try { const w = wrap.scrollWidth || 0; wrap.style.maxWidth = (w + 2) + 'px'; } catch {} }); } else { // freeze width then collapse try { const w = wrap.scrollWidth || 0; wrap.style.maxWidth = (w + 2) + 'px'; } catch {} bar && bar.classList.remove('open'); toggleBtn.textContent = '❮'; requestAnimationFrame(() => { try { wrap.style.maxWidth = '0px'; } catch {} }); } }); // (resume schedule button removed) const addBtn = document.createElement('button'); addBtn.className = 'btn primary'; addBtn.textContent = '+ ' + this._t('ui.add_block'); try { addBtn.dataset.role = 'addbtn'; if (this._disablePopups) addBtn.style.display = 'none'; } catch {} addBtn.addEventListener('click', () => { // If weekdays are enabled, open the block editor directly for today's weekly plan try { const row = this._schedules[eid]; if (this._config?.weekdays_enabled && row) { // Ensure weekly structures exist this._ensureWeeklyStruct(row); const mode = row.weekly?.mode || this._config?.weekdays_mode || 'weekday_weekend'; const today = this._timelineDisplayDayKey(); const isWeekday = ['mon','tue','wed','thu','fri'].includes(today); let sel = today; if (mode === 'weekday_weekend') sel = isWeekday ? 'weekdays' : 'weekend'; else if (mode === 'weekday_sat_sun') sel = isWeekday ? 'weekdays' : today; // sat/sun // Prepare an in-memory weekly draft without opening the weekdays popup try { const src = row.weekly_modes?.[mode]?.days || {}; const names = this._weekdayNames(); const draftDays = JSON.parse(JSON.stringify(src)); for (const k of names){ if (!Array.isArray(draftDays[k])) draftDays[k] = []; } this._weeklyDraft = { mode, days: draftDays }; } catch { this._weeklyDraft = JSON.parse(JSON.stringify(row.weekly)); } this._weeklyEntity = eid; this._weeklyDayKey = sel; this._autoSaveWeeklyFromTimeline = true; const now = this._getNowMin(); this._openWeeklyBlockEditor(null, now); return; } } catch {} // Fallback: non-weekly add this._openNewEditor(eid); }); toolsWrap.append(addBtn); // Copy/Paste schedule button const copyBtn = document.createElement('button'); copyBtn.className = 'btn ghost'; const applyCopyStyle = (mode)=>{ // mode: 'copy' | 'paste' | 'cancel' copyBtn.className = 'btn'; if (mode === 'copy') copyBtn.classList.add('ghost'); if (mode === 'paste') copyBtn.classList.add('success'); if (mode === 'cancel') copyBtn.classList.add('danger'); }; const updateCopyBtn = ()=>{ if (!this._copyFrom) { copyBtn.textContent = this._t('ui.copy_plan'); applyCopyStyle('copy'); } else if (this._copyFrom === eid) { copyBtn.textContent = this._t('ui.cancel'); applyCopyStyle('cancel'); } else { copyBtn.textContent = this._t('ui.paste_plan'); applyCopyStyle('paste'); } }; updateCopyBtn(); copyBtn.addEventListener('click', async () => { // Cancel on source row if (this._copyFrom && this._copyFrom === eid) { this._copyFrom = null; this._copyPayload = null; this._render(); return; } // Paste into this row when copy active if (this._copyFrom && this._copyPayload) { const before = this._desiredNowSnapshot(); const src = this._copyPayload; try { const tgt = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; // Deep copy const clone = JSON.parse(JSON.stringify(src)); tgt.defaultTemp = Number.isFinite(clone.defaultTemp) ? clone.defaultTemp : (tgt.defaultTemp || this._config.default_temp); tgt.blocks = Array.isArray(clone.blocks) ? clone.blocks.map(b=>({ ...b, id: Math.random().toString(36).slice(2,9) })) : []; // Weekday structures (keep modes if present) if (clone.weekly) tgt.weekly = JSON.parse(JSON.stringify(clone.weekly)); if (clone.weekly_modes) tgt.weekly_modes = JSON.parse(JSON.stringify(clone.weekly_modes)); this._schedules[eid] = tgt; await this._saveStore(); this._render(); if (this._config.apply_on_edit) await this._applyIfDesiredChanged(before); this._scheduleNextApply(); } catch(e){} return; } // Start copy from this row try { const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; const payload = { defaultTemp: row.defaultTemp, blocks: (row.blocks||[]).map(b=>({ startMin:b.startMin, endMin:b.endMin, temp:b.temp })), weekly: row.weekly ? JSON.parse(JSON.stringify(row.weekly)) : undefined, weekly_modes: row.weekly_modes ? JSON.parse(JSON.stringify(row.weekly_modes)) : undefined }; this._copyFrom = eid; this._copyPayload = payload; this._render(); } catch(e){} }); toolsWrap.append(copyBtn); // Weekdays button moved to header tools; hidden on rows to avoid duplication // Row-level Manual schedule button removed – profiles button now lives in the header only try { /* intentionally no-op */ } catch {} // (Profiles button moved to header) toolbar.append(toolsWrap, toggleBtn); // Hide toggle button if toolsWrap is empty if (toolsWrap.children.length === 0) { toggleBtn.style.display = 'none'; } // Ensure correct initial width for open state without rerendering try { if (this._toolsOpen[eid]) { requestAnimationFrame(() => { try { toolsWrap.style.maxWidth = (toolsWrap.scrollWidth + 2) + 'px'; } catch {} }); } else { toolsWrap.style.maxWidth = '0px'; } } catch {} head.append(toolbar); rowEl.append(head); const track = document.createElement('div'); track.className = 'track'; track.style.setProperty('--row-height', `${this._config.row_height}px`); // Use browsed day in weekdays view (all rooms • one day) const displayDayKey = this._timelineDisplayDayKey(); let dayKey = displayDayKey; try { if (this._config?.weekdays_enabled && row?.weekly) { const mode = row.weekly?.mode || this._config?.weekdays_mode || 'weekday_weekend'; dayKey = this._weekdaysStorageDayKeyForMode(displayDayKey, mode); } } catch { dayKey = displayDayKey; } this._ensureHolidayStruct(row); this._ensurePresenceStruct(row); let showBlocks = (row.blocks || []); try { if (this._isHolidayActive() && row?.holiday?.blocks?.length) { showBlocks = row.holiday.blocks || []; } else { // Advanced Presence overrides visual track as well (when a combo is active) try { const key = this._activePresenceComboKey && this._activePresenceComboKey(); if (key) { const roomBlocks = (row.presence && Array.isArray(row.presence[key]?.blocks)) ? (row.presence[key].blocks || []) : []; if (roomBlocks.length) { showBlocks = roomBlocks; } else { // Presence combo active but no schedule for this room -> show a virtual all-day Away block const awayC = Number(this._config?.away?.target_c ?? 17); const fallback = Number.isFinite(awayC) ? awayC : this._rowDefaultTemp(row); showBlocks = [{ id: '__presence_away__', startMin: 0, endMin: 1440, temp: fallback }]; } } else if (this._config?.profiles_enabled) { this._ensureProfilesStruct(row); const ap = row.activeProfile; if (ap && row.profiles && Array.isArray(row.profiles[ap]?.blocks) && (row.profiles[ap].blocks||[]).length) { showBlocks = row.profiles[ap].blocks || []; } else if (this._config?.weekdays_enabled) { showBlocks = this._getBlocksForDay(row, dayKey) || []; } } else if (this._config?.weekdays_enabled) { showBlocks = this._getBlocksForDay(row, dayKey) || []; } } catch { /* fallback below */ } } } catch {} for (const b of showBlocks) { const pctStart = (b.startMin / 1440) * 100; const pctWidth = ((b.endMin - b.startMin) / 1440) * 100; const bl = document.createElement('div'); bl.className = 'block'; bl.style.left = `${pctStart}%`; bl.style.width = `${pctWidth}%`; try { const clr = this._colorFor(eid, b.temp); if (clr) { bl.style.background = clr; bl.style.borderColor = clr; const txt = this._contrastTextColor(clr); if (txt) bl.style.color = txt; } } catch {} if (this._active?.entity === eid && this._active?.id === b.id) bl.classList.add('active'); // Time will be shown in a hover tooltip (like the weekdays popup). Do not render time pill here. UiHelper.genBlockPill( this, bl, `${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}` ); track.append(bl); // Ensure a tooltip element exists for main timeline (reuse weekly style) try { let tooltip = this.shadowRoot.querySelector('.wk-tooltip.main'); const rowsEl = this.shadowRoot.querySelector('.rows'); const modalHost = rowsEl || this.shadowRoot.querySelector('ha-card'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className = 'wk-tooltip main'; tooltip.style.display = 'none'; // append to rows so positioning is relative to timeline if (modalHost) modalHost.append(tooltip); } const showTip = () => { const txt = `${this._label(b.startMin)} - ${this._label(b.endMin)} • ${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}`; if (!tooltip) return; // Cancel any pending hide try { if (tooltip._hideTimer) { clearTimeout(tooltip._hideTimer); tooltip._hideTimer = null; } } catch {} // Do not show while editor popup is open try { const ov = this.shadowRoot.querySelector('.overlay'); if (this._editing || (ov && ov.classList.contains('open'))) return; } catch {} tooltip.textContent = txt; const box = bl.getBoundingClientRect(); const cont = (modalHost && modalHost.getBoundingClientRect) ? modalHost.getBoundingClientRect() : { left: 0, top: 0 }; const left = box.left + box.width / 2 - (cont.left || 0); const top = (box.top - (cont.top || 0)) - 8; tooltip.style.left = left + 'px'; tooltip.style.top = top + 'px'; tooltip.style.transform = 'translate(-50%,-100%)'; tooltip.style.display = ''; }; const hideTip = () => { if (!tooltip) return; // On coarse pointers (tablet) keep tooltip a bit longer const delay = (window.matchMedia && window.matchMedia('(pointer:coarse)').matches) ? 3000 : 120; try { if (tooltip._hideTimer) clearTimeout(tooltip._hideTimer); } catch {} tooltip._hideTimer = setTimeout(() => { try { tooltip.style.display = 'none'; tooltip._hideTimer = null; } catch {} }, delay); }; bl.addEventListener('mouseenter', showTip); bl.addEventListener('mouseleave', hideTip); } catch (e) { /* ignore tooltip errors */ } // Open editor only on double interaction: // - Desktop: double-click // - Touch/iPad: double-tap within 350ms on the same block const openEditor = () => { // If a manual profile is active, edit that profile directly instead of weekdays try { if (this._config?.profiles_enabled) { this._ensureProfilesStruct(row); const ap = row?.activeProfile; if (ap && row?.profiles && Array.isArray(row.profiles[ap]?.blocks) && (row.profiles[ap].blocks||[]).length) { // Configure profile context and open profile block editor this._profilesEntity = eid; this._profilesSelected = ap; this._profilesRoom = eid; this._autoSaveProfileFromTimeline = true; this._openProfileBlockEditor(b.id); return; } } } catch {} // Else if weekdays are enabled, edit inline (no weekday popup); auto‑save back to weekdays try { const rowHasWeekly = !!(this._config?.weekdays_enabled && row?.weekly); if (rowHasWeekly) { // Prepare an in‑memory weekly draft for the current mode this._ensureWeeklyStruct(row); const mode = row.weekly?.mode || this._config?.weekdays_mode || 'weekday_weekend'; const today = displayDayKey; // computed earlier const isWeekday = ['mon','tue','wed','thu','fri'].includes(today); let sel = today; if (mode === 'weekday_weekend') sel = isWeekday ? 'weekdays' : 'weekend'; else if (mode === 'weekday_sat_sun') sel = isWeekday ? 'weekdays' : today; // sat/sun // Build draft from the selected mode bucket try { const src = row.weekly_modes?.[mode]?.days || {}; const names = this._weekdayNames(); const draftDays = JSON.parse(JSON.stringify(src)); for (const k of names){ if (!Array.isArray(draftDays[k])) draftDays[k] = []; } this._weeklyDraft = { mode, days: draftDays }; } catch { this._weeklyDraft = JSON.parse(JSON.stringify(row.weekly)); } this._weeklyEntity = eid; this._weeklyDayKey = sel; this._autoSaveWeeklyFromTimeline = true; // Locate corresponding block in effective day using midpoint const eff = this._effectiveDayKey(sel); const mid = Math.floor((b.startMin + b.endMin) / 2); const effArr = (this._weeklyDraft?.days?.[eff] || []); let targetId = b.id; if (eff !== today) { const match = effArr.find(x => mid >= x.startMin && mid < x.endMin); targetId = match ? match.id : null; } if (targetId) this._openWeeklyBlockEditor(targetId); else this._openWeeklyBlockEditor(null, mid); return; } } catch {} // Fallback to normal (non-weekly/non-profile) editor this._openEditor(eid, b.id); }; // Desktop/mouse bl.addEventListener('dblclick', (e) => { try { e.preventDefault(); e.stopPropagation(); } catch {} openEditor(); }); // Touch/iPad: detect double-tap bl.addEventListener('touchend', (e) => { const now = Date.now(); const last = this._lastTap || { t: 0, target: null }; if (last.target === bl && (now - last.t) < 350) { try { e.preventDefault(); e.stopPropagation(); } catch {} this._lastTap = { t: 0, target: null }; openEditor(); } else { this._lastTap = { t: now, target: bl }; // Clear after timeout to avoid stale reference setTimeout(() => { if (this._lastTap && this._lastTap.target === bl && (Date.now() - this._lastTap.t) > 350) { this._lastTap = { t: 0, target: null }; } }, 360); } }, { passive: false }); // Note: single click/tap no longer opens the editor } // Single-line NOW: rely on .now with extend; remove extra top pin /* now-top removed */ rowEl.append(track); rowsEl.append(rowEl); } } // Single global NOW line across all rows { const nowEl = document.createElement('div'); nowEl.className = 'now'; // left will be set in pixels to account for row padding rowsEl.append(nowEl); const updateNowGeom = () => { try { const tracks = Array.from(this.shadowRoot.querySelectorAll('.track')); const rowsBox = rowsEl.getBoundingClientRect(); // Vertical placement/height if (tracks.length) { const overhang = 12; const firstBox = tracks[0].getBoundingClientRect(); const lastBox = tracks[tracks.length - 1].getBoundingClientRect(); const top = Math.round(firstBox.top - rowsBox.top - overhang); const bottom = Math.round(lastBox.bottom - rowsBox.top); nowEl.style.top = top + 'px'; nowEl.style.height = (bottom - top) + 'px'; } // Horizontal left: respect rows padding so it matches .track coordinate system const cs = getComputedStyle(rowsEl); const pl = parseFloat(cs.paddingLeft || '0') || 0; const pr = parseFloat(cs.paddingRight || '0') || 0; const contentW = rowsEl.clientWidth - pl - pr; const leftPx = pl + (nowMin / 1440) * contentW; nowEl.style.left = leftPx + 'px'; // Hide NOW line in "All days • one room" view (multi-day timeline) try { const perRoomAllDays = !!(this._config?.weekdays_enabled) && String(this._config?.weekdays_view||'all_rooms_one_day') === 'one_room_all_days'; if (perRoomAllDays) { nowEl.style.display = 'none'; return; } nowEl.style.display = ''; } catch {} // Set visibility of this NowGeo elt depending on the selected day key let selectedDayKey = ''; try { // In normal timeline, the current display day is derived from the timeline browsing state. // In weekdays view, _weekdaysViewDayKey may be used. selectedDayKey = String((this._timelineDisplayDayKey && this._timelineDisplayDayKey()) || this._weekdaysViewDayKey || '').toLowerCase(); } catch { selectedDayKey = String(this._weekdaysViewDayKey || '').toLowerCase(); } const todayKey = String(this._todayKey && this._todayKey() || '').toLowerCase(); // If we don't have a selected day (common in normal view), assume today. if (!selectedDayKey) selectedDayKey = todayKey; nowEl.style.visibility = (selectedDayKey === todayKey) ? "visible" : "hidden"; } catch {} }; updateNowGeom(); const ro = new ResizeObserver(() => { updateNowGeom(); }); try { ro.observe(rowsEl); } catch {} } // Update footer labels to reflect time format try { const s = this.shadowRoot.querySelector('.lbl-start'); const e = this.shadowRoot.querySelector('.lbl-end'); if (s) s.textContent = this._label(0); if (e) e.textContent = this._label(0); } catch {} // Editor modal events const overlay = qs('.overlay'); const edSave = qs('.ed-save'); const edCancel = qs('.ed-cancel'); const edDelete = qs('.ed-delete'); if (edSave) edSave.addEventListener('click', () => this._saveEditor()); if (edCancel) edCancel.addEventListener('click', () => this._closeEditor()); if (edDelete) edDelete.addEventListener('click', () => this._deleteFromEditor()); // Do not close editor when clicking outside; require explicit buttons overlay.addEventListener('click', (e) => { if (e.target === overlay) { e.preventDefault(); e.stopPropagation(); } }); // Week overlay events const wov = qs('.overlay-week'); const wsave = qs('.wk-save'); const wcancel = qs('.wk-cancel'); if (wsave) wsave.addEventListener('click', () => this._weeklySaveAll()); if (wcancel) wcancel.addEventListener('click', () => this._closeWeeklyEditor()); // Do not close weekly editor on outside click either if (wov) wov.addEventListener('click', (e) => { if (e.target === wov) { e.preventDefault(); e.stopPropagation(); } }); // Profiles overlay events const pov = qs('.overlay-profiles'); const pclose = qs('.prof-close'); const psave = qs('.prof-save'); if (psave) psave.addEventListener('click', () => { this._profilesSave(); }); const _maybeCloseProfiles = () => { try { if (this._profilesDirty) { this._openConfirmCloseProfiles(); return false; } } catch {} this._closeProfilesEditor(); return true; }; if (pclose) pclose.addEventListener('click', _maybeCloseProfiles); if (pov) pov.addEventListener('click', (e) => { // Close profiles overlay when clicking outside the modal (with guard) if (e.target === pov) { e.preventDefault(); _maybeCloseProfiles(); } }); // Holidays overlay events const hov = qs('.overlay-holiday'); const hsave = qs('.holiday-save'); const hclose = qs('.holiday-close'); if (hsave) hsave.addEventListener('click', () => this._holidaySave()); if (hclose) hclose.addEventListener('click', () => { this._closeHolidayEditor(); this._render(); }); if (hov) hov.addEventListener('click', (e)=>{ if (e.target === hov) { e.preventDefault(); this._closeHolidayEditor(); } }); // Holiday copy popup overlay events const hcov = qs('.overlay-hcopy'); if (hcov) hcov.addEventListener('click', (e)=>{ if (e.target === hcov) { e.preventDefault(); this._closeHolidayCopy(); } }); // Onboarding controls try { const next = qs('.onb-next'); const prev = qs('.onb-prev'); const skip = qs('.onb-skip'); const done = qs('.onb-done'); const ov = qs('.overlay-onboard'); next && (next.onclick = ()=>{ this._onboardStep = Math.min(this._onboardSlides.length-1, this._onboardStep+1); this._renderOnboard(); }); prev && (prev.onclick = ()=>{ this._onboardStep = Math.max(0, this._onboardStep-1); this._renderOnboard(); }); skip && (skip.onclick = ()=>{ this._closeOnboard(true); }); done && (done.onclick = ()=>{ this._closeOnboard(true); }); ov && ov.addEventListener('click', (e)=>{ if (e.target === ov) { e.preventDefault(); e.stopPropagation(); } }); } catch {} // Test popup events try { const tov = qs('.overlay-test'); const tclose = qs('.test-close'); const texport = qs('.test-export'); if (tclose) tclose.onclick = ()=>{ try { tov?.classList.remove('open'); } catch {} }; if (texport) texport.onclick = ()=>{ try { this._exportTestCommands(); } catch {} }; if (tov) tov.onclick = (e)=>{ if (e.target === tov) { e.preventDefault(); e.stopPropagation(); } }; } catch {} // Settings popup events try { const sov = qs('.overlay-settings'); const sclose = qs('.settings-close'); const ssave = qs('.settings-save'); const stabs = qs('.settings-tabs'); if (sclose) sclose.onclick = ()=>{ try { this._closeSettingsPopup(false); } catch {} }; if (ssave) ssave.onclick = ()=>{ try { this._saveSettingsPopup(); } catch {} }; if (stabs) stabs.onclick = (ev)=>{ try { // In Shadow DOM, the click target can be a Text node inside the button. // Use composedPath() to reliably find the originating button. const path = typeof ev.composedPath === 'function' ? ev.composedPath() : []; let btn = null; for (const n of path) { if (n && n.classList && n.classList.contains('settings-tab-btn')) { btn = n; break; } } if (!btn) btn = (ev.target && ev.target.closest) ? ev.target.closest('.settings-tab-btn') : null; if (!btn) return; const tab = btn.getAttribute('data-tab') || 'settings'; this._setSettingsPopupTab(tab); } catch {} }; if (sov) sov.onclick = (e)=>{ if (e.target === sov) { e.preventDefault(); e.stopPropagation(); } }; // Prevent scroll wheel from accidentally changing number inputs (which triggers "unsaved" state) try { const scroller = qs('.overlay-settings .settings-body') || sov; const onWheel = (ev)=>{ try { const t = ev?.target; if (!t || t.tagName !== 'INPUT' || t.type !== 'number') return; // Stop number increment/decrement and treat it as scroll try { t.blur?.(); } catch {} ev.preventDefault(); const s = t.closest('.settings-body') || scroller; if (s && typeof s.scrollTop === 'number') s.scrollTop += (Number(ev.deltaY) || 0); } catch {} }; try { if (this._settingsPopupWheelHandler?.el && this._settingsPopupWheelHandler?.fn) { this._settingsPopupWheelHandler.el.removeEventListener('wheel', this._settingsPopupWheelHandler.fn); } } catch {} if (scroller) { scroller.addEventListener('wheel', onWheel, { passive: false }); this._settingsPopupWheelHandler = { el: scroller, fn: onWheel }; } } catch {} // Settings tab controls (draft-only) const onNum = (key) => (e)=>{ try { if (!this._settingsDraft) return; const v = e && e.target ? e.target.value : ''; if (v === '') return; const n = Number(v); if (!Number.isFinite(n)) return; this._settingsDraft[key] = n; } catch {} }; const onTempNum = (key) => (e)=>{ try { if (!this._settingsDraft) return; const v = e && e.target ? e.target.value : ''; if (v === '') return; const raw = String(v).trim().replace(',', '.'); const nDisp = Number(raw); if (!Number.isFinite(nDisp)) return; // Convert based on draft unit (config unit may not match while popup is open) const wantF = String(this._settingsDraft?.temp_unit || 'C').toUpperCase() === 'F'; const nC = wantF ? ((nDisp - 32) * 5/9) : nDisp; if (!Number.isFinite(nC)) return; this._settingsDraft[key] = nC; } catch {} }; const onSw = (key) => (e)=>{ try { if (!this._settingsDraft) return; const checked = !!(e && e.target && e.target.checked); this._settingsDraft[key] = checked; } catch {} }; const spDef = qs('.overlay-settings .sp-def'); const spRowH = qs('.overlay-settings .sp-rowh'); const spMinC = qs('.overlay-settings .sp-minc'); const spMaxC = qs('.overlay-settings .sp-maxc'); if (spDef) spDef.oninput = onTempNum('default_temp'); if (spRowH) spRowH.oninput = onNum('row_height'); if (spMinC) spMinC.oninput = onTempNum('min_temp'); if (spMaxC) spMaxC.oninput = onTempNum('max_temp'); const swAuto = qs('.overlay-settings .sp-auto'); const swApplyEdit = qs('.overlay-settings .sp-applyedit'); const swApplyDef = qs('.overlay-settings .sp-applydef'); const swPerRoom = qs('.overlay-settings .sp-perroom'); const swShowRoomTemp = qs('.overlay-settings .sp-showroomtemp'); if (swAuto) swAuto.onchange = onSw('auto_apply'); if (swApplyEdit) swApplyEdit.onchange = onSw('apply_on_edit'); if (swApplyDef) swApplyDef.onchange = onSw('apply_on_default_change'); if (swPerRoom) swPerRoom.onchange = onSw('per_room_defaults'); if (swShowRoomTemp) swShowRoomTemp.onchange = onSw('show_room_temp'); // Segmented controls + profiles enable const fmt24 = qs('.overlay-settings .sp-fmt24'); const fmt12 = qs('.overlay-settings .sp-fmt12'); const srcB = qs('.overlay-settings .sp-srcBrowser'); const srcH = qs('.overlay-settings .sp-srcHa'); const unitC = qs('.overlay-settings .sp-unitC'); const unitF = qs('.overlay-settings .sp-unitF'); const swProfiles = qs('.overlay-settings .sp-profiles'); const swSeasonal = qs('.overlay-settings .sp-seasonal'); const swInstance = qs('.overlay-settings .sp-instance-enable'); const btnRegenerateInstance = qs('.overlay-settings .sp-instance-regenerate-btn'); if (fmt24) fmt24.onclick = ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.time_12h = false; this._renderSettingsPopupSettingsTab(); } catch {} }; if (fmt12) fmt12.onclick = ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.time_12h = true; this._renderSettingsPopupSettingsTab(); } catch {} }; if (srcB) srcB.onclick = ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.time_source = 'browser'; this._renderSettingsPopupSettingsTab(); } catch {} }; if (srcH) srcH.onclick = ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.time_source = 'ha'; this._renderSettingsPopupSettingsTab(); } catch {} }; if (unitC) unitC.onclick = ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.temp_unit = 'C'; this._renderSettingsPopupTab(); } catch {} }; if (unitF) unitF.onclick = ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.temp_unit = 'F'; this._renderSettingsPopupTab(); } catch {} }; if (swProfiles) swProfiles.onchange = onSw('profiles_enabled'); if (swSeasonal) swSeasonal.onchange = onSw('seasonal_enabled'); // Configuration ID (instance) toggle (persists outside of the popup draft) if (swInstance) { swInstance.onchange = async (e)=>{ try { const on = !!e?.target?.checked; if (!on) { const oldId = this._normInstanceId(String(this._config?.instance_id || '').trim() || 'default'); const oldKey = (this._config?.instance_enabled) ? `thermostat_timeline_store:${oldId}` : 'thermostat_timeline_store'; // Best-effort migration: copy current instance back into default + activate it try { if (this._config?.storage_enabled && this._hass?.callApi) { const api = await this._hass.callApi('GET', `thermostat_timeline/state?instance_id=${encodeURIComponent(oldId)}`); const payload = { force: true, instance_id: 'default', activate: true }; if (api?.schedules) payload.schedules = api.schedules; if (api?.settings) payload.settings = api.settings; if (api?.colors) payload.colors = api.colors; if (api?.weekdays) payload.weekdays = api.weekdays; if (api?.profiles) payload.profiles = api.profiles; await this._hass.callService('thermostat_timeline','set_store', payload); } else { const dstKey = 'thermostat_timeline_store'; const src = localStorage.getItem(oldKey) || ''; const dst = localStorage.getItem(dstKey) || ''; if (src && (!dst || dst.length < 3)) { try { localStorage.setItem(dstKey, src); } catch {} } } } catch {} try { this._config.instance_enabled = false; this._config.instance_id = ''; } catch {} try { this._persistInstanceCfg(false, String(this._config?.instance_id||'')); } catch {} try { this._renderSettingsPopupSettingsTab(); } catch {} try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} return; } let cur = String(this._config?.instance_id || '').trim(); if (!cur) cur = this._genInstanceId(); cur = this._normInstanceId(cur); try { this._config.instance_enabled = true; this._config.instance_id = cur; } catch {} try { this._persistInstanceCfg(true, cur); } catch {} // Best-effort migration: copy legacy store into this instance try { if (this._config?.storage_enabled && this._hass?.callApi) { const api = await this._hass.callApi('GET', 'thermostat_timeline/state'); const payload = { force: true, instance_id: cur, activate: false }; if (api?.schedules) payload.schedules = api.schedules; if (api?.settings) payload.settings = api.settings; if (api?.colors) payload.colors = api.colors; if (api?.weekdays) payload.weekdays = api.weekdays; if (api?.profiles) payload.profiles = api.profiles; await this._hass.callService('thermostat_timeline','set_store', payload); } else { const srcKey = 'thermostat_timeline_store'; const dstKey = this._localStoreKey(); const src = localStorage.getItem(srcKey) || ''; const dst = localStorage.getItem(dstKey) || ''; if (src && (!dst || dst.length < 3)) { try { localStorage.setItem(dstKey, src); } catch {} } } } catch {} try { this._renderSettingsPopupSettingsTab(); } catch {} try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} } catch {} }; } // Regenerate Configuration ID (instance_id) - renames/moves data to a new id (no duplicate instance) if (btnRegenerateInstance) { btnRegenerateInstance.onclick = async ()=>{ try { if (!this._config?.instance_enabled) return; const oldId = this._normInstanceId(this._config?.instance_id || ''); if (!oldId) return; const newId = this._genInstanceId(); if (!newId || newId === oldId) return; // Rename/move old -> new (avoids creating an extra instance/section) const renamed = await this._renameInstanceData(oldId, newId); if (!renamed) return; // Switch this card to the new id + persist outside YAML try { this._config.instance_id = newId; } catch {} try { this._persistInstanceCfg(true, newId); } catch {} try { this._renderSettingsPopupSettingsTab(); } catch {} try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} } catch {} }; } } catch {} // Weekdays copy modal events try { const wov = qs('.overlay-weekcopy'); const applyBtn = qs('.weekcopy-apply'); const skipBtn = qs('.weekcopy-skip'); if (applyBtn) applyBtn.onclick = ()=>{ try { this._applyWeekCopyModal(true); } catch {} }; if (skipBtn) skipBtn.onclick = ()=>{ try { this._applyWeekCopyModal(false); } catch {} }; if (wov) wov.onclick = (e)=>{ if (e.target === wov) { e.preventDefault(); this._applyWeekCopyModal(false); } }; } catch {} } // --- Settings popup (in-card, not HA editor) --- _openSettingsPopup(){ try { if (this._disablePopups) return; const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings'); if (!ov) return; ov.classList.add('open'); try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} try { if (!this._settingsPopupTab) this._settingsPopupTab = 'settings'; } catch {} try { this._initSettingsPopupDraft(); } catch {} try { this._applyCardI18n(); } catch {} try { this._renderSettingsPopupTabs(); } catch {} try { this._renderSettingsPopupTab(); } catch {} // Post-open refresh for Configuration ID toggle from local cache try { const refreshInstance = ()=>{ try { this._hydrateInstanceCfgFromLocal(); this._renderSettingsPopupSettingsTab(); } catch {} }; setTimeout(refreshInstance, 0); setTimeout(refreshInstance, 300); } catch {} try { const onKey = (ev) => { if (ev.key === 'Escape') { this._closeSettingsPopup(false); } }; try { if (this._settingsPopupKeyHandler) window.removeEventListener('keydown', this._settingsPopupKeyHandler); } catch {} this._settingsPopupKeyHandler = onKey; window.addEventListener('keydown', onKey); } catch {} } catch {} } _setSettingsPopupTab(tab){ try { const allowed = new Set(['settings','pause','weekdays','rooms','boiler','colors','presence_sensor','owd','holidays','away','reset']); const next = allowed.has(String(tab)) ? String(tab) : 'settings'; this._settingsPopupTab = next; this._renderSettingsPopupTabs(); this._renderSettingsPopupTab(); } catch {} } _renderSettingsPopupTabs(){ try { const active = String(this._settingsPopupTab || 'settings'); const btns = this.shadowRoot ? Array.from(this.shadowRoot.querySelectorAll('.overlay-settings .settings-tab-btn')) : []; for (const b of btns){ const isActive = (b.getAttribute('data-tab') === active); try { b.classList.toggle('active', isActive); } catch {} try { b.setAttribute('aria-selected', isActive ? 'true' : 'false'); } catch {} } } catch {} } _openWeekCopyModal(){ try { if (this._disablePopups) return; const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-weekcopy'); if (!ov) return; ov.classList.add('open'); try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} try { this._renderWeekCopyModal(); } catch {} } catch {} } _closeWeekCopyModal(){ try { const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-weekcopy'); if (ov) ov.classList.remove('open'); try { const settingsOpen = !!this.shadowRoot?.querySelector('.overlay-settings')?.classList.contains('open'); if (!settingsOpen) this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} } catch {} } _renderWeekCopyModal(){ try { const title = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-title'); const desc = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-desc'); const modeLabel = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-mode-label'); const sel = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-mode'); const targetLabel = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-target-label'); const targetSel = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-target'); const applyBtn = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-apply'); const skipBtn = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-skip'); if (title) title.textContent = this._t('week.copy_from_main.title') || 'Copy main schedule'; if (desc) desc.textContent = this._t('week.copy_from_main.desc') || ''; if (modeLabel) modeLabel.textContent = this._t('week.copy_from_main.mode_label') || (this._t('week.mode') || 'Weekday mode'); if (targetLabel) targetLabel.textContent = this._t('week.copy_from_main.target_label') || 'Copy to'; if (applyBtn) applyBtn.textContent = this._t('week.copy_from_main.copy_btn') || (this._t('ui.copy_plan') || 'Copy'); if (skipBtn) skipBtn.textContent = this._t('week.copy_from_main.skip_btn') || (this._t('ui.cancel') || 'Skip'); const renderTargets = (mode, preferred='')=>{ if (!targetSel) return; const m = String(mode || 'weekday_weekend'); const opts = []; if (m === 'weekday_weekend') { opts.push({ v: 'weekdays', l: this._t('week.group.weekdays') || 'Weekdays' }); opts.push({ v: 'weekend', l: this._t('week.group.weekend') || 'Weekend' }); } else if (m === 'weekday_sat_sun') { opts.push({ v: 'weekdays', l: this._t('week.group.weekdays') || 'Weekdays' }); opts.push({ v: 'sat', l: this._t('week.days_full.sat') || 'Saturday' }); opts.push({ v: 'sun', l: this._t('week.days_full.sun') || 'Sunday' }); } else { opts.push({ v: 'mon', l: this._t('week.days_full.mon') || 'Monday' }); opts.push({ v: 'tue', l: this._t('week.days_full.tue') || 'Tuesday' }); opts.push({ v: 'wed', l: this._t('week.days_full.wed') || 'Wednesday' }); opts.push({ v: 'thu', l: this._t('week.days_full.thu') || 'Thursday' }); opts.push({ v: 'fri', l: this._t('week.days_full.fri') || 'Friday' }); opts.push({ v: 'sat', l: this._t('week.days_full.sat') || 'Saturday' }); opts.push({ v: 'sun', l: this._t('week.days_full.sun') || 'Sunday' }); } targetSel.innerHTML = ''; for (const o of opts) { const opt = document.createElement('option'); opt.value = o.v; opt.textContent = o.l; targetSel.append(opt); } const allowed = new Set(opts.map(o=>o.v)); let pick = String(preferred || '').toLowerCase(); if (!allowed.has(pick)) { if (allowed.has('weekdays')) pick = 'weekdays'; else pick = opts[0]?.v || ''; } if (pick) targetSel.value = pick; }; if (sel) { const opt1 = sel.querySelector('.opt-weekday-weekend'); const opt2 = sel.querySelector('.opt-weekday-sat-sun'); const opt3 = sel.querySelector('.opt-all-7'); if (opt1) opt1.textContent = this._t('week.mode.weekday_weekend') || 'Weekdays + Weekend'; if (opt2) opt2.textContent = this._t('week.mode.weekday_sat_sun') || 'Weekdays + Saturday + Sunday'; if (opt3) opt3.textContent = this._t('week.mode.all_7') || 'All days'; const defMode = String(this._config?.weekdays_mode || 'weekday_weekend'); sel.value = (defMode === 'weekday_sat_sun' || defMode === 'all_7') ? defMode : 'weekday_weekend'; try { renderTargets(sel.value, this._settingsDraft?.weekdays_copy_from_main_target || ''); } catch {} sel.onchange = ()=>{ try { renderTargets(sel.value, targetSel?.value || ''); } catch {} }; } else { try { renderTargets('weekday_weekend', this._settingsDraft?.weekdays_copy_from_main_target || ''); } catch {} } } catch {} } _applyWeekCopyModal(doCopy){ try { if (this._settingsDraft) { if (doCopy) { const sel = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-mode'); const tsel = this.shadowRoot && this.shadowRoot.querySelector('.weekcopy-target'); const v = String(sel?.value || 'weekday_weekend'); this._settingsDraft.weekdays_copy_from_main_mode = (v === 'weekday_sat_sun' || v === 'all_7') ? v : 'weekday_weekend'; this._settingsDraft.weekdays_copy_from_main_target = String(tsel?.value || '').toLowerCase(); } else { this._settingsDraft.weekdays_copy_from_main_mode = ''; this._settingsDraft.weekdays_copy_from_main_target = ''; } } this._closeWeekCopyModal(); try { this._renderSettingsPopupWeekdaysTab(); } catch {} } catch { try { this._closeWeekCopyModal(); } catch {} } } _renderSettingsPopupTab(){ try { const active = String(this._settingsPopupTab || 'settings'); const pages = this.shadowRoot ? Array.from(this.shadowRoot.querySelectorAll('.overlay-settings .settings-page')) : []; for (const p of pages){ const isActive = (p.getAttribute('data-page') === active); try { p.classList.toggle('active', isActive); } catch {} // Some tab renderers apply inline display styles (e.g. grid). Clear those on inactive tabs // so CSS `.settings-page{display:none}` can hide them properly. if (!isActive) { try { p.style.display = ''; } catch {} } } if (active === 'settings') { this._renderSettingsPopupSettingsTab(); } else if (active === 'pause') { this._renderSettingsPopupPauseTab(); } else if (active === 'weekdays') { this._renderSettingsPopupWeekdaysTab(); } else if (active === 'rooms') { this._renderSettingsPopupRoomsTab(); } else if (active === 'boiler') { this._renderSettingsPopupBoilerTab(); } else if (active === 'colors') { this._renderSettingsPopupColorsTab(); } else if (active === 'presence_sensor') { this._renderSettingsPopupPresenceSensorTab(); } else if (active === 'owd') { this._renderSettingsPopupOwdTab(); } else if (active === 'holidays') { this._renderSettingsPopupHolidaysTab(); } else if (active === 'away') { this._renderSettingsPopupAwayTab(); } else if (active === 'reset') { this._renderSettingsPopupResetTab(); } // Some pages (like Weekdays) are built dynamically; re-apply i18n after rendering. try { this._applyCardI18n(); } catch {} } catch {} } _initSettingsPopupDraft(){ try { try { this._hydrateInstanceCfgFromLocal(); } catch {} // Start with current config const cfg = { ...(this._config || {}) }; // On initial open after a hard refresh, backend settings may not be loaded yet. // Prefer last localStorage copy for boiler settings so the toggle reflects saved state. try { const raw = localStorage.getItem(this._localStoreKey()) || ''; if (raw) { const parsed = JSON.parse(raw); const ls = parsed?.settings || {}; const has = (obj,k)=> Object.prototype.hasOwnProperty.call(obj||{}, k); if (!this._yamlProvided?.presence_live_header && has(ls, 'presence_live_header')) cfg.presence_live_header = !!ls.presence_live_header; if (!this._yamlProvided?.boiler_enabled && has(ls, 'boiler_enabled')) cfg.boiler_enabled = !!ls.boiler_enabled; if (!this._yamlProvided?.boiler_switch && has(ls, 'boiler_switch')) cfg.boiler_switch = String(ls.boiler_switch || ''); if (!this._yamlProvided?.boiler_rooms && has(ls, 'boiler_rooms')) cfg.boiler_rooms = Array.isArray(ls.boiler_rooms) ? ls.boiler_rooms.filter(Boolean).map(String) : (ls.boiler_rooms === null ? null : cfg.boiler_rooms); if (!this._yamlProvided?.boiler_on_offset && has(ls, 'boiler_on_offset')) { const v = (ls.boiler_on_offset === null) ? 0 : Number(ls.boiler_on_offset); if (Number.isFinite(v)) cfg.boiler_on_offset = Math.max(-10, Math.min(10, v)); } if (!this._yamlProvided?.boiler_off_offset && has(ls, 'boiler_off_offset')) { const v = (ls.boiler_off_offset === null) ? 0 : Number(ls.boiler_off_offset); if (Number.isFinite(v)) cfg.boiler_off_offset = Math.max(-10, Math.min(10, v)); } if (!this._yamlProvided?.boiler_temp_sensor && has(ls, 'boiler_temp_sensor')) cfg.boiler_temp_sensor = String(ls.boiler_temp_sensor || ''); if (!this._yamlProvided?.boiler_min_temp && has(ls, 'boiler_min_temp')) { const v = (ls.boiler_min_temp === null) ? 20 : Number(ls.boiler_min_temp); if (Number.isFinite(v)) cfg.boiler_min_temp = v; } if (!this._yamlProvided?.boiler_max_temp && has(ls, 'boiler_max_temp')) { const v = (ls.boiler_max_temp === null) ? 25 : Number(ls.boiler_max_temp); if (Number.isFinite(v)) cfg.boiler_max_temp = v; } } } catch {} const ow = (cfg.open_window && typeof cfg.open_window === 'object') ? cfg.open_window : {}; const owEnabled = (typeof ow.enabled === 'boolean') ? ow.enabled : false; const owSensors = (ow.sensors && typeof ow.sensors === 'object') ? ow.sensors : {}; const owOpenDelay = Number.isFinite(Number(ow.open_delay_min)) ? Math.max(0, Math.min(1440, Math.round(Number(ow.open_delay_min)))) : 2; const owCloseDelay = Number.isFinite(Number(ow.close_delay_min)) ? Math.max(0, Math.min(1440, Math.round(Number(ow.close_delay_min)))) : 5; let owSensorsClone = {}; try { for (const k of Object.keys(owSensors||{})) { const arr = Array.isArray(owSensors[k]) ? owSensors[k].filter(Boolean).map(String) : []; if (arr.length) owSensorsClone[String(k)] = Array.from(new Set(arr)); } } catch {} // Holidays let holDates = []; try { holDates = Array.isArray(cfg.holidays_dates) ? cfg.holidays_dates.filter(Boolean).map(String) : []; holDates = Array.from(new Set(holDates)).sort(); } catch {} // Away const awayCfg = (cfg.away && typeof cfg.away === 'object') ? cfg.away : {}; const awayPersons = Array.isArray(awayCfg.persons) ? awayCfg.persons.filter(Boolean).map(String) : []; const awayCombos = (awayCfg.combos && typeof awayCfg.combos === 'object') ? awayCfg.combos : {}; const awayCombosClone = (()=>{ try { const out = {}; for (const k of Object.keys(awayCombos||{})) { const v = awayCombos[k]; if (v && typeof v === 'object' && v.enabled) out[String(k)] = { enabled: true }; } return out; } catch { return {}; } })(); this._settingsDraft = { // Rooms (edited in popup, persisted via store) entities: (()=>{ try { return Array.isArray(cfg.entities) ? cfg.entities.map(String) : []; } catch { return []; } })(), room_use_input_number: (()=>{ try { const ents = Array.isArray(cfg.entities) ? cfg.entities.map(String) : []; const modes = Array.isArray(cfg.room_use_input_number) ? cfg.room_use_input_number.slice() : []; while (modes.length < ents.length) modes.push(false); return modes.map(v => !!v).slice(0, ents.length); } catch { return []; } })(), room_use_temp_sensor: (()=>{ try { const ents = Array.isArray(cfg.entities) ? cfg.entities.map(String) : []; const modes = Array.isArray(cfg.room_use_temp_sensor) ? cfg.room_use_temp_sensor.slice() : []; while (modes.length < ents.length) modes.push(false); return modes.map(v => !!v).slice(0, ents.length); } catch { return []; } })(), labels: (()=>{ try { return (cfg.labels && typeof cfg.labels === 'object') ? JSON.parse(JSON.stringify(cfg.labels)) : {}; } catch { return {}; } })(), merges: (()=>{ try { const src = (cfg.merges && typeof cfg.merges === 'object') ? cfg.merges : {}; const out = {}; for (const k of Object.keys(src||{})) { const arr = Array.isArray(src[k]) ? src[k].filter(Boolean).map(String) : []; if (arr.length) out[String(k)] = Array.from(new Set(arr)); } return out; } catch { return {}; } })(), temp_sensors: (()=>{ try { return (cfg.temp_sensors && typeof cfg.temp_sensors === 'object') ? JSON.parse(JSON.stringify(cfg.temp_sensors)) : {}; } catch { return {}; } })(), turn_on: (()=>{ try { return (cfg.turn_on && typeof cfg.turn_on === 'object') ? JSON.parse(JSON.stringify(cfg.turn_on)) : {}; } catch { return {}; } })(), default_temp: this._ttCoerceNum(cfg.default_temp, 20), row_height: this._ttCoerceNum(cfg.row_height, 64), min_temp: this._ttCoerceNum(cfg.min_temp, 5), max_temp: this._ttCoerceNum(cfg.max_temp, 25), auto_apply: !!cfg.auto_apply, apply_on_edit: !!cfg.apply_on_edit, apply_on_default_change: !!cfg.apply_on_default_change, per_room_defaults: !!cfg.per_room_defaults, show_pause_button: cfg.show_pause_button !== false, show_room_temp: cfg.show_room_temp !== false, pause_sensor_enabled: !!cfg.pause_sensor_enabled, pause_sensor_entity: String(cfg.pause_sensor_entity || ''), time_12h: !!cfg.time_12h, time_source: (cfg.time_source === 'ha') ? 'ha' : 'browser', temp_unit: (String(cfg.temp_unit || 'C').toUpperCase() === 'F') ? 'F' : 'C', profiles_enabled: !!cfg.profiles_enabled, presence_sensor_enabled: !!cfg.presence_sensor_enabled, presence_sensors: (()=>{ try { return (cfg.presence_sensors && typeof cfg.presence_sensors === 'object') ? JSON.parse(JSON.stringify(cfg.presence_sensors)) : {}; } catch { return {}; } })(), presence_sensor_temps: (()=>{ try { return (cfg.presence_sensor_temps && typeof cfg.presence_sensor_temps === 'object') ? JSON.parse(JSON.stringify(cfg.presence_sensor_temps)) : {}; } catch { return {}; } })(), presence_sensor_delays: (()=>{ try { return (cfg.presence_sensor_delays && typeof cfg.presence_sensor_delays === 'object') ? JSON.parse(JSON.stringify(cfg.presence_sensor_delays)) : {}; } catch { return {}; } })(), presence_sensor_delay_units: (()=>{ try { return (cfg.presence_sensor_delay_units && typeof cfg.presence_sensor_delay_units === 'object') ? JSON.parse(JSON.stringify(cfg.presence_sensor_delay_units)) : {}; } catch { return {}; } })(), weekdays_enabled: !!cfg.weekdays_enabled, weekdays_copy_from_main_mode: '', weekdays_copy_from_main_target: '', seasonal_enabled: !!cfg.seasonal_enabled, seasonal_mode: (String(cfg.seasonal_mode || 'winter') === 'summer') ? 'summer' : 'winter', weekdays_view: String(cfg.weekdays_view || 'all_rooms_one_day'), weekdays_view_switch_in_timeline: !!(cfg.weekdays_view_switch_in_timeline ?? false), weekdays_selected_room: String(cfg.weekdays_selected_room || ''), boiler_enabled: !!cfg.boiler_enabled, boiler_switch: String(cfg.boiler_switch || ''), boiler_switch_domain: (()=>{ try { const sw = String(cfg.boiler_switch || ''); const fromEid = (()=>{ const dom = String(sw||'').split('.')[0]; return (dom === 'input_boolean') ? 'input_boolean' : 'switch'; })(); const raw = String((cfg.boiler_switch_domain ?? fromEid) || '').toLowerCase().trim(); return (raw === 'input_boolean') ? 'input_boolean' : 'switch'; } catch { return 'switch'; } })(), boiler_rooms: (()=>{ try { const ents = Array.isArray(cfg.entities) ? cfg.entities.map(String) : []; const climateRooms = ents.filter(e=>String(e).startsWith('climate.')); const raw = cfg.boiler_rooms; if (Array.isArray(raw)) { const want = new Set(raw.filter(Boolean).map(String)); return climateRooms.filter(eid => want.has(eid)); } return climateRooms; } catch { return []; } })(), boiler_on_offset: (()=>{ try { const v = Number(cfg.boiler_on_offset); return Number.isFinite(v) ? v : 0; } catch { return 0; } })(), boiler_off_offset: (()=>{ try { const v = Number(cfg.boiler_off_offset); return Number.isFinite(v) ? v : 0; } catch { return 0; } })(), boiler_temp_sensor: String(cfg.boiler_temp_sensor || ''), boiler_min_temp: this._ttCoerceNum(cfg.boiler_min_temp, 20), boiler_max_temp: this._ttCoerceNum(cfg.boiler_max_temp, 25), boiler_multi_enabled: !!cfg.boiler_multi_enabled, boiler_room_settings: (()=>{ try { return (cfg.boiler_room_settings && typeof cfg.boiler_room_settings === 'object') ? JSON.parse(JSON.stringify(cfg.boiler_room_settings)) : {}; } catch { return {}; } })(), color_ranges: (()=>{ try { const src = (cfg.color_ranges && typeof cfg.color_ranges === 'object') ? cfg.color_ranges : {}; const out = {}; for (const k of Object.keys(src||{})) { const arr = Array.isArray(src[k]) ? src[k] : []; out[String(k)] = arr.map(r => ({ from: Number.isFinite(Number(r?.from)) ? Number(r.from) : (this._ttCoerceNum(cfg.default_temp, 20)), to: Number.isFinite(Number(r?.to)) ? Number(r.to) : (this._ttCoerceNum(cfg.default_temp, 20)), color: String(r?.color || '#ffb347') })); } return out; } catch { return {}; } })(), color_global: !!cfg.color_global, open_window: { enabled: !!owEnabled, sensors: owSensorsClone, open_delay_min: owOpenDelay, close_delay_min: owCloseDelay, }, holidays_enabled: !!cfg.holidays_enabled, holidays_source: (cfg.holidays_source === 'manual') ? 'manual' : 'calendar', holidays_entity: String(cfg.holidays_entity || ''), holidays_dates: holDates, holidays_groups: (()=>{ try { return Array.isArray(cfg.holidays_groups) ? JSON.parse(JSON.stringify(cfg.holidays_groups)) : []; } catch { return []; } })(), away: { enabled: !!awayCfg.enabled, target_c: this._ttCoerceNum(awayCfg.target_c, 17), persons: Array.from(new Set(awayPersons)), advanced_enabled: !!awayCfg.advanced_enabled, combos: awayCombosClone, }, presence_live_header: !!(cfg.presence_live_header ?? true), // Away delay (defaults) away_delay_enabled: !!(awayCfg.delay_enabled ?? false), away_delay_value: Number.isFinite(Number(awayCfg.delay_value)) ? Number(awayCfg.delay_value) : 0, away_delay_unit: String(awayCfg.delay_unit || 'minutes'), }; } catch { this._settingsDraft = null; } } _applySettingsPopupDraftToConfig(){ try { const d = this._settingsDraft; if (!d) return; const cfg = { ...(this._config || {}) }; const prevSeasonalEnabled = !!this._config?.seasonal_enabled; const prevSeasonalMode = (String(this._config?.seasonal_mode || 'winter') === 'summer') ? 'summer' : 'winter'; const prevWeekEnabled = !!this._config?.weekdays_enabled; // Rooms settings (entities + per-room mode + merges/labels) try { const rawEnts = Array.isArray(d.entities) ? d.entities.map(x => String(x||'').trim()) : []; const ents = rawEnts.filter(Boolean); const rawModes = Array.isArray(d.room_use_input_number) ? d.room_use_input_number.slice() : []; while (rawModes.length < rawEnts.length) rawModes.push(false); const modes = ents.map((eid, idx) => { // If the entity domain is explicit, prefer it to avoid inconsistent mode state if (String(eid).startsWith('input_number.')) return true; if (String(eid).startsWith('climate.')) return false; // Otherwise, take draft mode return !!rawModes[idx]; }); cfg.entities = ents; cfg.room_use_input_number = modes; // Per-room: use temperature sensor for room temp try { const rawTemp = Array.isArray(d.room_use_temp_sensor) ? d.room_use_temp_sensor.slice() : []; while (rawTemp.length < rawEnts.length) rawTemp.push(false); cfg.room_use_temp_sensor = ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return false; if (!String(eid).startsWith('climate.')) return false; return !!rawTemp[idx]; }); } catch { cfg.room_use_temp_sensor = []; } // Labels: keep only for active rooms and only non-empty strings try { const src = (d.labels && typeof d.labels === 'object') ? d.labels : {}; const out = {}; for (const eid of ents) { const v = (src && Object.prototype.hasOwnProperty.call(src, eid)) ? String(src[eid]||'').trim() : ''; if (v) out[eid] = v; } cfg.labels = out; } catch {} // Merges: only keep mappings for active primaries, only climate rooms, and only linked climate.* entities try { const src = (d.merges && typeof d.merges === 'object') ? d.merges : {}; const out = {}; const primSet = new Set(ents); for (let i=0;i String(x).startsWith('climate.') && !primSet.has(String(x))))); if (cleaned.length) out[primary] = cleaned; } cfg.merges = out; } catch {} // Temp sensors: keep only for active rooms try { const src = (d.temp_sensors && typeof d.temp_sensors === 'object') ? d.temp_sensors : {}; const out = {}; for (const eid of ents) { const v = (src && Object.prototype.hasOwnProperty.call(src, eid)) ? String(src[eid]||'').trim() : ''; if (v) out[eid] = v; } cfg.temp_sensors = out; } catch {} // Per-room turn_on behavior: keep only for active climate rooms try { const src = (d.turn_on && typeof d.turn_on === 'object') ? d.turn_on : {}; const out = {}; for (let i=0;i cfg.max_temp) { const tmp = cfg.min_temp; cfg.min_temp = cfg.max_temp; cfg.max_temp = tmp; } cfg.auto_apply = !!d.auto_apply; cfg.apply_on_edit = !!d.apply_on_edit; cfg.apply_on_default_change = !!d.apply_on_default_change; cfg.per_room_defaults = !!d.per_room_defaults; cfg.show_pause_button = !!d.show_pause_button; cfg.show_room_temp = !!d.show_room_temp; cfg.pause_sensor_enabled = !!d.pause_sensor_enabled; cfg.pause_sensor_entity = String(d.pause_sensor_entity || ''); cfg.time_12h = !!d.time_12h; cfg.time_source = (d.time_source === 'ha') ? 'ha' : 'browser'; cfg.temp_unit = (String(d.temp_unit || 'C').toUpperCase() === 'F') ? 'F' : 'C'; cfg.profiles_enabled = !!d.profiles_enabled; cfg.seasonal_enabled = !!d.seasonal_enabled; cfg.seasonal_mode = (String(d.seasonal_mode || prevSeasonalMode || 'winter') === 'summer') ? 'summer' : 'winter'; cfg.presence_sensor_enabled = !!d.presence_sensor_enabled; // Presence sensors: keep only for active rooms + only binary_sensor.* try { const ents = Array.isArray(cfg.entities) ? cfg.entities.filter(Boolean).map(String) : []; const src = (d.presence_sensors && typeof d.presence_sensors === 'object') ? d.presence_sensors : {}; const out = {}; for (const eid of ents) { const v = (src && Object.prototype.hasOwnProperty.call(src, eid)) ? String(src[eid]||'').trim() : ''; if (v && String(v).startsWith('binary_sensor.')) out[eid] = v; } cfg.presence_sensors = out; } catch {} // Presence sensor temps (°C): keep only for active rooms + only numeric try { const ents = Array.isArray(cfg.entities) ? cfg.entities.filter(Boolean).map(String) : []; const src = (d.presence_sensor_temps && typeof d.presence_sensor_temps === 'object') ? d.presence_sensor_temps : {}; const out = {}; const minC = Number(cfg.min_temp); const maxC = Number(cfg.max_temp); for (const eid of ents) { const v0 = (src && Object.prototype.hasOwnProperty.call(src, eid)) ? Number(src[eid]) : NaN; if (!Number.isFinite(v0)) continue; let v = v0; if (Number.isFinite(minC)) v = Math.max(minC, v); if (Number.isFinite(maxC)) v = Math.min(maxC, v); out[eid] = v; } cfg.presence_sensor_temps = out; } catch {} // Presence sensor delays (seconds): keep only for active rooms + only numeric >= 0 try { const ents = Array.isArray(cfg.entities) ? cfg.entities.filter(Boolean).map(String) : []; const src = (d.presence_sensor_delays && typeof d.presence_sensor_delays === 'object') ? d.presence_sensor_delays : {}; const out = {}; for (const eid of ents) { const v = src?.[eid]; if (!v || typeof v !== 'object') continue; const onS = Number(v.on_s); const offS = Number(v.off_s); const obj = {}; if (Number.isFinite(onS) && onS >= 0) obj.on_s = onS; if (Number.isFinite(offS) && offS >= 0) obj.off_s = offS; if (Object.keys(obj).length) out[eid] = obj; } cfg.presence_sensor_delays = out; } catch {} // Presence sensor delay units: keep only for active rooms + only 'minutes'|'seconds' try { const ents = Array.isArray(cfg.entities) ? cfg.entities.filter(Boolean).map(String) : []; const src = (d.presence_sensor_delay_units && typeof d.presence_sensor_delay_units === 'object') ? d.presence_sensor_delay_units : {}; const out = {}; for (const eid of ents) { const u = (src && Object.prototype.hasOwnProperty.call(src, eid)) ? String(src[eid]||'').toLowerCase().trim() : ''; out[eid] = (u === 'seconds') ? 'seconds' : 'minutes'; } cfg.presence_sensor_delay_units = out; } catch {} cfg.weekdays_enabled = !!d.weekdays_enabled; cfg.weekdays_view = (String(d.weekdays_view || 'all_rooms_one_day') === 'one_room_all_days') ? 'one_room_all_days' : 'all_rooms_one_day'; cfg.weekdays_view_switch_in_timeline = !!d.weekdays_view_switch_in_timeline; // If per-room view, ensure we have a selected room try { const ents = Array.isArray(cfg.entities) ? cfg.entities.filter(Boolean) : []; const want = String(d.weekdays_selected_room || ''); if (cfg.weekdays_view === 'one_room_all_days') { cfg.weekdays_selected_room = (want && ents.includes(want)) ? want : (ents[0] || ''); } else { cfg.weekdays_selected_room = ''; } } catch {} // Optional: copy main schedule into weekdays when enabling try { const copyMode = String(d.weekdays_copy_from_main_mode || '').trim(); const copyTarget = String(d.weekdays_copy_from_main_target || '').trim(); if (!prevWeekEnabled && cfg.weekdays_enabled && copyMode) { const m = (copyMode === 'weekday_sat_sun' || copyMode === 'all_7') ? copyMode : 'weekday_weekend'; cfg.weekdays_mode = m; this._copyMainToWeekdays(m, copyTarget); } } catch {} // Boiler settings cfg.boiler_enabled = !!d.boiler_enabled; cfg.boiler_switch = String(d.boiler_switch || ''); try { const raw = String(d.boiler_switch_domain || '').toLowerCase().trim(); const rawDom = (raw === 'input_boolean') ? 'input_boolean' : 'switch'; const swDom = String(cfg.boiler_switch || '').split('.')[0]; if (cfg.boiler_switch && (swDom === 'switch' || swDom === 'input_boolean')) { cfg.boiler_switch_domain = swDom; } else { cfg.boiler_switch_domain = rawDom; } } catch { cfg.boiler_switch_domain = 'switch'; } try { const ents = Array.isArray(cfg.entities) ? cfg.entities.filter(Boolean).map(String) : []; const climateRooms = ents.filter(e=>String(e).startsWith('climate.')); const wantRaw = Array.isArray(d.boiler_rooms) ? d.boiler_rooms.filter(Boolean).map(String) : climateRooms; const wantSet = new Set(wantRaw); cfg.boiler_rooms = climateRooms.filter(eid => wantSet.has(eid)); } catch { cfg.boiler_rooms = []; } const clampOffset = (n, fallback)=>{ const v = Number(n); if (!Number.isFinite(v)) return fallback; return Math.max(-10, Math.min(10, v)); }; cfg.boiler_on_offset = clampOffset(d.boiler_on_offset, (cfg.boiler_on_offset ?? 0)); cfg.boiler_off_offset = clampOffset(d.boiler_off_offset, (cfg.boiler_off_offset ?? 0)); cfg.boiler_temp_sensor = String(d.boiler_temp_sensor || ''); const clampBoiler = (n, fallback)=>{ const v = Number(n); if (!Number.isFinite(v)) return fallback; return Math.max(0, Math.min(120, v)); }; cfg.boiler_min_temp = clampBoiler(d.boiler_min_temp, cfg.boiler_min_temp); cfg.boiler_max_temp = clampBoiler(d.boiler_max_temp, cfg.boiler_max_temp); // Normalize min/max if user swapped them if (Number.isFinite(Number(cfg.boiler_min_temp)) && Number.isFinite(Number(cfg.boiler_max_temp)) && Number(cfg.boiler_min_temp) > Number(cfg.boiler_max_temp)) { const tmpB = cfg.boiler_min_temp; cfg.boiler_min_temp = cfg.boiler_max_temp; cfg.boiler_max_temp = tmpB; } // Multi-boiler per-room settings cfg.boiler_multi_enabled = !!d.boiler_multi_enabled; try { const ents = Array.isArray(cfg.entities) ? cfg.entities.filter(Boolean).map(String) : []; const climateRooms = ents.filter(e=>String(e).startsWith('climate.')); const src = (d.boiler_room_settings && typeof d.boiler_room_settings === 'object') ? d.boiler_room_settings : {}; const out = {}; const clampOffsetLocal = (n, fallback)=>{ const v = Number(n); if (!Number.isFinite(v)) return fallback; return Math.max(-10, Math.min(10, v)); }; for (const eid of climateRooms) { const raw = src?.[eid]; if (!raw || typeof raw !== 'object') continue; const enabled = !!raw.enabled; const sw = String(raw.switch || '').trim(); const domFromEid = (sw ? String(sw).split('.')[0] : ''); const rawDom = String(raw.switch_domain || '').toLowerCase().trim(); const dom = (domFromEid === 'input_boolean' || domFromEid === 'switch') ? domFromEid : ((rawDom === 'input_boolean') ? 'input_boolean' : 'switch'); const onOff = clampOffsetLocal(raw.on_offset, this._ttCoerceNum(cfg.boiler_on_offset, 0)); const offOff = clampOffsetLocal(raw.off_offset, this._ttCoerceNum(cfg.boiler_off_offset, 0)); const hasAny = enabled || sw || Number.isFinite(Number(raw.on_offset)) || Number.isFinite(Number(raw.off_offset)) || !!raw.switch_domain; if (hasAny) out[eid] = { enabled, switch: sw, switch_domain: dom, on_offset: onOff, off_offset: offOff }; } cfg.boiler_room_settings = out; } catch { cfg.boiler_room_settings = {}; } // Colors settings try { cfg.color_global = !!d.color_global; const src = (d.color_ranges && typeof d.color_ranges === 'object') ? d.color_ranges : {}; const out = {}; const minC = Number.isFinite(Number(cfg.min_temp)) ? Number(cfg.min_temp) : 5; const maxC = Number.isFinite(Number(cfg.max_temp)) ? Number(cfg.max_temp) : 25; const clampC = (n, fallback)=>{ const v = Number(n); if (!Number.isFinite(v)) return fallback; return Math.max(minC, Math.min(maxC, v)); }; for (const k of Object.keys(src||{})) { const arr = Array.isArray(src[k]) ? src[k] : []; const cleaned = []; for (const r of arr) { const def = Number.isFinite(Number(cfg.default_temp)) ? Number(cfg.default_temp) : 20; const from = clampC(r?.from, def); const to = clampC(r?.to, def); const color = String(r?.color || '#ffb347'); cleaned.push({ from, to, color }); } out[String(k)] = cleaned; } cfg.color_ranges = out; } catch {} // Open Window Detection settings try { const ow = (d.open_window && typeof d.open_window === 'object') ? d.open_window : {}; const out = {}; out.enabled = !!ow.enabled; out.open_delay_min = Number.isFinite(Number(ow.open_delay_min)) ? Math.max(0, Math.min(1440, Math.round(Number(ow.open_delay_min)))) : 2; out.close_delay_min = Number.isFinite(Number(ow.close_delay_min)) ? Math.max(0, Math.min(1440, Math.round(Number(ow.close_delay_min)))) : 5; out.sensors = {}; const sMap = (ow.sensors && typeof ow.sensors === 'object') ? ow.sensors : {}; for (const k of Object.keys(sMap||{})) { const arr = Array.isArray(sMap[k]) ? sMap[k].filter(Boolean).map(String) : []; if (arr.length) out.sensors[String(k)] = Array.from(new Set(arr)); } cfg.open_window = out; } catch {} // Holidays settings try { cfg.holidays_enabled = !!d.holidays_enabled; cfg.holidays_source = (d.holidays_source === 'manual') ? 'manual' : 'calendar'; cfg.holidays_entity = String(d.holidays_entity || ''); let dates = []; try { dates = Array.isArray(d.holidays_dates) ? d.holidays_dates.filter(Boolean).map(String) : []; // Keep only YYYY-MM-DD (simple/strict) dates = dates.filter(x => /^\d{4}-\d{2}-\d{2}$/.test(String(x))); dates = Array.from(new Set(dates)).sort(); } catch {} cfg.holidays_dates = dates; try { const groups = Array.isArray(d.holidays_groups) ? d.holidays_groups : []; const cleaned = groups.map(g=>({ id: String(g?.id || ('hg_'+Date.now())), from: String(g?.from||''), to: String(g?.to||''), dates: Array.isArray(g?.dates) ? Array.from(new Set(g.dates.filter(Boolean).map(String))).sort() : [] })); cfg.holidays_groups = cleaned; } catch {} } catch {} // Away settings try { const __prevAway = JSON.stringify(cfg.away || {}); const __prevLive = !!(cfg.presence_live_header ?? true); const src = (d.away && typeof d.away === 'object') ? d.away : {}; const cur = { ...(cfg.away || {}) }; cur.enabled = !!src.enabled; // Clamp target C to min/max from draft (stored in C) const mn = Number.isFinite(Number(d.min_temp)) ? Number(d.min_temp) : (Number.isFinite(Number(cfg.min_temp)) ? Number(cfg.min_temp) : 5); const mx = Number.isFinite(Number(d.max_temp)) ? Number(d.max_temp) : (Number.isFinite(Number(cfg.max_temp)) ? Number(cfg.max_temp) : 25); const tC = Number(src.target_c); const safeTC = Number.isFinite(tC) ? Math.max(mn, Math.min(mx, tC)) : (Number.isFinite(Number(cur.target_c)) ? Number(cur.target_c) : 17); cur.target_c = safeTC; const persons = Array.isArray(src.persons) ? src.persons.filter(Boolean).map(String) : []; cur.persons = Array.from(new Set(persons)); cur.advanced_enabled = !!src.advanced_enabled; // Delay fields from draft try { cur.delay_enabled = !!(d.away_delay_enabled ?? cur.delay_enabled ?? false); const v = Number(d.away_delay_value); cur.delay_value = Number.isFinite(v) ? Math.max(0, Math.round(v)) : (Number.isFinite(cur.delay_value) ? cur.delay_value : 0); const u = String(d.away_delay_unit || cur.delay_unit || 'minutes'); cur.delay_unit = (u === 'seconds') ? 'seconds' : 'minutes'; } catch {} // Normalize combos (only keep enabled entries) const combosSrc = (src.combos && typeof src.combos === 'object') ? src.combos : {}; const combosOut = {}; for (const k of Object.keys(combosSrc||{})) { const v = combosSrc[k]; if (v && typeof v === 'object' && v.enabled) combosOut[String(k)] = { enabled: true }; } cur.combos = combosOut; // If Away is turned OFF, disable advanced mode only. // Keep live-header preference persisted; visibility is gated at runtime. if (!cur.enabled) { cur.advanced_enabled = false; } const live = !!(d.presence_live_header ?? true); cfg.presence_live_header = live; cfg.away = cur; try { const __nextAway = JSON.stringify(cfg.away || {}); const __nextLive = !!(cfg.presence_live_header ?? true); if (__prevAway !== __nextAway || __prevLive !== __nextLive) this._awayTouched = true; } catch {} } catch {} this._config = cfg; try { if (cfg.seasonal_enabled) { if (!prevSeasonalEnabled) { this._prepareSeasonalRowsOnEnable(); } this._applySeasonalModeToRows(true, prevSeasonalMode); } else if (prevSeasonalEnabled) { this._seasonAppliedKey = null; } } catch {} } catch {} } _renderSettingsPopupRoomsTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="rooms"]'); if (!page) return; // Build base markup once if (!page.dataset.built) { page.dataset.built = '1'; page.innerHTML = `
`; } // Ensure draft shape if (!Array.isArray(d.entities)) d.entities = []; if (!Array.isArray(d.room_use_input_number)) d.room_use_input_number = []; while (d.room_use_input_number.length < d.entities.length) d.room_use_input_number.push(false); if (!Array.isArray(d.room_use_temp_sensor)) d.room_use_temp_sensor = []; while (d.room_use_temp_sensor.length < d.entities.length) d.room_use_temp_sensor.push(false); if (!d.labels || typeof d.labels !== 'object') d.labels = {}; if (!d.merges || typeof d.merges !== 'object') d.merges = {}; if (!d.temp_sensors || typeof d.temp_sensors !== 'object') d.temp_sensors = {}; try { if (!this._openPopupRooms) this._openPopupRooms = new Set(); } catch {} const q = (sel)=> this.shadowRoot.querySelector(sel); const list = q('.overlay-settings .sp-rooms-list'); if (!list) return; list.innerHTML = ''; const ents = d.entities.map(x => String(x||'')); const usedPrimary = new Set(ents.filter(Boolean)); const usedLinked = new Set(Object.values(d.merges||{}).flat().filter(Boolean).map(String)); const titleEl = q('.overlay-settings .sp-rooms-title'); if (titleEl) titleEl.textContent = this._t('editor.tabs.rooms') || 'Rooms'; const descEl = q('.overlay-settings .sp-rooms-desc'); if (descEl) descEl.textContent = this._t('editor.select_entities') || 'Select rooms'; const addBtn = q('.overlay-settings .sp-rooms-add'); if (addBtn) addBtn.textContent = this._t('editor.add_entity') || 'Add room'; const getRowKey = (idx)=>{ const eid = ents[idx] || ''; return eid ? String(eid) : `#idx:${idx}`; }; const pretty = (eid)=>{ try { const lbl = d.labels?.[eid]; if (lbl) return String(lbl); } catch {} return this._prettyName(eid); }; const mkFoldout = (idx)=>{ const line = document.createElement('div'); line.className = 'inline'; const summary = document.createElement('div'); summary.className = 'summary'; const sumLeft = document.createElement('div'); sumLeft.className = 'summary-left'; const exp = document.createElement('button'); exp.type = 'button'; exp.className = 'expander'; exp.innerHTML = ''; const txtWrap = document.createElement('div'); txtWrap.className = 'summary-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'summary-title'; const subSpan = document.createElement('span'); subSpan.className = 'summary-sub'; const actions = document.createElement('div'); actions.className = 'actions'; const handle = document.createElement('button'); handle.type = 'button'; handle.className = 'drag-handle'; try { handle.setAttribute('title', this._t('editor.drag_reorder') || 'Drag to reorder'); } catch {} handle.innerHTML = ''; const del = document.createElement('button'); del.type = 'button'; del.className = 'remove-btn'; del.innerHTML = '' + (this._t('editor.remove') || 'Remove') + ''; actions.append(handle, del); txtWrap.append(titleSpan, subSpan); sumLeft.append(exp, txtWrap); summary.append(sumLeft, actions); line.append(summary); const details = document.createElement('div'); details.className = 'details'; line.append(details); // Inline styles (keep consistent with other popup foldouts) try { summary.style.display = 'flex'; summary.style.alignItems = 'center'; summary.style.justifyContent = 'space-between'; summary.style.padding = '6px'; summary.style.border = '1px dashed var(--divider-color)'; summary.style.borderRadius = '8px'; summary.style.background = 'var(--secondary-background-color, transparent)'; sumLeft.style.display = 'flex'; sumLeft.style.alignItems = 'center'; sumLeft.style.gap = '8px'; sumLeft.style.minWidth = '0'; txtWrap.style.display = 'grid'; txtWrap.style.gap = '2px'; txtWrap.style.minWidth = '0'; titleSpan.style.fontWeight = '600'; titleSpan.style.overflow = 'hidden'; titleSpan.style.textOverflow = 'ellipsis'; titleSpan.style.whiteSpace = 'nowrap'; titleSpan.style.maxWidth = '320px'; subSpan.style.fontSize = '.85rem'; subSpan.style.color = 'var(--secondary-text-color)'; subSpan.style.overflow = 'hidden'; subSpan.style.textOverflow = 'ellipsis'; subSpan.style.whiteSpace = 'nowrap'; subSpan.style.maxWidth = '320px'; // Match the Colors foldout expander styling exp.style.width = '28px'; exp.style.height = '28px'; exp.style.borderRadius = '8px'; exp.style.border = '1px solid var(--divider-color)'; exp.style.background = 'var(--card-background-color)'; exp.style.display = 'inline-flex'; exp.style.alignItems = 'center'; exp.style.justifyContent = 'center'; exp.style.cursor = 'pointer'; } catch {} try { details.style.display = 'none'; details.style.gridTemplateColumns = 'minmax(250px, 520px)'; details.style.gap = '8px'; details.style.padding = '8px'; details.style.border = '1px dashed var(--divider-color)'; details.style.borderRadius = '8px'; details.style.background = 'var(--secondary-background-color, transparent)'; } catch {} const rowKey = getRowKey(idx); const applyOpen = (isOpen)=>{ try { if (isOpen) { line.classList.add('open'); details.style.display = 'grid'; exp.style.transform = 'rotate(180deg)'; } else { line.classList.remove('open'); details.style.display = 'none'; exp.style.transform = ''; } } catch {} }; const toggle = (ev)=>{ try { ev?.stopPropagation?.(); } catch {} const nextOpen = !line.classList.contains('open'); applyOpen(nextOpen); try { if (!this._openPopupRooms) this._openPopupRooms = new Set(); if (nextOpen) this._openPopupRooms.add(String(rowKey)); else this._openPopupRooms.delete(String(rowKey)); } catch {} }; exp.addEventListener('click', toggle); summary.style.cursor = 'pointer'; summary.addEventListener('click', (ev)=>{ try { if (ev?.target?.closest?.('button, a, input, select, textarea, tt-entity-picker, ha-entity-picker, ha-switch')) return; } catch {} toggle(ev); }); try { applyOpen(!!this._openPopupRooms?.has(String(rowKey))); } catch {} // Build details content const left = document.createElement('div'); left.style.display = 'grid'; left.style.gap = '6px'; const useInput = (()=>{ try { const eid = String(ents[idx]||''); if (eid.startsWith('input_number.')) return true; if (eid.startsWith('climate.')) return false; return !!d.room_use_input_number?.[idx]; } catch { return false; } })(); const modeRow = document.createElement('div'); modeRow.style.display = 'flex'; modeRow.style.alignItems = 'center'; modeRow.style.justifyContent = 'space-between'; modeRow.style.gap = '10px'; const modeText = document.createElement('div'); modeText.style.display = 'grid'; modeText.style.gap = '2px'; const modeTitle = document.createElement('div'); modeTitle.style.fontWeight = '600'; modeTitle.textContent = this._t('editor.room_mode.title') || 'Use input_number instead of climate'; const modeDesc = document.createElement('div'); modeDesc.style.fontSize = '.85rem'; modeDesc.style.color = 'var(--secondary-text-color)'; modeDesc.textContent = this._t('editor.room_mode.desc') || ''; modeText.append(modeTitle, modeDesc); const modeSwitch = document.createElement('ha-switch'); modeSwitch.className = 'sp-room-mode-switch'; modeSwitch.checked = !!useInput; modeSwitch.addEventListener('change', (e)=>{ try { const on = !!e.target.checked; if (!Array.isArray(this._settingsDraft?.room_use_input_number)) this._settingsDraft.room_use_input_number = []; while (this._settingsDraft.room_use_input_number.length < this._settingsDraft.entities.length) this._settingsDraft.room_use_input_number.push(false); this._settingsDraft.room_use_input_number[idx] = on; // Switching to input_number mode means room temp sensor override is not applicable try { if (!Array.isArray(this._settingsDraft?.room_use_temp_sensor)) this._settingsDraft.room_use_temp_sensor = []; while (this._settingsDraft.room_use_temp_sensor.length < this._settingsDraft.entities.length) this._settingsDraft.room_use_temp_sensor.push(false); if (on) this._settingsDraft.room_use_temp_sensor[idx] = false; } catch {} const oldEid = String(this._settingsDraft.entities[idx]||'').trim(); const mismatch = !!oldEid && ((on && !oldEid.startsWith('input_number.')) || (!on && !oldEid.startsWith('climate.'))); if (mismatch) { // Clean mappings tied to old primary try { if (this._settingsDraft.labels && this._settingsDraft.labels[oldEid]) { const next = { ...(this._settingsDraft.labels||{}) }; delete next[oldEid]; this._settingsDraft.labels = next; this._labelsTouched = true; } } catch {} try { const merges = { ...(this._settingsDraft.merges||{}) }; if (merges[oldEid]) delete merges[oldEid]; for (const k of Object.keys(merges)) { merges[k] = (merges[k] || []).filter(x => String(x) !== String(oldEid)); if (!merges[k].length) delete merges[k]; } this._settingsDraft.merges = merges; } catch {} try { if (this._settingsDraft.temp_sensors && this._settingsDraft.temp_sensors[oldEid]) { const next = { ...(this._settingsDraft.temp_sensors||{}) }; delete next[oldEid]; this._settingsDraft.temp_sensors = next; } } catch {} try { if (this._settingsDraft.turn_on && this._settingsDraft.turn_on[oldEid]) { const next = { ...(this._settingsDraft.turn_on||{}) }; delete next[oldEid]; this._settingsDraft.turn_on = next; } } catch {} try { if (this._settingsDraft.presence_sensors && this._settingsDraft.presence_sensors[oldEid]) { const next = { ...(this._settingsDraft.presence_sensors||{}) }; delete next[oldEid]; this._settingsDraft.presence_sensors = next; } } catch {} this._settingsDraft.entities[idx] = ''; } this._renderSettingsPopupRoomsTab(); } catch {} }); modeRow.append(modeText, modeSwitch); const pick = document.createElement('tt-entity-picker'); try { pick.style.display = 'block'; pick.style.minHeight = '48px'; pick.style.maxWidth = '440px'; } catch {} try { pick.hass = this._hass; } catch {} pick.setAttribute('include-domains', useInput ? '["input_number"]' : '["climate"]'); pick.setAttribute('label', this._t('editor.entity_placeholder') || 'Entity'); try { pick.value = String(ents[idx] || ''); } catch {} pick.entityFilter = (st) => { try { const id = st?.entity_id || st?.entityId || st?.entityID || ''; if (!id) return false; const current = String(ents[idx] || ''); const blocked = new Set([...usedPrimary, ...usedLinked]); return !blocked.has(id) || id === current; } catch { return true; } }; const nameLabel = document.createElement('div'); nameLabel.className = 'label'; nameLabel.textContent = this._t('editor.display_name_optional') || 'Room name (optional)'; const nameInp = document.createElement('input'); nameInp.type = 'text'; nameInp.className = 'label-input tt-input tt-input-lg'; try { nameInp.style.width = '100%'; nameInp.style.maxWidth = '440px'; } catch {} nameInp.setAttribute('placeholder', this._t('editor.display_name_optional') || 'Room name (optional)'); try { const curEid = String(ents[idx] || ''); nameInp.value = (d.labels && curEid && d.labels[curEid]) ? String(d.labels[curEid]) : ''; } catch {} const setDraftLabel = (eid, value)=>{ try { if (!eid) return; const labels = { ...(this._settingsDraft.labels || {}) }; if (value) labels[eid] = value; else delete labels[eid]; this._settingsDraft.labels = labels; // Mark labels as edited so an empty labels object should clear stored labels this._labelsTouched = true; } catch {} }; // Important: don't re-render the whole Rooms tab on every keystroke, // otherwise the input element gets recreated and loses focus. nameInp.addEventListener('input', (e)=>{ try { const eid = String(this._settingsDraft?.entities?.[idx] || '').trim(); if (!eid) return; const raw = String(e?.target?.value ?? ''); setDraftLabel(eid, raw ? raw : ''); } catch {} }); nameInp.addEventListener('change', (e)=>{ try { const eid = String(this._settingsDraft?.entities?.[idx] || '').trim(); if (!eid) return; const val = String(e?.target?.value ?? '').trim(); setDraftLabel(eid, val); try { e.target.value = val; } catch {} this._renderSettingsPopupRoomsTab(); } catch {} }); // Per-room: optional turn_on behavior const turnOnWrap = document.createElement('div'); turnOnWrap.style.display = 'grid'; turnOnWrap.style.gap = '6px'; const turnOnRow = document.createElement('div'); turnOnRow.style.display = 'flex'; turnOnRow.style.alignItems = 'center'; turnOnRow.style.justifyContent = 'space-between'; turnOnRow.style.gap = '10px'; const turnOnText = document.createElement('div'); turnOnText.style.display = 'grid'; turnOnText.style.gap = '2px'; const turnOnTitle = document.createElement('div'); turnOnTitle.style.fontWeight = '600'; turnOnTitle.textContent = this._t('editor.turn_on.title') || 'Send turn_on command'; const turnOnDesc = document.createElement('div'); turnOnDesc.style.fontSize = '.85rem'; turnOnDesc.style.color = 'var(--secondary-text-color)'; turnOnDesc.textContent = this._t('editor.turn_on.desc') || ''; turnOnText.append(turnOnTitle, turnOnDesc); const turnOnSwitch = document.createElement('ha-switch'); turnOnSwitch.className = 'turn-on-switch'; // Dropdown (only visible when enabled) const turnOnOrderRow = document.createElement('div'); turnOnOrderRow.style.display = 'grid'; turnOnOrderRow.style.gap = '2px'; const orderLbl = document.createElement('div'); orderLbl.className = 'slabel'; orderLbl.textContent = this._t('editor.turn_on.order') || 'Order'; const orderSel = document.createElement('select'); orderSel.className = 'turn-on-order'; try { orderSel.style.height = '32px'; orderSel.style.border = '1px solid var(--divider-color)'; orderSel.style.borderRadius = '8px'; orderSel.style.background = 'var(--card-background-color)'; orderSel.style.color = 'var(--primary-text-color)'; orderSel.style.padding = '0 10px'; orderSel.style.width = '100%'; orderSel.style.maxWidth = '440px'; } catch {} const optBefore = document.createElement('option'); optBefore.value = 'before'; optBefore.textContent = this._t('editor.turn_on.order.before') || 'Before set_temperature'; const optAfter = document.createElement('option'); optAfter.value = 'after'; optAfter.textContent = this._t('editor.turn_on.order.after') || 'After set_temperature'; orderSel.append(optBefore, optAfter); const readTurnOnCfg = ()=>{ try { const eid = String(this._settingsDraft?.entities?.[idx] || '').trim(); const map = (this._settingsDraft?.turn_on && typeof this._settingsDraft.turn_on === 'object') ? this._settingsDraft.turn_on : {}; const cfg0 = (eid && map[eid] && typeof map[eid] === 'object') ? map[eid] : {}; const enabled = !!cfg0.enabled; const order = String(cfg0.order || 'before'); return { eid, enabled, order: (order === 'after' ? 'after' : 'before') }; } catch { return { eid: String(this._settingsDraft?.entities?.[idx] || '').trim(), enabled: false, order: 'before' }; } }; const writeTurnOnCfg = (eid, patch)=>{ try { if (!eid) return; const prev = (this._settingsDraft.turn_on && typeof this._settingsDraft.turn_on === 'object') ? this._settingsDraft.turn_on : {}; const cur = (prev[eid] && typeof prev[eid] === 'object') ? prev[eid] : {}; this._settingsDraft.turn_on = { ...prev, [eid]: { ...cur, ...patch } }; } catch {} }; const applyTurnOnUi = ()=>{ const { enabled, order } = readTurnOnCfg(); try { turnOnSwitch.checked = !!enabled; } catch {} try { orderSel.value = (order === 'after' ? 'after' : 'before'); } catch {} try { turnOnOrderRow.style.display = enabled ? '' : 'none'; } catch {} }; turnOnSwitch.addEventListener('change', (e)=>{ try { const { eid, order } = readTurnOnCfg(); const enabled = !!e.target.checked; writeTurnOnCfg(eid, { enabled, order }); applyTurnOnUi(); } catch {} }); orderSel.addEventListener('change', ()=>{ try { const { eid, enabled } = readTurnOnCfg(); const order = String(orderSel.value || 'before'); writeTurnOnCfg(eid, { enabled: !!enabled, order: (order === 'after' ? 'after' : 'before') }); applyTurnOnUi(); } catch {} }); applyTurnOnUi(); turnOnRow.append(turnOnText, turnOnSwitch); turnOnOrderRow.append(orderLbl, orderSel); turnOnWrap.append(turnOnRow, turnOnOrderRow); try { if (useInput) turnOnWrap.style.display = 'none'; } catch {} // Per-room: temperature sensor override (for display + boiler) const tempWrap = document.createElement('div'); tempWrap.style.display = 'grid'; tempWrap.style.gap = '6px'; const tempRow = document.createElement('div'); tempRow.style.display = 'flex'; tempRow.style.alignItems = 'center'; tempRow.style.justifyContent = 'space-between'; tempRow.style.gap = '10px'; const tempText = document.createElement('div'); tempText.style.display = 'grid'; tempText.style.gap = '2px'; const tempTitle = document.createElement('div'); tempTitle.style.fontWeight = '600'; tempTitle.textContent = this._t('editor.room_temp_sensor_override.title') || 'Use room temperature sensor'; const tempDesc = document.createElement('div'); tempDesc.style.fontSize = '.85rem'; tempDesc.style.color = 'var(--secondary-text-color)'; tempDesc.textContent = this._t('editor.room_temp_sensor_override.desc') || ''; tempText.append(tempTitle, tempDesc); const tempSwitch = document.createElement('ha-switch'); tempSwitch.className = 'room-temp-sensor-switch'; const tempPicker = document.createElement('tt-entity-picker'); try { tempPicker.style.display = 'block'; tempPicker.style.minHeight = '48px'; tempPicker.style.maxWidth = '440px'; } catch {} tempPicker.setAttribute('include-domains', '["sensor"]'); tempPicker.setAttribute('label', this._t('editor.room_temp_sensor_override.entity') || (this._t('editor.temp_sensor') || 'Temperature sensor')); try { tempPicker.hass = this._hass; } catch {} const applyTempUi = ()=>{ try { const primary = String(this._settingsDraft?.entities?.[idx] || '').trim(); const can = !useInput && primary.startsWith('climate.'); const enabled = can && !!this._settingsDraft?.room_use_temp_sensor?.[idx]; tempRow.style.display = can ? '' : 'none'; tempPicker.style.display = (can && enabled) ? '' : 'none'; tempSwitch.checked = !!enabled; tempSwitch.disabled = !can; if (can) { const cur = String(this._settingsDraft?.temp_sensors?.[primary] || '').trim(); tempPicker.value = cur; } else { tempPicker.value = ''; } } catch {} }; tempSwitch.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; if (!Array.isArray(this._settingsDraft.room_use_temp_sensor)) this._settingsDraft.room_use_temp_sensor = []; while (this._settingsDraft.room_use_temp_sensor.length < this._settingsDraft.entities.length) this._settingsDraft.room_use_temp_sensor.push(false); this._settingsDraft.room_use_temp_sensor[idx] = !!e.target.checked; applyTempUi(); } catch {} }); tempPicker.addEventListener('value-changed', (e)=>{ try { if (!this._settingsDraft) return; const primary = String(this._settingsDraft?.entities?.[idx] || '').trim(); if (!primary) return; const v = String(e?.detail?.value || '').trim(); const next = { ...(this._settingsDraft.temp_sensors || {}) }; if (v) next[primary] = v; else delete next[primary]; this._settingsDraft.temp_sensors = next; this._renderSettingsPopupRoomsTab(); } catch {} }); tempRow.append(tempText, tempSwitch); tempWrap.append(tempRow, tempPicker); applyTempUi(); // Merge UI const linkWrap = document.createElement('div'); linkWrap.style.display = 'grid'; linkWrap.style.gap = '6px'; const linkLabel = document.createElement('div'); linkLabel.className = 'label'; linkLabel.textContent = this._t('editor.merge_label') || 'Merge with'; const linkPicker = document.createElement('tt-entity-picker'); try { linkPicker.style.display = 'block'; linkPicker.style.minHeight = '48px'; linkPicker.style.width = '100%'; linkPicker.style.maxWidth = '440px'; } catch {} linkPicker.className = 'merge-picker'; linkPicker.setAttribute('include-domains', '["climate"]'); linkPicker.setAttribute('label', this._t('editor.merge_label') || 'Merge with'); try { linkPicker.hass = this._hass; } catch {} linkPicker.entityFilter = (st) => { try { const id = st?.entity_id || st?.entityId || st?.entityID || ''; if (!id) return false; const prims = (this._settingsDraft?.entities || []).filter(Boolean).map(String); const allLinked = Object.values(this._settingsDraft?.merges || {}).flat().filter(Boolean).map(String); const used = new Set([...prims, ...allLinked]); const cur = String(this._settingsDraft?.entities?.[idx] || ''); return !used.has(id) && id !== cur; } catch { return true; } }; const chips = document.createElement('div'); chips.className = 'linked-chips'; const refreshSubtitle = ()=>{ try { const eid = String(this._settingsDraft?.entities?.[idx] || ''); const links = (this._settingsDraft?.merges?.[eid] || []).map(String); const linkedNames = links.map(id => this._prettyName(id)); const parts = []; if (eid) parts.push(eid); if (linkedNames.length) parts.push(`${this._t('editor.merged_with') || 'Merged with'}: ${linkedNames.join(', ')}`); subSpan.textContent = parts.join(' • '); } catch { subSpan.textContent = ''; } }; const refreshChips = ()=>{ try { chips.innerHTML = ''; const primary = String(this._settingsDraft?.entities?.[idx] || ''); const links = (this._settingsDraft?.merges?.[primary] || []).map(String); for (const l of links) { const chip = document.createElement('span'); chip.className = 'pill-chip'; const txt = document.createElement('span'); txt.textContent = this._prettyName(l); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'rm'; rm.textContent = '×'; try { rm.setAttribute('aria-label', this._t('editor.remove') || 'Remove'); } catch {} rm.addEventListener('click', ()=>{ try { const merges = { ...(this._settingsDraft.merges || {}) }; merges[primary] = (merges[primary] || []).filter(x => String(x) !== String(l)); if (!merges[primary]?.length) delete merges[primary]; this._settingsDraft.merges = merges; this._renderSettingsPopupRoomsTab(); } catch {} }); chip.append(txt, rm); chips.append(chip); } refreshSubtitle(); } catch {} }; linkPicker.addEventListener('value-changed', (e)=>{ try { const toAdd = String(e?.detail?.value || ''); try { linkPicker.value = ''; } catch {} const primary = String(this._settingsDraft?.entities?.[idx] || ''); if (!primary || !toAdd) return; const merges = { ...(this._settingsDraft.merges || {}) }; const arr = Array.from(new Set([...(merges[primary] || []).map(String), toAdd])); merges[primary] = arr; this._settingsDraft.merges = merges; this._renderSettingsPopupRoomsTab(); } catch {} }); left.append(modeRow, pick, nameLabel, nameInp, tempWrap, turnOnWrap, linkWrap, chips); try { linkWrap.append(linkLabel, linkPicker); } catch {} details.append(left); // Summary title try { const eid = String(ents[idx] || '').trim(); titleSpan.textContent = eid ? pretty(eid) : (this._t('editor.entity_placeholder') || 'Select entity'); } catch { titleSpan.textContent = this._t('editor.entity_placeholder') || 'Select entity'; } refreshSubtitle(); refreshChips(); // Remove room del.addEventListener('click', (ev)=>{ try { ev.stopPropagation?.(); } catch {} try { const arr = [...(this._settingsDraft.entities || [])]; const modes = Array.isArray(this._settingsDraft.room_use_input_number) ? [...this._settingsDraft.room_use_input_number] : []; const tmodes = Array.isArray(this._settingsDraft.room_use_temp_sensor) ? [...this._settingsDraft.room_use_temp_sensor] : []; const primary = String(arr[idx] || ''); arr.splice(idx, 1); try { if (modes.length) modes.splice(idx, 1); } catch {} try { if (tmodes.length) tmodes.splice(idx, 1); } catch {} // Clean labels try { const labels = { ...(this._settingsDraft.labels || {}) }; if (primary && labels[primary]) delete labels[primary]; this._settingsDraft.labels = labels; } catch {} // Clean merges try { const merges = { ...(this._settingsDraft.merges || {}) }; if (primary && merges[primary]) delete merges[primary]; for (const k of Object.keys(merges)) { merges[k] = (merges[k] || []).filter(e => String(e) !== String(primary)); if (!merges[k].length) delete merges[k]; } this._settingsDraft.merges = merges; } catch {} // Clean temp sensors try { const sensors = { ...(this._settingsDraft.temp_sensors || {}) }; if (primary && sensors[primary]) delete sensors[primary]; this._settingsDraft.temp_sensors = sensors; } catch {} // Clean presence sensors try { const ps = { ...(this._settingsDraft.presence_sensors || {}) }; if (primary && ps[primary]) delete ps[primary]; this._settingsDraft.presence_sensors = ps; } catch {} // Clean turn_on try { const t = { ...(this._settingsDraft.turn_on || {}) }; if (primary && t[primary]) delete t[primary]; this._settingsDraft.turn_on = t; } catch {} this._settingsDraft.entities = arr; this._settingsDraft.room_use_input_number = modes; this._settingsDraft.room_use_temp_sensor = tmodes; this._renderSettingsPopupRoomsTab(); } catch {} }); // Primary picker change pick.addEventListener('value-changed', (e)=>{ try { const newPrimary = String(e?.detail?.value || '').trim(); const oldPrimary = String(this._settingsDraft?.entities?.[idx] || '').trim(); // Auto-sync mode based on chosen domain try { while (this._settingsDraft.room_use_input_number.length < this._settingsDraft.entities.length) this._settingsDraft.room_use_input_number.push(false); if (newPrimary.startsWith('input_number.')) this._settingsDraft.room_use_input_number[idx] = true; else if (newPrimary.startsWith('climate.')) this._settingsDraft.room_use_input_number[idx] = false; } catch {} // If switching away from climate, disable temp sensor override for this row try { if (!Array.isArray(this._settingsDraft.room_use_temp_sensor)) this._settingsDraft.room_use_temp_sensor = []; while (this._settingsDraft.room_use_temp_sensor.length < this._settingsDraft.entities.length) this._settingsDraft.room_use_temp_sensor.push(false); if (!newPrimary.startsWith('climate.')) this._settingsDraft.room_use_temp_sensor[idx] = false; } catch {} // Migrate merges/temp sensors when primary changes if (oldPrimary && newPrimary && oldPrimary !== newPrimary) { try { const merges = { ...(this._settingsDraft.merges || {}) }; if (merges[oldPrimary] && !merges[newPrimary]) { merges[newPrimary] = merges[oldPrimary]; delete merges[oldPrimary]; } else if (merges[oldPrimary] && merges[newPrimary]) { merges[newPrimary] = Array.from(new Set([...(merges[newPrimary]||[]), ...(merges[oldPrimary]||[])])); delete merges[oldPrimary]; } this._settingsDraft.merges = merges; } catch {} try { const sensors = { ...(this._settingsDraft.temp_sensors || {}) }; if (sensors[oldPrimary] && !sensors[newPrimary]) sensors[newPrimary] = sensors[oldPrimary]; if (sensors[oldPrimary]) delete sensors[oldPrimary]; this._settingsDraft.temp_sensors = sensors; } catch {} try { const labels = { ...(this._settingsDraft.labels || {}) }; if (labels[oldPrimary] && !labels[newPrimary]) labels[newPrimary] = labels[oldPrimary]; if (labels[oldPrimary]) delete labels[oldPrimary]; this._settingsDraft.labels = labels; } catch {} try { const t = { ...(this._settingsDraft.turn_on || {}) }; if (t[oldPrimary] && !t[newPrimary]) t[newPrimary] = t[oldPrimary]; if (t[oldPrimary]) delete t[oldPrimary]; this._settingsDraft.turn_on = t; } catch {} try { const ps = { ...(this._settingsDraft.presence_sensors || {}) }; if (ps[oldPrimary] && !ps[newPrimary]) ps[newPrimary] = ps[oldPrimary]; if (ps[oldPrimary]) delete ps[oldPrimary]; this._settingsDraft.presence_sensors = ps; } catch {} // Move open-state key old -> new try { if (this._openPopupRooms && this._openPopupRooms.has(oldPrimary)) { this._openPopupRooms.delete(oldPrimary); this._openPopupRooms.add(newPrimary); } } catch {} } const next = [...(this._settingsDraft.entities || [])]; next[idx] = newPrimary; this._settingsDraft.entities = next; this._renderSettingsPopupRoomsTab(); } catch {} }); // Drag reorder handle.addEventListener('mousedown', (ev)=>{ try { ev.preventDefault(); } catch {} try { const wrap = line.parentElement; const curIdx = wrap ? Array.prototype.indexOf.call(wrap.children, line) : idx; this._startSettingsPopupRoomsPointerDnd(line, curIdx, ev); } catch { this._startSettingsPopupRoomsPointerDnd(line, idx, ev); } }); handle.addEventListener('touchstart', (ev)=>{ try { ev.preventDefault(); } catch {} try { const wrap = line.parentElement; const curIdx = wrap ? Array.prototype.indexOf.call(wrap.children, line) : idx; this._startSettingsPopupRoomsPointerDnd(line, curIdx, ev); } catch { this._startSettingsPopupRoomsPointerDnd(line, idx, ev); } }, { passive: false }); return line; }; // Add rows for (let i=0;i{ try { if (!this._settingsDraft) return; this._settingsDraft.entities = [...(this._settingsDraft.entities || []), '']; const modes = Array.isArray(this._settingsDraft.room_use_input_number) ? [...this._settingsDraft.room_use_input_number] : []; modes.push(false); this._settingsDraft.room_use_input_number = modes; const tmodes = Array.isArray(this._settingsDraft.room_use_temp_sensor) ? [...this._settingsDraft.room_use_temp_sensor] : []; tmodes.push(false); this._settingsDraft.room_use_temp_sensor = tmodes; this._renderSettingsPopupRoomsTab(); } catch {} }); } catch {} } } catch {} } _startSettingsPopupRoomsPointerDnd(line, idx, startEv){ try { const wrap = line.parentElement; if (!wrap) return; if (this._settingsPopupPointerDndActive) return; this._settingsPopupPointerDndActive = true; const rect = line.getBoundingClientRect(); const ghost = line.cloneNode(true); ghost.classList.add('drag-ghost'); ghost.style.width = rect.width + 'px'; ghost.style.height = rect.height + 'px'; ghost.style.top = rect.top + 'px'; ghost.style.left = rect.left + 'px'; const placeholder = document.createElement('div'); placeholder.className = 'placeholder'; placeholder.style.height = rect.height + 'px'; // place placeholder where line was, and hide line wrap.insertBefore(placeholder, line); line.style.display = 'none'; document.body.appendChild(ghost); const readClient = (e)=>{ if (e?.touches && e.touches[0]) return { x: e.touches[0].clientX, y: e.touches[0].clientY }; return { x: e?.clientX, y: e?.clientY }; }; const onMove = (ev)=>{ try { ev.preventDefault(); } catch {} const { y, x } = readClient(ev); if (!Number.isFinite(y) || !Number.isFinite(x)) return; ghost.style.top = (y - rect.height/2) + 'px'; // find element under pointer within this shadowRoot let el = this.shadowRoot && this.shadowRoot.elementFromPoint(x, y); // climb to row in the same wrap while (el && el !== wrap && !el.classList?.contains('inline')) el = el.parentElement; if (!el || el===placeholder || el===wrap) return; const targetRect = el.getBoundingClientRect(); const before = (y - targetRect.top) < targetRect.height/2; if (before) wrap.insertBefore(placeholder, el); else wrap.insertBefore(placeholder, el.nextSibling); }; const endDrag = ()=>{ window.removeEventListener('mousemove', onMove, true); window.removeEventListener('mouseup', onUp, true); window.removeEventListener('touchmove', onMove, true); window.removeEventListener('touchend', onUp, true); try { ghost.remove(); } catch {} try { const from = idx; const to = Array.prototype.indexOf.call(wrap.children, placeholder); const arr = [...(this._settingsDraft?.entities || [])]; const modes = Array.isArray(this._settingsDraft?.room_use_input_number) ? [...this._settingsDraft.room_use_input_number] : []; const tmodes = Array.isArray(this._settingsDraft?.room_use_temp_sensor) ? [...this._settingsDraft.room_use_temp_sensor] : []; while (modes.length < arr.length) modes.push(false); while (tmodes.length < arr.length) tmodes.push(false); const [item] = arr.splice(from,1); const [mItem] = modes.splice(from,1); const [tItem] = tmodes.splice(from,1); const correctedTo = Math.max(0, Math.min(to, arr.length)); arr.splice(correctedTo, 0, item); modes.splice(correctedTo, 0, mItem); tmodes.splice(correctedTo, 0, tItem); this._settingsDraft.entities = arr; this._settingsDraft.room_use_input_number = modes; this._settingsDraft.room_use_temp_sensor = tmodes; } catch {} try { placeholder.remove(); } catch {} try { line.style.display = ''; } catch {} this._settingsPopupPointerDndActive = false; try { this._renderSettingsPopupRoomsTab(); } catch {} }; const onUp = (ev)=>{ try { ev.preventDefault?.(); } catch {} endDrag(); }; window.addEventListener('mousemove', onMove, true); window.addEventListener('mouseup', onUp, true); window.addEventListener('touchmove', onMove, true); window.addEventListener('touchend', onUp, true); // Initial nudge so the ghost doesn't feel stuck under finger try { const { x, y } = readClient(startEv || {}); if (Number.isFinite(y) && Number.isFinite(x)) ghost.style.top = (y - rect.height/2) + 'px'; } catch {} } catch { this._settingsPopupPointerDndActive = false; } } _renderSettingsPopupAwayTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="away"]'); if (!page) return; // Build markup once if (!page.dataset.built) { page.dataset.built = '1'; page.innerHTML = `
`; } // Ensure draft shape if (!d.away || typeof d.away !== 'object') d.away = { enabled:false, target_c:17, persons:[], advanced_enabled:false, combos:{} }; if (!Array.isArray(d.away.persons)) d.away.persons = []; if (!d.away.combos || typeof d.away.combos !== 'object') d.away.combos = {}; const q = (sel)=> this.shadowRoot.querySelector(sel); const awayOn = !!d.away.enabled; const advOn = !!d.away.advanced_enabled; // Show/hide dependent controls based on master toggle try { const tempRow = q('.overlay-settings .sp-away-temp-row'); const personsRow = q('.overlay-settings .sp-away-persons-row'); const advRow = q('.overlay-settings .sp-away-adv-row'); const delayRow = q('.overlay-settings .sp-away-delay-row'); const liveRow = q('.overlay-settings .sp-presence-live-row'); const combosRow = q('.overlay-settings .sp-away-combos-row'); [tempRow, personsRow, delayRow, advRow, liveRow, combosRow].forEach(r=>{ if (r) r.style.display = awayOn ? '' : 'none'; }); } catch {} // Toggle states try { const sw = q('.overlay-settings .sp-away-enable'); if (sw) sw.checked = awayOn; } catch {} try { const adv = q('.overlay-settings .sp-away-adv-enable'); if (adv) adv.checked = advOn; } catch {} try { const pl = q('.overlay-settings .sp-presence-live-enable'); if (pl) { pl.checked = !!(d.presence_live_header ?? true); // Enabled whenever Away mode is enabled pl.disabled = !awayOn; } } catch {} // Temperature field formatting based on draft unit try { const inp = q('.overlay-settings .sp-away-temp'); if (inp) { const wantF = String(d.temp_unit || 'C').toUpperCase() === 'F'; const toDisp = (c)=> wantF ? ((Number(c) * 9/5) + 32) : Number(c); const mnC = Number.isFinite(Number(d.min_temp)) ? Number(d.min_temp) : (Number.isFinite(Number(this._config?.min_temp)) ? Number(this._config.min_temp) : 5); const mxC = Number.isFinite(Number(d.max_temp)) ? Number(d.max_temp) : (Number.isFinite(Number(this._config?.max_temp)) ? Number(this._config.max_temp) : 25); inp.setAttribute('min', String(toDisp(mnC))); inp.setAttribute('max', String(toDisp(mxC))); if (inp !== this.shadowRoot.activeElement) { const v = Number.isFinite(Number(d.away.target_c)) ? Number(d.away.target_c) : 17; inp.value = String(Math.round(toDisp(v) * 2) / 2); } } } catch {} // Delay controls try { const en = q('.overlay-settings .sp-away-delay-enable'); if (en) en.checked = !!d.away_delay_enabled; const val = q('.overlay-settings .sp-away-delay-value'); if (val && val !== this.shadowRoot.activeElement) val.value = String(Number.isFinite(Number(d.away_delay_value)) ? Number(d.away_delay_value) : 0); const unit = q('.overlay-settings .sp-away-delay-unit'); if (unit) unit.value = (String(d.away_delay_unit || 'minutes')); } catch {} // Entity picker try { const picker = q('.overlay-settings .sp-away-person-picker'); if (picker) { picker.hass = this._hass; picker.value = ''; try { picker.setAttribute('label', this._t('away.add_person') || 'Add person'); } catch {} } } catch {} // Chips try { const chips = q('.overlay-settings .sp-away-chips'); if (chips) { chips.innerHTML = ''; const arr = Array.isArray(d.away.persons) ? d.away.persons.filter(Boolean).map(String) : []; for (const p of arr) { const chip = document.createElement('span'); chip.className = 'pill-chip'; const txt = document.createElement('span'); txt.textContent = this._hass?.states?.[p]?.attributes?.friendly_name || (p.split('.')[1]||p); const rm = document.createElement('button'); rm.type='button'; rm.className='rm'; rm.textContent='×'; try { rm.setAttribute('aria-label', this._t('editor.remove') || 'Remove'); } catch {} rm.addEventListener('click', ()=>{ try { if (!this._settingsDraft?.away) return; this._settingsDraft.away.persons = (this._settingsDraft.away.persons||[]).filter(x=>String(x)!==String(p)); this._awayTouched = true; this._renderSettingsPopupAwayTab(); } catch {} }); chip.append(txt, rm); chips.append(chip); } } } catch {} // Presence combinations checklist try { const comboWrap = q('.overlay-settings .sp-presence-combo-list'); if (comboWrap) { comboWrap.innerHTML = ''; const persons = (d.away.persons || []).map(String); const enabled = !!d.away.advanced_enabled; const conf = (d.away.combos && typeof d.away.combos === 'object') ? d.away.combos : {}; const n = persons.length; const labelOf = (arr)=> arr.map(e => (this._hass?.states?.[e]?.attributes?.friendly_name || (e.split('.')[1]||e))).join(', '); for (let mask=0; mask < (1<>i)&1) ? home.push(persons[i]) : away.push(persons[i]); } // Skip "All home" entry; fallback to main/weekdays when all are home if (home.length===n) continue; const key = `H:${home.slice().sort().join(',')}|A:${away.slice().sort().join(',')}`; const row = document.createElement('label'); row.style.display='flex'; row.style.alignItems='center'; row.style.gap='8px'; row.style.opacity = enabled ? '' : '.6'; const cb = document.createElement('input'); cb.type='checkbox'; cb.disabled = !enabled || !persons.length; cb.checked = !!conf[key]?.enabled; cb.addEventListener('change', ()=>{ try { if (!this._settingsDraft?.away) return; const map = { ...(this._settingsDraft.away.combos || {}) }; if (cb.checked) map[key] = { ...(map[key]||{}), enabled:true }; else delete map[key]; this._settingsDraft.away.combos = map; this._awayTouched = true; } catch {} }); const sp = document.createElement('span'); let lab; if (home.length===0) lab = this._t('presence.none_home'); else { const h = (this._t('presence.home') || 'Home: {names}').replace('{names}', labelOf(home)); const a = (this._t('presence.away') || 'Away: {names}').replace('{names}', labelOf(away)); lab = `${h} • ${a}`; } sp.textContent = lab; row.append(cb, sp); comboWrap.append(row); } } } catch {} // Bind events once if (!page.dataset.bound) { page.dataset.bound = '1'; const _ttReadSwitchValue = (evt, el, fallback = false)=>{ try { const d = evt?.detail; if (d && typeof d === 'object' && typeof d.value === 'boolean') return !!d.value; } catch {} try { const ct = evt?.currentTarget; if (ct && typeof ct.checked === 'boolean') return !!ct.checked; } catch {} try { if (el && typeof el.checked === 'boolean') return !!el.checked; } catch {} try { const t = evt?.target; if (t && typeof t.checked === 'boolean') return !!t.checked; } catch {} return !!fallback; }; const _ttBindHaSwitch = (el, handler)=>{ try { if (!el || typeof handler !== 'function') return; } catch { return; } try { el.addEventListener('change', handler); } catch {} try { el.addEventListener('checked-changed', handler); } catch {} }; try { const sw = q('.overlay-settings .sp-away-enable'); if (sw) _ttBindHaSwitch(sw, (e)=>{ try { if (!this._settingsDraft?.away) return; const enabled = _ttReadSwitchValue(e, sw, !!this._settingsDraft.away.enabled); this._settingsDraft.away.enabled = enabled; // If Away is turned OFF, also disable Advanced Away and live presence chips if (!enabled) { this._settingsDraft.away.advanced_enabled = false; } this._awayTouched = true; this._renderSettingsPopupAwayTab(); } catch {} }); } catch {} try { const inp = q('.overlay-settings .sp-away-temp'); if (inp) inp.addEventListener('change', (e)=>{ try { if (!this._settingsDraft?.away) return; let raw = String(e.target.value||'').trim().replace(',', '.'); const vDisp = Number(raw); if (!Number.isFinite(vDisp)) return; const wantF = String(this._settingsDraft.temp_unit || 'C').toUpperCase() === 'F'; const fromDisp = (x)=> wantF ? ((Number(x) - 32) * 5/9) : Number(x); let vC = fromDisp(vDisp); const mn = Number.isFinite(Number(this._settingsDraft.min_temp)) ? Number(this._settingsDraft.min_temp) : 5; const mx = Number.isFinite(Number(this._settingsDraft.max_temp)) ? Number(this._settingsDraft.max_temp) : 25; vC = Math.max(mn, Math.min(mx, vC)); this._settingsDraft.away.target_c = vC; this._awayTouched = true; } catch {} }); } catch {} try { const picker = q('.overlay-settings .sp-away-person-picker'); if (picker) picker.addEventListener('value-changed', (e)=>{ try { const val = e?.detail?.value; if (!val || !this._settingsDraft?.away) return; picker.value = ''; const set = new Set((this._settingsDraft.away.persons || []).map(String)); set.add(String(val)); this._settingsDraft.away.persons = Array.from(set); this._awayTouched = true; this._renderSettingsPopupAwayTab(); } catch {} }); } catch {} try { const adv = q('.overlay-settings .sp-away-adv-enable'); if (adv) _ttBindHaSwitch(adv, (e)=>{ try { if (!this._settingsDraft?.away) return; this._settingsDraft.away.advanced_enabled = _ttReadSwitchValue(e, adv, !!this._settingsDraft.away.advanced_enabled); // Do not force presence chips off when advanced is disabled; // availability is controlled by Away mode. this._awayTouched = true; this._renderSettingsPopupAwayTab(); } catch {} }); } catch {} try { const pl = q('.overlay-settings .sp-presence-live-enable'); if (pl) _ttBindHaSwitch(pl, (e)=>{ try { if (!this._settingsDraft?.away?.enabled) { e.preventDefault?.(); pl.checked = !!(this._settingsDraft?.presence_live_header ?? true); return; } this._settingsDraft.presence_live_header = _ttReadSwitchValue(e, pl, !!(this._settingsDraft.presence_live_header ?? true)); this._awayTouched = true; } catch {} }); } catch {} // Delay bindings try { const en = q('.overlay-settings .sp-away-delay-enable'); if (en) _ttBindHaSwitch(en, (e)=>{ try { this._settingsDraft.away_delay_enabled = _ttReadSwitchValue(e, en, !!(this._settingsDraft.away_delay_enabled ?? false)); this._awayTouched = true; } catch {} }); } catch {} try { const unit = q('.overlay-settings .sp-away-delay-unit'); if (unit) unit.addEventListener('change', (e)=>{ try { this._settingsDraft.away_delay_unit = String(e.target.value||'minutes'); this._awayTouched = true; } catch {} }); } catch {} try { const val = q('.overlay-settings .sp-away-delay-value'); if (val) val.addEventListener('change', (e)=>{ try { const n=Number(e.target.value); this._settingsDraft.away_delay_value = Number.isFinite(n)? Math.max(0,Math.round(n)) : 0; this._awayTouched = true; } catch {} }); } catch {} } } catch {} } _renderSettingsPopupResetTab(){ try { if (!this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="reset"]'); if (!page) return; if (!page.dataset.built) { page.dataset.built = '1'; page.innerHTML = `
`; } const q = (sel)=> this.shadowRoot.querySelector(sel); const ttl = q('.overlay-settings .sp-reset-title'); if (ttl) ttl.textContent = this._t('editor.tabs.reset') || 'Reset'; const desc = q('.overlay-settings .sp-reset-desc'); if (desc) { let msg = String(this._t('editor.reset.desc') || '').trim(); if (!msg) { let short = String(this._t('editor.clear_all_confirm') || '').trim(); short = short.replace(/\?\s*$/, ''); msg = short || 'Clear storage + local/browser data.'; } desc.textContent = msg; } const bAll = q('.overlay-settings .sp-reset-clear-all'); if (bAll) { const s = bAll.querySelector('span') || bAll; if (s) s.textContent = this._t('editor.clear_all') || 'Clear all data'; } const bLocal = q('.overlay-settings .sp-reset-clear-local'); if (bLocal) { const s = bLocal.querySelector('span') || bLocal; if (s) s.textContent = this._t('editor.clear_local_only') || 'Clear ALL local data (local only)'; } if (!page.dataset.bound) { page.dataset.bound = '1'; try { const btn = page.querySelector('.sp-reset-clear-all'); if (btn) btn.addEventListener('click', async ()=>{ const msg = this._t('editor.clear_all_confirm'); if (!confirm(msg)) return; try { await this._clearAllDataEverywhereConfirmed(); } catch {} }); } catch {} try { const btn = page.querySelector('.sp-reset-clear-local'); if (btn) btn.addEventListener('click', ()=>{ const msg = this._t('editor.clear_local_only_confirm'); if (!confirm(msg)) return; try { this._clearAllLocalOnlyConfirmed(); } catch {} }); } catch {} } } catch {} } _renderSettingsPopupOwdTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="owd"]'); if (!page) return; page.innerHTML = ''; try { page.style.display = ''; page.style.gap = ''; } catch {} // Ensure draft shape const cur = (d.open_window && typeof d.open_window === 'object') ? d.open_window : (d.open_window = { enabled:false, sensors:{}, open_delay_min:2, close_delay_min:5 }); if (typeof cur.enabled !== 'boolean') cur.enabled = false; if (!cur.sensors || typeof cur.sensors !== 'object') cur.sensors = {}; if (!Number.isFinite(Number(cur.open_delay_min))) cur.open_delay_min = 2; if (!Number.isFinite(Number(cur.close_delay_min))) cur.close_delay_min = 5; cur.open_delay_min = Math.max(0, Math.min(1440, Math.round(Number(cur.open_delay_min)))); cur.close_delay_min = Math.max(0, Math.min(1440, Math.round(Number(cur.close_delay_min)))); // Card with all OWD content const enableCard = document.createElement('div'); enableCard.className = 'settings-card'; const enRow = document.createElement('div'); enRow.className = 'setting'; const enText = document.createElement('div'); enText.className = 'text'; const enTitle = document.createElement('div'); enTitle.className = 'title sp-owd-enable-title'; const enDesc = document.createElement('div'); enDesc.className = 'desc sp-owd-enable-desc'; enText.append(enTitle, enDesc); const enSwitch = document.createElement('ha-switch'); enSwitch.className = 'sp-owd-enable'; enSwitch.checked = !!cur.enabled; enSwitch.addEventListener('change', (e)=>{ try { if (!this._settingsDraft?.open_window) return; this._settingsDraft.open_window.enabled = !!e.target.checked; try { contentWrap.style.display = this._settingsDraft.open_window.enabled ? 'grid' : 'none'; } catch {} } catch {} }); enRow.append(enText, enSwitch); enableCard.append(enRow); // Remaining content shown only when enabled (inside the same card, like other tabs) const contentWrap = document.createElement('div'); contentWrap.className = 'owd-content'; try { contentWrap.style.display = cur.enabled ? 'grid' : 'none'; contentWrap.style.gap = '16px'; contentWrap.style.marginTop = '6px'; } catch {} enableCard.append(contentWrap); page.append(enableCard); // Rooms foldouts, wrapped in a setting row (like Boiler) const ents = Array.isArray(this._config?.entities) ? this._config.entities : []; const sensorsRow = document.createElement('div'); sensorsRow.className = 'setting'; sensorsRow.style.gridTemplateColumns = '1fr'; const sensorsText = document.createElement('div'); sensorsText.className = 'text'; const sensorsTitle = document.createElement('div'); sensorsTitle.className = 'title sp-owd-sensors-label'; sensorsText.append(sensorsTitle); sensorsRow.append(sensorsText); const roomsWrap = document.createElement('div'); try { roomsWrap.style.display = 'grid'; roomsWrap.style.gap = '10px'; } catch {} sensorsRow.append(roomsWrap); contentWrap.append(sensorsRow); if (!ents.length) { const box = document.createElement('div'); box.className = 'empty-box'; const p = document.createElement('div'); p.textContent = this._t('editor.select_entities') || 'Rooms'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'add-entity-btn'; btn.innerHTML = '' + (this._t('editor.add_entity') || 'Add room') + ''; btn.onclick = ()=>{ this._setSettingsPopupTab('rooms'); }; box.append(p, btn); roomsWrap.append(box); } // Remember open/closed state while popup is open try { if (!this._openOwdRooms) this._openOwdRooms = new Set(); } catch {} for (const eid of ents) { if (!eid) continue; // Foldout (same UI pattern as HA editor sections) const line = document.createElement('div'); line.className = 'inline'; const summary = document.createElement('div'); summary.className = 'summary'; const sumLeft = document.createElement('div'); sumLeft.className = 'summary-left'; const exp = document.createElement('button'); exp.type = 'button'; exp.className = 'expander'; exp.innerHTML = ''; const txtWrap = document.createElement('div'); txtWrap.className = 'summary-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'summary-title'; // Inline styles to match the editor foldout appearance (robust across contexts) try { summary.style.display = 'flex'; summary.style.alignItems = 'center'; summary.style.justifyContent = 'space-between'; summary.style.padding = '6px'; summary.style.border = '1px dashed var(--divider-color)'; summary.style.borderRadius = '8px'; summary.style.background = 'var(--secondary-background-color, transparent)'; sumLeft.style.display = 'flex'; sumLeft.style.alignItems = 'center'; sumLeft.style.gap = '8px'; sumLeft.style.minWidth = '0'; txtWrap.style.display = 'grid'; txtWrap.style.gap = '2px'; txtWrap.style.minWidth = '0'; titleSpan.style.fontWeight = '600'; titleSpan.style.overflow = 'hidden'; titleSpan.style.textOverflow = 'ellipsis'; titleSpan.style.whiteSpace = 'nowrap'; titleSpan.style.maxWidth = '320px'; exp.style.width = '28px'; exp.style.height = '28px'; exp.style.borderRadius = '8px'; exp.style.border = '1px solid var(--divider-color)'; exp.style.background = 'var(--card-background-color)'; exp.style.display = 'inline-flex'; exp.style.alignItems = 'center'; exp.style.justifyContent = 'center'; exp.style.cursor = 'pointer'; } catch {} try { const labels = this._config.labels || {}; const display = labels[eid] || this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1] || eid); titleSpan.textContent = display; } catch { titleSpan.textContent = this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1] || eid); } txtWrap.append(titleSpan); sumLeft.append(exp, txtWrap); summary.append(sumLeft); line.append(summary); const details = document.createElement('div'); details.className = 'details'; line.append(details); try { details.style.display = 'none'; details.style.gridTemplateColumns = 'minmax(250px, 440px)'; details.style.gap = '6px'; details.style.padding = '6px'; details.style.border = '1px dashed var(--divider-color)'; details.style.borderRadius = '8px'; details.style.background = 'var(--secondary-background-color, transparent)'; } catch {} const applyOpen = (isOpen)=>{ try { if (isOpen) { line.classList.add('open'); details.style.display = 'grid'; exp.style.transform = 'rotate(180deg)'; } else { line.classList.remove('open'); details.style.display = 'none'; exp.style.transform = ''; } } catch {} }; const toggle = (ev)=>{ try { ev?.stopPropagation?.(); } catch {} const nextOpen = !line.classList.contains('open'); applyOpen(nextOpen); try { if (!this._openOwdRooms) this._openOwdRooms = new Set(); if (nextOpen) this._openOwdRooms.add(String(eid)); else this._openOwdRooms.delete(String(eid)); } catch {} }; exp.addEventListener('click', toggle); summary.style.cursor = 'pointer'; summary.addEventListener('click', (ev)=>{ // Allow clicking the row header to toggle as well try { if (ev?.target?.closest?.('button, a, input, select, textarea, tt-entity-picker, ha-entity-picker')) return; } catch {} toggle(ev); }); try { applyOpen(!!this._openOwdRooms?.has(String(eid))); } catch {} const wrap = document.createElement('div'); wrap.style.display = 'grid'; wrap.style.gap = '8px'; const picker = document.createElement('tt-entity-picker'); picker.className = 'sp-owd-sensor-picker'; picker.setAttribute('include-domains', '["binary_sensor"]'); try { picker.hass = this._hass; } catch {} try { picker.value = ''; } catch {} const chips = document.createElement('div'); chips.className = 'linked-chips sp-owd-chips'; const renderChips = ()=>{ chips.innerHTML = ''; const map = (this._settingsDraft?.open_window?.sensors && typeof this._settingsDraft.open_window.sensors === 'object') ? this._settingsDraft.open_window.sensors : {}; const arr = Array.isArray(map[eid]) ? map[eid] : []; for (const sEid of arr) { const chip = document.createElement('span'); chip.className = 'pill-chip'; const txt = document.createElement('span'); txt.textContent = this._hass?.states?.[sEid]?.attributes?.friendly_name || (sEid.split('.')[1]||sEid); const rm = document.createElement('button'); rm.type='button'; rm.className='rm'; rm.textContent='×'; rm.setAttribute('aria-label', this._t('editor.remove')); rm.addEventListener('click', ()=>{ try { const ow = this._settingsDraft?.open_window; if (!ow || !ow.sensors) return; const next = { ...(ow.sensors || {}) }; const curArr = Array.isArray(next[eid]) ? next[eid] : []; const out = curArr.filter(x => x !== sEid); if (out.length) next[eid] = out; else delete next[eid]; ow.sensors = next; renderChips(); } catch {} }); chip.append(txt, rm); chips.append(chip); } }; renderChips(); picker.addEventListener('value-changed', (e)=>{ const val = e.detail?.value || ''; if (!val) return; try { picker.value = ''; } catch {} try { const ow = this._settingsDraft?.open_window; if (!ow) return; const next = { ...(ow.sensors || {}) }; const set = new Set(Array.isArray(next[eid]) ? next[eid] : []); set.add(String(val)); next[eid] = Array.from(set); ow.sensors = next; renderChips(); } catch {} }); wrap.append(picker, chips); details.append(wrap); roomsWrap.append(line); } // Global delay settings // Delay row inside same card const row = document.createElement('div'); row.className = 'setting'; row.style.gridTemplateColumns = '1fr'; const txt = document.createElement('div'); txt.className = 'text'; const title = document.createElement('div'); title.className = 'title sp-owd-delays-title'; const desc = document.createElement('div'); desc.className = 'desc sp-owd-delays-desc'; txt.append(title, desc); row.append(txt); contentWrap.append(row); const grid = document.createElement('div'); grid.style.display = 'flex'; grid.style.gap = '10px'; grid.style.flexWrap = 'wrap'; grid.style.alignItems = 'flex-end'; const mkField = (cls, labelKey, value, onChange)=>{ const f = document.createElement('div'); f.className = 'sfield'; const l = document.createElement('div'); l.className = 'slabel ' + cls + '-label'; l.textContent = (this._t(labelKey) || labelKey); const inp = document.createElement('input'); inp.className = cls + ' settings-input'; inp.type = 'number'; inp.min='0'; inp.max='1440'; inp.step='1'; if (inp !== this.shadowRoot.activeElement) inp.value = String(value); inp.addEventListener('change', (e)=>{ let v = Math.round(Number(String(e.target.value||'').replace(',','.'))); if (!Number.isFinite(v)) v = 0; v = Math.max(0, Math.min(1440, v)); e.target.value = String(v); onChange(v); }); f.append(l, inp); return f; }; const openField = mkField('sp-owd-open-delay', 'owd.open_delay', Number(cur.open_delay_min||0), (v)=>{ try { if (!this._settingsDraft?.open_window) return; this._settingsDraft.open_window.open_delay_min = v; } catch {} }); const closeField = mkField('sp-owd-close-delay', 'owd.close_delay', Number(cur.close_delay_min||0), (v)=>{ try { if (!this._settingsDraft?.open_window) return; this._settingsDraft.open_window.close_delay_min = v; } catch {} }); grid.append(openField, closeField); contentWrap.append(grid); } catch {} } _renderSettingsPopupHolidaysTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="holidays"]'); if (!page) return; // Build markup once if (!page.dataset.built) { page.dataset.built = '1'; page.innerHTML = `
`; } const q = (sel)=> this.shadowRoot.querySelector(sel); // Ensure draft shape if (!Array.isArray(d.holidays_dates)) d.holidays_dates = []; if (d.holidays_source !== 'manual') d.holidays_source = 'calendar'; // Visibility of dependent controls const holOn = !!d.holidays_enabled; const src = (d.holidays_source === 'manual') ? 'manual' : 'calendar'; try { const editRow = q('.overlay-settings .sp-holidays-edit-setting'); const srcRow = q('.overlay-settings .sp-holidays-source-setting'); const entRow = q('.overlay-settings .sp-holidays-entity-setting'); const datesRow = q('.overlay-settings .sp-holidays-dates-setting'); if (editRow) editRow.style.display = holOn ? '' : 'none'; if (srcRow) srcRow.style.display = holOn ? '' : 'none'; if (entRow) entRow.style.display = (holOn && src === 'calendar') ? '' : 'none'; if (datesRow) datesRow.style.display = (holOn && src === 'manual') ? '' : 'none'; } catch {} // Segmented UI state try { const bCal = q('.overlay-settings .sp-holidays-source-cal'); const bMan = q('.overlay-settings .sp-holidays-source-man'); if (bCal) bCal.classList.toggle('active', src === 'calendar'); if (bMan) bMan.classList.toggle('active', src === 'manual'); } catch {} // Master toggle state try { const sw = q('.overlay-settings .sp-holidays-enable'); if (sw) sw.checked = !!d.holidays_enabled; } catch {} // Entity picker try { const p = q('.overlay-settings .sp-holidays-entity-picker'); if (p) { p.hass = this._hass; p.value = String(d.holidays_entity || ''); try { p.setAttribute('label', this._t('holidays.entity') || 'Holiday entity'); } catch {} } } catch {} // Dates chips const chips = q('.overlay-settings .sp-holidays-date-chips'); const renderChips = ()=>{ try { if (!chips) return; chips.innerHTML = ''; const arr = Array.isArray(this._settingsDraft?.holidays_dates) ? this._settingsDraft.holidays_dates.filter(Boolean).map(String) : []; const groups = Array.isArray(this._settingsDraft?.holidays_groups) ? this._settingsDraft.holidays_groups : []; const groupDates = new Set([].concat(...groups.map(g=>g.dates||[])).map(String)); const sorted = Array.from(new Set(arr.filter(x => /^\d{4}-\d{2}-\d{2}$/.test(String(x)) && !groupDates.has(String(x))))).sort(); for (const dt of sorted) { const chip = document.createElement('span'); chip.className = 'pill-chip'; const txt = document.createElement('span'); txt.textContent = dt; const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'rm'; rm.textContent = '×'; try { rm.setAttribute('aria-label', this._t('editor.remove') || 'Remove'); } catch {} rm.addEventListener('click', ()=>{ try { if (!this._settingsDraft) return; const cur = Array.isArray(this._settingsDraft.holidays_dates) ? this._settingsDraft.holidays_dates : []; this._settingsDraft.holidays_dates = cur.filter(x => String(x) !== dt); this._holidaysDatesTouched = true; renderChips(); } catch {} }); chip.append(txt, rm); chips.append(chip); } } catch {} }; renderChips(); // Range group chips const grpChips = q('.overlay-settings .sp-holidays-range-chips'); const renderGrpChips = ()=>{ try { if (!grpChips) return; grpChips.innerHTML = ''; const groups = Array.isArray(this._settingsDraft?.holidays_groups) ? this._settingsDraft.holidays_groups : []; for (const g of groups) { const chip = document.createElement('span'); chip.className='pill-chip'; const txt = document.createElement('span'); txt.textContent = `${g.from} – ${g.to}`; const rm = document.createElement('button'); rm.type='button'; rm.className='rm'; rm.textContent='×'; rm.addEventListener('click', ()=>{ try { if (!this._settingsDraft) return; const all = Array.isArray(this._settingsDraft.holidays_groups) ? this._settingsDraft.holidays_groups : []; const others = all.filter(x=>x!==g); const othersDates = new Set([].concat(...others.map(o=>o.dates||[])).map(String)); const cur = new Set((this._settingsDraft.holidays_dates||[]).map(String)); for (const d of (g.dates||[])) { if (!othersDates.has(String(d))) cur.delete(String(d)); } this._settingsDraft.holidays_groups = others; this._settingsDraft.holidays_dates = Array.from(cur).sort(); this._holidaysGroupsTouched = true; this._holidaysDatesTouched = true; renderChips(); renderGrpChips(); } catch {} }); chip.append(txt, rm); grpChips.append(chip); } } catch {} }; renderGrpChips(); // Bind events once if (!page.dataset.bound) { page.dataset.bound = '1'; try { const sw = q('.overlay-settings .sp-holidays-enable'); if (sw) sw.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; this._settingsDraft.holidays_enabled = !!e.target.checked; this._holidaysToggleTouched = true; this._renderSettingsPopupHolidaysTab(); } catch {} }); } catch {} try { const btn = q('.overlay-settings .sp-holidays-edit-btn'); if (btn) btn.addEventListener('click', ()=>{ try { const eid = (this._config?.entities || [])[0] || null; if (!eid) return; this._resumeSettingsAfterHoliday = true; this._suspendSettingsPopup(); this._openHolidayEditor(eid); } catch {} }); } catch {} try { const bCal = q('.overlay-settings .sp-holidays-source-cal'); const bMan = q('.overlay-settings .sp-holidays-source-man'); if (bCal) bCal.addEventListener('click', ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.holidays_source = 'calendar'; this._holidaysSourceTouched = true; this._renderSettingsPopupHolidaysTab(); } catch {} }); if (bMan) bMan.addEventListener('click', ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.holidays_source = 'manual'; this._holidaysSourceTouched = true; this._renderSettingsPopupHolidaysTab(); } catch {} }); } catch {} try { const p = q('.overlay-settings .sp-holidays-entity-picker'); if (p) p.addEventListener('value-changed', (e)=>{ try { if (!this._settingsDraft) return; this._settingsDraft.holidays_entity = e.detail?.value || ''; this._holidaysEntityTouched = true; } catch {} }); } catch {} try { const inp = q('.overlay-settings .sp-holidays-date-input'); const add = q('.overlay-settings .sp-holidays-add-date'); const msg = q('.overlay-settings .sp-holidays-add-date-msg'); const onAdd = ()=>{ try { if (!this._settingsDraft || !inp) return; const v = String(inp.value || '').trim(); if (!v) return; if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return; // Show info if v is inside any existing range; do not add try { const groups = Array.isArray(this._settingsDraft.holidays_groups) ? this._settingsDraft.holidays_groups : []; const covered = groups.some(g => Array.isArray(g?.dates) && g.dates.map(String).includes(String(v))); if (covered) { if (msg) msg.textContent = this._t('holidays.date_in_range') || 'Date already covered by a range'; return; } } catch {} const cur = Array.isArray(this._settingsDraft.holidays_dates) ? this._settingsDraft.holidays_dates.filter(Boolean).map(String) : []; const next = Array.from(new Set(cur.concat([v]))).sort(); this._settingsDraft.holidays_dates = next; this._holidaysDatesTouched = true; try { inp.value = ''; if (msg) msg.textContent = ''; } catch {} renderChips(); } catch {} }; if (add) add.addEventListener('click', onAdd); if (inp) inp.addEventListener('keydown', (e)=>{ if (e.key === 'Enter') { try { e.preventDefault(); } catch {} onAdd(); } }); } catch {} // Range add in settings overlay try { const inpf = q('.overlay-settings .sp-holidays-range-from'); const inpt = q('.overlay-settings .sp-holidays-range-to'); const addr = q('.overlay-settings .sp-holidays-add-range'); const onAddRange = ()=>{ try { if (!this._settingsDraft || !inpf || !inpt) return; const f = String(inpf.value||'').trim(); const t = String(inpt.value||'').trim(); if (!f || !t) return; if (!/^\d{4}-\d{2}-\d{2}$/.test(f) || !/^\d{4}-\d{2}-\d{2}$/.test(t)) return; let d1 = new Date(f+'T00:00:00'); let d2 = new Date(t+'T00:00:00'); if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return; if (d1.getTime() > d2.getTime()) { const tmp=d1; d1=d2; d2=tmp; } const fmt = (d)=>{ const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${dd}`; }; const curArr = Array.isArray(this._settingsDraft.holidays_dates) ? this._settingsDraft.holidays_dates.filter(Boolean).map(String) : []; const cur = new Set(curArr); const grpDates = []; for (let d=new Date(d1); d.getTime()<=d2.getTime(); d.setDate(d.getDate()+1)) { const s=fmt(d); cur.add(s); grpDates.push(s); } this._settingsDraft.holidays_dates = Array.from(cur).sort(); // Add a group tag for UI const gid = 'hg_'+Date.now()+'_'+Math.floor(Math.random()*1e6); const groups = Array.isArray(this._settingsDraft.holidays_groups) ? [...this._settingsDraft.holidays_groups] : []; groups.push({ id: gid, from: fmt(d1), to: fmt(d2), dates: grpDates }); this._settingsDraft.holidays_groups = groups; this._holidaysGroupsTouched = true; this._holidaysDatesTouched = true; // Mirror into live config so the main card view updates as well try { this._config.holidays_dates = Array.from(cur).sort(); const liveGroups = Array.isArray(this._config.holidays_groups) ? [...this._config.holidays_groups] : []; liveGroups.push({ id: gid, from: fmt(d1), to: fmt(d2), dates: grpDates.slice() }); this._config.holidays_groups = liveGroups; } catch {} try { inpf.value=''; inpt.value=''; } catch {} renderChips(); try { renderGrpChips(); } catch {} } catch {} }; if (addr) addr.addEventListener('click', onAddRange); if (inpf) inpf.addEventListener('keydown', (e)=>{ if (e.key==='Enter') { try { e.preventDefault(); } catch {} onAddRange(); } }); if (inpt) inpt.addEventListener('keydown', (e)=>{ if (e.key==='Enter') { try { e.preventDefault(); } catch {} onAddRange(); } }); } catch {} } } catch {} } _renderSettingsPopupColorsTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="colors"]'); if (!page) return; page.innerHTML = ''; // Wrap the entire Colors tab in one settings-card (border around everything) const outerCard = document.createElement('div'); outerCard.className = 'settings-card'; try { outerCard.style.padding = '10px'; outerCard.style.display = 'grid'; outerCard.style.gap = '10px'; } catch {} page.append(outerCard); // Ensure draft shape try { if (typeof d.color_global !== 'boolean') d.color_global = !!(this._config?.color_global); if (!d.color_ranges || typeof d.color_ranges !== 'object') d.color_ranges = {}; } catch {} const wantF = String(d.temp_unit || 'C').toUpperCase() === 'F'; const toDisp = (c)=>{ const v = Number(c); if (!Number.isFinite(v)) return 0; return wantF ? Math.round((v*9/5+32)*10)/10 : v; }; const fromDisp = (v)=>{ const n = Number(v); if (!Number.isFinite(n)) return NaN; return wantF ? ((n-32)*5/9) : n; }; const minC = Number.isFinite(Number(d.min_temp)) ? Number(d.min_temp) : (Number.isFinite(Number(this._config?.min_temp)) ? Number(this._config.min_temp) : 5); const maxC = Number.isFinite(Number(d.max_temp)) ? Number(d.max_temp) : (Number.isFinite(Number(this._config?.max_temp)) ? Number(this._config.max_temp) : 25); const minDisp = toDisp(minC); const maxDisp = toDisp(maxC); const defC = Number.isFinite(Number(d.default_temp)) ? Number(d.default_temp) : (Number.isFinite(Number(this._config?.default_temp)) ? Number(this._config.default_temp) : 20); // Mode toggle (Per room | All rooms) const modeWrap = document.createElement('div'); modeWrap.style.display = 'flex'; modeWrap.style.alignItems = 'center'; modeWrap.style.justifyContent = 'space-between'; modeWrap.style.gap = '10px'; const modeText = document.createElement('div'); modeText.style.display = 'grid'; modeText.style.gap = '2px'; const modeTitle = document.createElement('div'); modeTitle.className = 'colors-mode-title'; modeTitle.style.fontWeight = '600'; modeTitle.textContent = this._t('editor.colors.mode') || 'Color mode'; const modeDesc = document.createElement('div'); modeDesc.className = 'colors-mode-desc'; modeDesc.style.fontSize = '.85rem'; modeDesc.style.color = 'var(--secondary-text-color)'; modeDesc.textContent = this._t('editor.colors.mode.desc') || 'Choose per-room colors or one set for all rooms.'; modeText.append(modeTitle, modeDesc); const seg = document.createElement('div'); seg.className = 'seg colors-mode'; const bRoom = document.createElement('button'); bRoom.type = 'button'; bRoom.className = 'mode-room'; bRoom.textContent = this._t('editor.colors.mode_per_room') || 'Per room'; const bGlob = document.createElement('button'); bGlob.type = 'button'; bGlob.className = 'mode-global'; bGlob.textContent = this._t('editor.colors.mode_global') || 'All rooms'; const applySegUi = ()=>{ const on = !!d.color_global; bGlob.classList.toggle('active', on); bRoom.classList.toggle('active', !on); }; bRoom.onclick = ()=>{ try { d.color_global = false; applySegUi(); this._renderSettingsPopupColorsTab(); } catch {} }; bGlob.onclick = ()=>{ try { d.color_global = true; applySegUi(); this._renderSettingsPopupColorsTab(); } catch {} }; seg.append(bRoom, bGlob); applySegUi(); modeWrap.append(modeText, seg); outerCard.append(modeWrap); const isGlobal = !!d.color_global; const ents = Array.isArray(this._config?.entities) ? this._config.entities.filter(Boolean) : []; // If per-room mode and there are no rooms yet, show helper box if (!isGlobal && ents.length === 0) { const box = document.createElement('div'); box.className = 'empty-box'; const p = document.createElement('div'); p.textContent = this._t('editor.colors.empty') || 'No rooms added yet.'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'add-entity-btn'; btn.innerHTML = '' + (this._t('editor.colors.go_add') || 'Add room') + ''; btn.onclick = ()=>{ this._setSettingsPopupTab('rooms'); }; box.append(p, btn); outerCard.append(box); } // Remember open/closed state while popup is open try { if (!this._openPopupColorRows) this._openPopupColorRows = new Set(); } catch {} const roomsWrap = document.createElement('div'); try { roomsWrap.style.display = 'grid'; roomsWrap.style.gap = '10px'; } catch {} outerCard.append(roomsWrap); const buildFoldout = (key, titleText)=>{ const line = document.createElement('div'); line.className = 'inline'; const summary = document.createElement('div'); summary.className = 'summary'; const sumLeft = document.createElement('div'); sumLeft.className = 'summary-left'; const exp = document.createElement('button'); exp.type = 'button'; exp.className = 'expander'; exp.innerHTML = ''; const txtWrap = document.createElement('div'); txtWrap.className = 'summary-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'summary-title'; titleSpan.textContent = titleText; // Inline styles to match the editor foldout appearance (robust across contexts) try { summary.style.display = 'flex'; summary.style.alignItems = 'center'; summary.style.justifyContent = 'space-between'; summary.style.padding = '6px'; summary.style.border = '1px dashed var(--divider-color)'; summary.style.borderRadius = '8px'; summary.style.background = 'var(--secondary-background-color, transparent)'; sumLeft.style.display = 'flex'; sumLeft.style.alignItems = 'center'; sumLeft.style.gap = '8px'; sumLeft.style.minWidth = '0'; txtWrap.style.display = 'grid'; txtWrap.style.gap = '2px'; txtWrap.style.minWidth = '0'; titleSpan.style.fontWeight = '600'; titleSpan.style.overflow = 'hidden'; titleSpan.style.textOverflow = 'ellipsis'; titleSpan.style.whiteSpace = 'nowrap'; titleSpan.style.maxWidth = '320px'; exp.style.width = '28px'; exp.style.height = '28px'; exp.style.borderRadius = '8px'; exp.style.border = '1px solid var(--divider-color)'; exp.style.background = 'var(--card-background-color)'; exp.style.display = 'inline-flex'; exp.style.alignItems = 'center'; exp.style.justifyContent = 'center'; exp.style.cursor = 'pointer'; } catch {} txtWrap.append(titleSpan); sumLeft.append(exp, txtWrap); summary.append(sumLeft); line.append(summary); const details = document.createElement('div'); details.className = 'details'; line.append(details); try { details.style.display = 'none'; details.style.gridTemplateColumns = 'minmax(250px, 440px)'; details.style.gap = '6px'; details.style.padding = '6px'; details.style.border = '1px dashed var(--divider-color)'; details.style.borderRadius = '8px'; details.style.background = 'var(--secondary-background-color, transparent)'; } catch {} const applyOpen = (isOpen)=>{ try { if (isOpen) { line.classList.add('open'); details.style.display = 'grid'; exp.style.transform = 'rotate(180deg)'; } else { line.classList.remove('open'); details.style.display = 'none'; exp.style.transform = ''; } } catch {} }; const toggle = (ev)=>{ try { ev?.stopPropagation?.(); } catch {} const nextOpen = !line.classList.contains('open'); applyOpen(nextOpen); try { if (!this._openPopupColorRows) this._openPopupColorRows = new Set(); if (nextOpen) this._openPopupColorRows.add(String(key)); else this._openPopupColorRows.delete(String(key)); } catch {} }; exp.addEventListener('click', toggle); summary.style.cursor = 'pointer'; summary.addEventListener('click', (ev)=>{ try { if (ev?.target?.closest?.('button, a, input, select, textarea')) return; } catch {} toggle(ev); }); try { applyOpen(!!this._openPopupColorRows?.has(String(key))); } catch {} return { line, details }; }; const renderRangesForKey = (key, details)=>{ details.innerHTML = ''; const card = document.createElement('div'); card.className = 'settings-card'; try { card.style.padding = '10px'; card.style.gap = '8px'; } catch {} details.append(card); const head = document.createElement('div'); head.className = 'colors-head'; try { head.style.display = 'grid'; head.style.gridTemplateColumns = '88px 88px 70px 1fr'; head.style.gap = '8px'; head.style.alignItems = 'end'; } catch {} const mkHead = (k)=>{ const el = document.createElement('div'); el.className = 'slabel'; el.textContent = this._t(k) || k; return el; }; head.append( mkHead('editor.colors.col_from'), mkHead('editor.colors.col_to'), mkHead('editor.colors.col_color'), document.createElement('div') ); const getArr = ()=>{ const map = (d.color_ranges && typeof d.color_ranges === 'object') ? d.color_ranges : {}; return Array.isArray(map[key]) ? map[key] : []; }; const setArr = (arr)=>{ try { const map = (d.color_ranges && typeof d.color_ranges === 'object') ? d.color_ranges : {}; d.color_ranges = { ...map, [key]: arr }; } catch {} }; const ranges = getArr(); if (ranges.length > 0) card.append(head); ranges.forEach((r, ri)=>{ const outer = document.createElement('div'); outer.className = 'color-item'; const row = document.createElement('div'); row.className = 'colors-row'; try { row.style.display = 'grid'; row.style.gridTemplateColumns = '88px 88px 70px 1fr'; row.style.gap = '8px'; row.style.alignItems = 'center'; } catch {} const f = document.createElement('input'); f.type = 'number'; f.step = '0.5'; f.className = 'settings-input'; f.value = String(toDisp(r?.from ?? defC)); f.setAttribute('aria-label', this._t('editor.colors.col_from')); f.title = this._t('editor.colors.col_from'); const t = document.createElement('input'); t.type = 'number'; t.step = '0.5'; t.className = 'settings-input'; t.value = String(toDisp(r?.to ?? defC)); t.setAttribute('aria-label', this._t('editor.colors.col_to')); t.title = this._t('editor.colors.col_to'); const c = document.createElement('input'); c.type = 'color'; c.value = String(r?.color || '#ffb347'); c.setAttribute('aria-label', this._t('editor.colors.col_color')); c.title = this._t('editor.colors.col_color'); try { c.style.height = '28px'; c.style.width = '70px'; c.style.padding = '0'; c.style.borderRadius = '8px'; c.style.border = '1px solid var(--divider-color)'; c.style.background = 'var(--card-background-color)'; c.style.boxSizing = 'border-box'; } catch {} f.min = String(minDisp); t.min = String(minDisp); f.max = String(maxDisp); t.max = String(maxDisp); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn ghost danger'; rm.innerHTML = '' + (this._t('editor.remove') || 'Remove') + ''; try { rm.style.height = '28px'; rm.style.padding = '2px 10px'; rm.style.borderRadius = '8px'; rm.style.justifySelf = 'end'; rm.style.width = 'max-content'; rm.style.display = 'inline-flex'; rm.style.alignItems = 'center'; rm.style.gap = '6px'; rm.querySelector?.('ha-icon')?.style && (rm.querySelector('ha-icon').style.setProperty('--mdc-icon-size','16px')); } catch {} const updateAt = (idx, patch)=>{ const arr = getArr(); const next = arr.map((x, i)=> (i===idx ? ({ ...x, ...patch }) : x)); setArr(next); }; f.onchange = ()=>{ let v = parseFloat(String(f.value||'').replace(',','.')); if (!Number.isFinite(v)) v = minDisp; v = Math.max(minDisp, Math.min(maxDisp, v)); f.value = String(v); const vC = fromDisp(v); const clampC = Math.max(minC, Math.min(maxC, Number.isFinite(vC) ? vC : defC)); updateAt(ri, { from: clampC }); }; t.onchange = ()=>{ let v = parseFloat(String(t.value||'').replace(',','.')); if (!Number.isFinite(v)) v = minDisp; v = Math.max(minDisp, Math.min(maxDisp, v)); t.value = String(v); const vC = fromDisp(v); const clampC = Math.max(minC, Math.min(maxC, Number.isFinite(vC) ? vC : defC)); updateAt(ri, { to: clampC }); }; c.onchange = ()=>{ updateAt(ri, { color: c.value }); }; rm.onclick = ()=>{ const arr = getArr(); const next = [...arr]; next.splice(ri, 1); setArr(next); renderRangesForKey(key, details); }; row.append(f, t, c, rm); outer.append(row); card.append(outer); }); const add = document.createElement('button'); add.className = 'add-entity-btn'; add.type = 'button'; add.innerHTML = '' + (this._t('editor.heat_colors.add') || 'Add color range') + ''; try { add.style.width = 'max-content'; add.style.justifySelf = 'start'; } catch {} add.onclick = ()=>{ const arr = getArr(); const next = [...arr, { from: defC, to: defC, color: '#ffb347' }]; setArr(next); renderRangesForKey(key, details); }; card.append(add); }; if (isGlobal) { const key = '*'; const f = buildFoldout(key, this._t('editor.colors.global_title') || 'All rooms'); roomsWrap.append(f.line); renderRangesForKey(key, f.details); } else { for (const eid of ents) { const labels = this._config?.labels || {}; const title = labels?.[eid] || this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1] || eid); const f = buildFoldout(eid, title); roomsWrap.append(f.line); renderRangesForKey(eid, f.details); } } // Actions (Update schedule + Clear all) const actions = document.createElement('div'); actions.className = 'colors-actions'; actions.style.display = 'flex'; actions.style.gap = '8px'; actions.style.alignItems = 'center'; actions.style.flexWrap = 'wrap'; const clrAll = document.createElement('button'); clrAll.className = 'remove-btn clr-colors-all-btn'; clrAll.type = 'button'; try { clrAll.style.width = 'max-content'; clrAll.style.justifySelf = 'start'; } catch {} clrAll.innerHTML = '' + (this._t('editor.colors.clear_all') || 'Clear all color data') + ''; clrAll.onclick = ()=>{ const ok = confirm(this._t('editor.colors.clear_all_confirm') || 'This will delete all color ranges. Continue?'); if (!ok) return; try { d.color_ranges = {}; } catch {} this._renderSettingsPopupColorsTab(); }; actions.append(clrAll); outerCard.append(actions); } catch {} } _renderSettingsPopupBoilerTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="boiler"]'); if (!page) return; const wantF = String(d?.temp_unit || 'C').toUpperCase() === 'F'; const toDispDelta = (nC)=>{ const v = Number(nC); if (!Number.isFinite(v)) return 0; return wantF ? (v * 9/5) : v; }; const fromDispDelta = (nDisp)=>{ const v = Number(nDisp); if (!Number.isFinite(v)) return NaN; return wantF ? (v * 5/9) : v; }; const round1 = (n)=>{ const v = Number(n); if (!Number.isFinite(v)) return 0; return Math.round(v * 10) / 10; }; if (!page.dataset.built) { page.dataset.built = '1'; page.innerHTML = `
`; } const q = (sel)=> this.shadowRoot.querySelector(sel); // Visibility of dependent controls const boilerOn = !!d.boiler_enabled; const multiOn = !!d.boiler_multi_enabled; try { const switchRow = q('.overlay-settings .sp-boiler-switch-setting'); const roomsRow = q('.overlay-settings .sp-boiler-rooms-setting'); const offRow = q('.overlay-settings .sp-boiler-offsets-setting'); const multiRow = q('.overlay-settings .sp-boiler-multi-setting'); const multiRoomsRow = q('.overlay-settings .sp-boiler-multi-rooms-setting'); if (switchRow) switchRow.style.display = (boilerOn && !multiOn) ? '' : 'none'; if (roomsRow) roomsRow.style.display = (boilerOn && !multiOn) ? '' : 'none'; if (offRow) offRow.style.display = (boilerOn && !multiOn) ? '' : 'none'; if (multiRow) multiRow.style.display = boilerOn ? '' : 'none'; if (multiRoomsRow) multiRoomsRow.style.display = (boilerOn && multiOn) ? '' : 'none'; } catch {} // Master toggle try { const sw = q('.overlay-settings .sp-boiler-enable'); if (sw) sw.checked = !!d.boiler_enabled; } catch {} // Multi toggle try { const mw = q('.overlay-settings .sp-boiler-multi'); if (mw) mw.checked = !!d.boiler_multi_enabled; } catch {} // Pickers try { const domSel = q('.overlay-settings .sp-boiler-switch-domain'); const p = q('.overlay-settings .sp-boiler-switch-picker'); const swDom = String(d.boiler_switch || '').split('.')[0]; const raw = String(d.boiler_switch_domain || '').toLowerCase().trim(); const dom = (swDom === 'input_boolean' || swDom === 'switch') ? swDom : ((raw === 'input_boolean') ? 'input_boolean' : 'switch'); try { d.boiler_switch_domain = dom; } catch {} if (domSel) domSel.value = dom; if (p) { try { p.setAttribute('include-domains', JSON.stringify([dom])); } catch {} p.hass = this._hass; p.value = String(d.boiler_switch || ''); } } catch {} // Rooms list (checkboxes) try { const list = q('.overlay-settings .sp-boiler-rooms-list'); if (list) { list.innerHTML = ''; const ents = Array.isArray(d.entities) ? d.entities.map(String) : []; const rooms = ents.filter(e=>String(e).startsWith('climate.')); const selected = Array.isArray(d.boiler_rooms) ? new Set(d.boiler_rooms.map(String)) : new Set(rooms); const labels = (this._config && this._config.labels && typeof this._config.labels === 'object') ? this._config.labels : {}; const byName = (eid)=>{ try { const lbl = labels && Object.prototype.hasOwnProperty.call(labels, eid) ? String(labels[eid]||'').trim() : ''; if (lbl) return lbl; return String(this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1] || eid)); } catch { return eid; } }; for (const eid of rooms) { const row = document.createElement('label'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = selected.has(eid); cb.setAttribute('data-eid', eid); cb.addEventListener('change', (ev)=>{ try { if (!this._settingsDraft) return; const cur = Array.isArray(this._settingsDraft.boiler_rooms) ? this._settingsDraft.boiler_rooms.map(String) : rooms; const curSet = new Set(cur); const on = !!ev?.target?.checked; if (on) curSet.add(eid); else curSet.delete(eid); // Preserve order as in entities[] this._settingsDraft.boiler_rooms = rooms.filter(x => curSet.has(x)); } catch {} }); const sp = document.createElement('span'); sp.textContent = byName(eid); row.append(cb, sp); list.append(row); } } } catch {} // Multi-boiler rooms list (per-room foldouts) try { const list = q('.overlay-settings .sp-boiler-multi-rooms-list'); if (list) { list.innerHTML = ''; const ents = Array.isArray(d.entities) ? d.entities.map(String) : []; const rooms = ents.filter(e=>String(e).startsWith('climate.')); const labels = (this._config && this._config.labels && typeof this._config.labels === 'object') ? this._config.labels : {}; const byName = (eid)=>{ try { const lbl = labels && Object.prototype.hasOwnProperty.call(labels, eid) ? String(labels[eid]||'').trim() : ''; if (lbl) return lbl; return String(this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1] || eid)); } catch { return eid; } }; if (!this._openBoilerMultiRooms) this._openBoilerMultiRooms = new Set(); const ensureDraftMap = ()=>{ if (!this._settingsDraft) return; if (!this._settingsDraft.boiler_room_settings || typeof this._settingsDraft.boiler_room_settings !== 'object') this._settingsDraft.boiler_room_settings = {}; }; const getRoomCfg = (eid)=>{ const src = (d.boiler_room_settings && typeof d.boiler_room_settings === 'object') ? d.boiler_room_settings : {}; const raw = (src && Object.prototype.hasOwnProperty.call(src, eid)) ? src[eid] : null; const sw = String(raw?.switch || ''); const domFromEid = (sw ? String(sw).split('.')[0] : ''); const rawDom = String(raw?.switch_domain || '').toLowerCase().trim(); const dom = (domFromEid === 'input_boolean' || domFromEid === 'switch') ? domFromEid : ((rawDom === 'input_boolean') ? 'input_boolean' : 'switch'); const onOff = Number.isFinite(Number(raw?.on_offset)) ? Number(raw.on_offset) : Number(d.boiler_on_offset ?? 0); const offOff = Number.isFinite(Number(raw?.off_offset)) ? Number(raw.off_offset) : Number(d.boiler_off_offset ?? 0); const enabled = !!raw?.enabled; return { enabled, switch: sw, switch_domain: dom, on_offset: onOff, off_offset: offOff }; }; const updateRoomCfg = (eid, patch)=>{ try { if (!this._settingsDraft) return; ensureDraftMap(); const prev = (this._settingsDraft.boiler_room_settings && this._settingsDraft.boiler_room_settings[eid]) ? this._settingsDraft.boiler_room_settings[eid] : {}; this._settingsDraft.boiler_room_settings[eid] = { ...prev, ...patch }; } catch {} }; for (const eid of rooms) { const cfg = getRoomCfg(eid); const line = document.createElement('div'); line.className = 'inline'; const summary = document.createElement('div'); summary.className = 'summary'; const sumLeft = document.createElement('div'); sumLeft.className = 'summary-left'; const exp = document.createElement('button'); exp.type = 'button'; exp.className = 'expander'; exp.innerHTML = ''; const txtWrap = document.createElement('div'); txtWrap.className = 'summary-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'summary-title'; titleSpan.textContent = byName(eid); const subSpan = document.createElement('span'); subSpan.className = 'summary-sub'; const swLabel = (cfg.switch ? (this._hass?.states?.[cfg.switch]?.attributes?.friendly_name || cfg.switch) : ''); subSpan.textContent = cfg.enabled ? (swLabel || '') : ''; txtWrap.append(titleSpan, subSpan); sumLeft.append(exp, txtWrap); summary.append(sumLeft); line.append(summary); const details = document.createElement('div'); details.className = 'details'; line.append(details); // Styles (match other popup foldouts) try { summary.style.display = 'flex'; summary.style.alignItems = 'center'; summary.style.justifyContent = 'space-between'; summary.style.padding = '6px'; summary.style.border = '1px dashed var(--divider-color)'; summary.style.borderRadius = '8px'; summary.style.background = 'var(--secondary-background-color, transparent)'; sumLeft.style.display = 'flex'; sumLeft.style.alignItems = 'center'; sumLeft.style.gap = '8px'; sumLeft.style.minWidth = '0'; txtWrap.style.display = 'grid'; txtWrap.style.gap = '2px'; txtWrap.style.minWidth = '0'; titleSpan.style.fontWeight = '600'; titleSpan.style.overflow = 'hidden'; titleSpan.style.textOverflow = 'ellipsis'; titleSpan.style.whiteSpace = 'nowrap'; titleSpan.style.maxWidth = '320px'; subSpan.style.fontSize = '.85rem'; subSpan.style.color = 'var(--secondary-text-color)'; subSpan.style.overflow = 'hidden'; subSpan.style.textOverflow = 'ellipsis'; subSpan.style.whiteSpace = 'nowrap'; subSpan.style.maxWidth = '320px'; exp.style.width = '28px'; exp.style.height = '28px'; exp.style.borderRadius = '8px'; exp.style.border = '1px solid var(--divider-color)'; exp.style.background = 'var(--card-background-color)'; exp.style.display = 'inline-flex'; exp.style.alignItems = 'center'; exp.style.justifyContent = 'center'; exp.style.cursor = 'pointer'; details.style.display = 'none'; details.style.gridTemplateColumns = 'minmax(250px, 520px)'; details.style.gap = '8px'; details.style.padding = '8px'; details.style.border = '1px dashed var(--divider-color)'; details.style.borderRadius = '8px'; details.style.background = 'var(--secondary-background-color, transparent)'; } catch {} const rowKey = String(eid); const applyOpen = (isOpen)=>{ try { if (isOpen) { line.classList.add('open'); details.style.display = 'grid'; exp.style.transform = 'rotate(180deg)'; } else { line.classList.remove('open'); details.style.display = 'none'; exp.style.transform = ''; } } catch {} }; const toggle = (ev)=>{ try { ev?.stopPropagation?.(); } catch {} const nextOpen = !line.classList.contains('open'); applyOpen(nextOpen); try { if (!this._openBoilerMultiRooms) this._openBoilerMultiRooms = new Set(); if (nextOpen) this._openBoilerMultiRooms.add(rowKey); else this._openBoilerMultiRooms.delete(rowKey); } catch {} }; exp.addEventListener('click', toggle); summary.style.cursor = 'pointer'; summary.addEventListener('click', (ev)=>{ try { if (ev?.target?.closest?.('button, a, input, select, textarea, tt-entity-picker, ha-entity-picker, ha-switch')) return; } catch {} toggle(ev); }); try { applyOpen(!!this._openBoilerMultiRooms?.has(rowKey)); } catch {} // Details content const left = document.createElement('div'); left.style.display = 'grid'; left.style.gap = '6px'; const includeRow = document.createElement('div'); includeRow.style.display = 'flex'; includeRow.style.alignItems = 'center'; includeRow.style.justifyContent = 'space-between'; includeRow.style.gap = '10px'; const incText = document.createElement('div'); incText.style.display = 'grid'; incText.style.gap = '2px'; const incTitle = document.createElement('div'); incTitle.style.fontWeight = '600'; incTitle.textContent = this._t('boiler.room.enable') || 'Include room'; const incDesc = document.createElement('div'); incDesc.style.fontSize = '.85rem'; incDesc.style.color = 'var(--secondary-text-color)'; incDesc.textContent = this._t('boiler.room.enable.desc') || ''; incText.append(incTitle, incDesc); const incSwitch = document.createElement('ha-switch'); incSwitch.checked = !!cfg.enabled; incSwitch.addEventListener('change', (e)=>{ updateRoomCfg(eid, { enabled: !!e.target.checked }); this._renderSettingsPopupBoilerTab(); }); includeRow.append(incText, incSwitch); const domRow = document.createElement('div'); domRow.className = 'sfield'; const domLabel = document.createElement('div'); domLabel.className = 'slabel'; domLabel.textContent = this._t('boiler.switch_type') || 'Entity type'; const domSel = document.createElement('select'); domSel.className = 'settings-input'; domSel.innerHTML = ''; domSel.value = (cfg.switch_domain === 'input_boolean') ? 'input_boolean' : 'switch'; domSel.addEventListener('change', (e)=>{ const v = String(e?.target?.value || '').toLowerCase().trim(); updateRoomCfg(eid, { switch_domain: (v === 'input_boolean') ? 'input_boolean' : 'switch', switch: '' }); this._renderSettingsPopupBoilerTab(); }); domRow.append(domLabel, domSel); const pick = document.createElement('tt-entity-picker'); try { pick.style.display = 'block'; pick.style.minHeight = '48px'; pick.style.maxWidth = '440px'; } catch {} try { pick.hass = this._hass; } catch {} pick.setAttribute('include-domains', JSON.stringify([(cfg.switch_domain === 'input_boolean') ? 'input_boolean' : 'switch'])); pick.setAttribute('label', this._t('boiler.switch') || 'Boiler switch'); try { pick.value = String(cfg.switch || ''); } catch {} pick.addEventListener('value-changed', (e)=>{ try { const v = String(e.detail?.value || ''); const dom = String(v||'').split('.')[0]; const domVal = (dom === 'input_boolean') ? 'input_boolean' : 'switch'; updateRoomCfg(eid, { switch: v, switch_domain: domVal }); this._renderSettingsPopupBoilerTab(); } catch {} }); const offsetsTitle = document.createElement('div'); offsetsTitle.style.fontWeight = '600'; offsetsTitle.textContent = this._t('boiler.offsets') || 'Offsets'; const offsetsDesc = document.createElement('div'); offsetsDesc.style.fontSize = '.85rem'; offsetsDesc.style.color = 'var(--secondary-text-color)'; offsetsDesc.textContent = this._t('boiler.offsets.desc') || ''; const grid = document.createElement('div'); grid.className = 'grid2'; grid.style.marginTop = '6px'; const onField = document.createElement('div'); onField.className = 'sfield'; const onLabel = document.createElement('div'); onLabel.className = 'slabel'; onLabel.textContent = String(this._t('boiler.on_offset') || '').replace('°C', this._unitSymbol()); const onInput = document.createElement('input'); onInput.className = 'settings-input'; onInput.type = 'number'; onInput.step = '0.1'; onInput.min = String(wantF ? -18 : -10); onInput.max = String(wantF ? 18 : 10); onInput.value = String(round1(toDispDelta(cfg.on_offset ?? 0))); onInput.addEventListener('change', (e)=>{ try { const raw = String(e?.target?.value ?? '').trim().replace(',', '.'); if (raw === '') return; const nDisp = Number(raw); const nC = fromDispDelta(nDisp); if (!Number.isFinite(nC)) return; updateRoomCfg(eid, { on_offset: Math.max(-10, Math.min(10, nC)) }); } catch {} }); onField.append(onLabel, onInput); const offField = document.createElement('div'); offField.className = 'sfield'; const offLabel = document.createElement('div'); offLabel.className = 'slabel'; offLabel.textContent = String(this._t('boiler.off_offset') || '').replace('°C', this._unitSymbol()); const offInput = document.createElement('input'); offInput.className = 'settings-input'; offInput.type = 'number'; offInput.step = '0.1'; offInput.min = String(wantF ? -18 : -10); offInput.max = String(wantF ? 18 : 10); offInput.value = String(round1(toDispDelta(cfg.off_offset ?? 0))); offInput.addEventListener('change', (e)=>{ try { const raw = String(e?.target?.value ?? '').trim().replace(',', '.'); if (raw === '') return; const nDisp = Number(raw); const nC = fromDispDelta(nDisp); if (!Number.isFinite(nC)) return; updateRoomCfg(eid, { off_offset: Math.max(-10, Math.min(10, nC)) }); } catch {} }); offField.append(offLabel, offInput); grid.append(onField, offField); left.append(includeRow, domRow, pick, offsetsTitle, offsetsDesc, grid); details.append(left); list.append(line); } } } catch {} // Offsets try { const onIn = q('.overlay-settings .sp-boiler-on-offset'); if (onIn) { const lim = wantF ? 18 : 10; onIn.min = String(-lim); onIn.max = String(lim); onIn.value = String(round1(toDispDelta(d.boiler_on_offset ?? 0))); } } catch {} try { const offIn = q('.overlay-settings .sp-boiler-off-offset'); if (offIn) { const lim = wantF ? 18 : 10; offIn.min = String(-lim); offIn.max = String(lim); offIn.value = String(round1(toDispDelta(d.boiler_off_offset ?? 0))); } } catch {} // Bind events once if (!page.dataset.bound) { page.dataset.bound = '1'; try { const sw = q('.overlay-settings .sp-boiler-enable'); if (sw) sw.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; this._settingsDraft.boiler_enabled = !!e.target.checked; this._boilerToggleTouched = true; this._renderSettingsPopupBoilerTab(); } catch {} }); } catch {} try { const mw = q('.overlay-settings .sp-boiler-multi'); if (mw) mw.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; this._settingsDraft.boiler_multi_enabled = !!e.target.checked; this._renderSettingsPopupBoilerTab(); } catch {} }); } catch {} try { const p = q('.overlay-settings .sp-boiler-switch-picker'); if (p) p.addEventListener('value-changed', (e)=>{ try { if (!this._settingsDraft) return; const v = String(e.detail?.value || ''); this._settingsDraft.boiler_switch = v; const dom = String(v||'').split('.')[0]; if (dom === 'switch' || dom === 'input_boolean') { this._settingsDraft.boiler_switch_domain = (dom === 'input_boolean') ? 'input_boolean' : 'switch'; this._renderSettingsPopupBoilerTab(); } } catch {} }); } catch {} try { const s = q('.overlay-settings .sp-boiler-switch-domain'); if (s) s.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; const v = String(e?.target?.value || '').toLowerCase().trim(); this._settingsDraft.boiler_switch_domain = (v === 'input_boolean') ? 'input_boolean' : 'switch'; // Clear selected entity when changing type to avoid mismatch this._settingsDraft.boiler_switch = ''; this._renderSettingsPopupBoilerTab(); } catch {} }); } catch {} try { const onIn = q('.overlay-settings .sp-boiler-on-offset'); if (onIn) onIn.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; const raw = String(e?.target?.value ?? '').trim().replace(',', '.'); if (raw === '') return; const nDisp = Number(raw); const nC = fromDispDelta(nDisp); if (!Number.isFinite(nC)) return; this._settingsDraft.boiler_on_offset = Math.max(-10, Math.min(10, nC)); } catch {} }); } catch {} try { const offIn = q('.overlay-settings .sp-boiler-off-offset'); if (offIn) offIn.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; const raw = String(e?.target?.value ?? '').trim().replace(',', '.'); if (raw === '') return; const nDisp = Number(raw); const nC = fromDispDelta(nDisp); if (!Number.isFinite(nC)) return; this._settingsDraft.boiler_off_offset = Math.max(-10, Math.min(10, nC)); } catch {} }); } catch {} } } catch {} } _renderSettingsPopupWeekdaysTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="weekdays"]'); if (!page) return; // Build markup once if (!page.dataset.built) { page.dataset.built = '1'; page.innerHTML = `
`; } const q = (sel)=> this.shadowRoot.querySelector(sel); const setChk = (sel, v)=>{ const el=q(sel); if (el) el.checked = !!v; }; setChk('.overlay-settings .sp-week-enable', d.weekdays_enabled); setChk('.overlay-settings .sp-week-view-switch', d.weekdays_view_switch_in_timeline); const view = (String(d.weekdays_view || 'all_rooms_one_day') === 'one_room_all_days') ? 'one_room_all_days' : 'all_rooms_one_day'; try { const bRooms = q('.overlay-settings .sp-week-view-rooms'); const bDays = q('.overlay-settings .sp-week-view-days'); bRooms && bRooms.classList.toggle('active', view === 'all_rooms_one_day'); bDays && bDays.classList.toggle('active', view === 'one_room_all_days'); // Disable when weekdays are off (match editor behavior) const on = !!d.weekdays_enabled; const row = q('.overlay-settings .sp-week-view-setting'); if (row) row.style.opacity = on ? '' : '.5'; if (bRooms) { bRooms.disabled = !on; bRooms.setAttribute('aria-disabled', (!on).toString()); } if (bDays) { bDays.disabled = !on; bDays.setAttribute('aria-disabled', (!on).toString()); } const swRow = q('.overlay-settings .sp-week-view-switch-setting'); if (swRow) swRow.style.opacity = on ? '' : '.5'; const swInline = q('.overlay-settings .sp-week-view-switch'); if (swInline) { swInline.disabled = !on; swInline.setAttribute('aria-disabled', (!on).toString()); } // Open weekdays editor button const hasEnts = Array.isArray(this._config?.entities) && this._config.entities.filter(Boolean).length > 0; const openRow = q('.overlay-settings .sp-week-open-setting'); if (openRow) openRow.style.display = (on && hasEnts) ? '' : 'none'; const openBtn = q('.overlay-settings .sp-week-open-btn'); if (openBtn) { openBtn.disabled = !(on && hasEnts); openBtn.setAttribute('aria-disabled', (!(on && hasEnts)).toString()); } } catch {} // Bind events once if (!page.dataset.bound) { page.dataset.bound = '1'; try { const sw = q('.overlay-settings .sp-week-enable'); if (sw) sw.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; const wasOn = !!this._settingsDraft.weekdays_enabled; const on = !!e.target.checked; this._settingsDraft.weekdays_enabled = on; if (!on) { this._settingsDraft.weekdays_copy_from_main_mode = ''; this._settingsDraft.weekdays_copy_from_main_target = ''; } else if (!wasOn && this._hasAnyMainBlocks()) { this._settingsDraft.weekdays_copy_from_main_mode = ''; this._settingsDraft.weekdays_copy_from_main_target = ''; this._openWeekCopyModal(); } // When enabling, default view to all rooms if (on && (!this._settingsDraft.weekdays_view || this._settingsDraft.weekdays_view === 'undefined')) { this._settingsDraft.weekdays_view = 'all_rooms_one_day'; } this._renderSettingsPopupWeekdaysTab(); } catch {} }); } catch {} try { const swInline = q('.overlay-settings .sp-week-view-switch'); if (swInline) swInline.addEventListener('change', (e)=>{ try { if (!this._settingsDraft) return; this._settingsDraft.weekdays_view_switch_in_timeline = !!e.target.checked; this._renderSettingsPopupWeekdaysTab(); } catch {} }); } catch {} const applyView = (mode)=>{ try { if (!this._settingsDraft) return; if (!this._settingsDraft.weekdays_enabled) return; this._settingsDraft.weekdays_view = (mode === 'one_room_all_days') ? 'one_room_all_days' : 'all_rooms_one_day'; this._renderSettingsPopupWeekdaysTab(); } catch {} }; try { const bRooms = q('.overlay-settings .sp-week-view-rooms'); const bDays = q('.overlay-settings .sp-week-view-days'); if (bRooms) bRooms.addEventListener('click', ()=>applyView('all_rooms_one_day')); if (bDays) bDays.addEventListener('click', ()=>applyView('one_room_all_days')); } catch {} try { const seg = q('.overlay-settings .sp-week-view'); if (seg) seg.addEventListener('click', (ev)=>{ try { const el = ev.target && ev.target.closest ? ev.target.closest('.sp-week-view-rooms, .sp-week-view-days') : null; if (!el) return; if (el.classList.contains('sp-week-view-rooms')) applyView('all_rooms_one_day'); else if (el.classList.contains('sp-week-view-days')) applyView('one_room_all_days'); } catch {} }); } catch {} try { const btn = q('.overlay-settings .sp-week-open-btn'); if (btn) btn.addEventListener('click', ()=>{ try { const ents = Array.isArray(this._config?.entities) ? this._config.entities.filter(Boolean).map(String) : []; if (!ents.length) return; const want = String(this._settingsDraft?.weekdays_selected_room || this._config?.weekdays_selected_room || ''); const eid = (want && ents.includes(want)) ? want : (ents[0] || null); if (!eid) return; this._resumeSettingsAfterWeekdays = true; this._suspendSettingsPopup(); this._openWeeklyEditor(eid); } catch {} }); } catch {} } } catch {} } _renderSettingsPopupPresenceSensorTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const page = this.shadowRoot.querySelector('.overlay-settings .settings-page[data-page="presence_sensor"]'); if (!page) return; page.innerHTML = ''; const card = document.createElement('div'); card.className = 'settings-card'; page.append(card); // Toggle const row = document.createElement('div'); row.className = 'setting'; const text = document.createElement('div'); text.className = 'text'; const title = document.createElement('div'); title.className = 'title'; title.textContent = this._t('editor.presence_sensor.enable.title') || 'Presence sensor'; const desc = document.createElement('div'); desc.className = 'desc'; desc.textContent = this._t('editor.presence_sensor.enable.desc') || 'Enable per-room presence sensor configuration.'; text.append(title, desc); const sw = document.createElement('ha-switch'); const on = !!d.presence_sensor_enabled; try { sw.checked = on; sw.disabled = false; } catch {} sw.addEventListener('change', ()=>{ try { d.presence_sensor_enabled = !!sw.checked; this._renderSettingsPopupPresenceSensorTab(); } catch {} }); row.append(text, sw); card.append(row); // Rooms list const ents = (()=>{ try { const arr = Array.isArray(d.entities) ? d.entities : (Array.isArray(this._config?.entities) ? this._config.entities : []); return arr.filter(Boolean).map(String); } catch { return []; } })(); if (!ents.length) { const box = document.createElement('div'); box.className = 'empty-box'; const p = document.createElement('div'); p.textContent = this._t('editor.colors.empty') || 'No rooms added yet.'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'add-entity-btn'; btn.innerHTML = '' + (this._t('editor.colors.go_add') || 'Add room') + ''; btn.onclick = ()=>{ this._setSettingsPopupTab('rooms'); }; box.append(p, btn); card.append(box); return; } try { if (!this._openPopupPresenceSensorRows) this._openPopupPresenceSensorRows = new Set(); } catch {} const wrap = document.createElement('div'); try { wrap.style.display = 'grid'; wrap.style.gap = '10px'; } catch {} card.append(wrap); const buildFoldout = (key, titleText)=>{ const line = document.createElement('div'); line.className = 'inline'; const summary = document.createElement('div'); summary.className = 'summary'; const sumLeft = document.createElement('div'); sumLeft.className = 'summary-left'; const exp = document.createElement('button'); exp.type = 'button'; exp.className = 'expander'; exp.innerHTML = ''; const txtWrap = document.createElement('div'); txtWrap.className = 'summary-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'summary-title'; titleSpan.textContent = titleText; try { summary.style.display = 'flex'; summary.style.alignItems = 'center'; summary.style.justifyContent = 'space-between'; summary.style.padding = '6px'; summary.style.border = '1px dashed var(--divider-color)'; summary.style.borderRadius = '8px'; summary.style.background = 'var(--secondary-background-color, transparent)'; sumLeft.style.display = 'flex'; sumLeft.style.alignItems = 'center'; sumLeft.style.gap = '8px'; sumLeft.style.minWidth = '0'; txtWrap.style.display = 'grid'; txtWrap.style.gap = '2px'; txtWrap.style.minWidth = '0'; titleSpan.style.fontWeight = '600'; titleSpan.style.overflow = 'hidden'; titleSpan.style.textOverflow = 'ellipsis'; titleSpan.style.whiteSpace = 'nowrap'; titleSpan.style.maxWidth = '320px'; exp.style.width = '28px'; exp.style.height = '28px'; exp.style.borderRadius = '8px'; exp.style.border = '1px solid var(--divider-color)'; exp.style.background = 'var(--card-background-color)'; exp.style.display = 'inline-flex'; exp.style.alignItems = 'center'; exp.style.justifyContent = 'center'; exp.style.cursor = 'pointer'; } catch {} txtWrap.append(titleSpan); sumLeft.append(exp, txtWrap); summary.append(sumLeft); line.append(summary); const details = document.createElement('div'); details.className = 'details'; line.append(details); try { details.style.display = 'none'; details.style.gridTemplateColumns = 'minmax(250px, 440px)'; details.style.gap = '6px'; details.style.padding = '6px'; details.style.border = '1px dashed var(--divider-color)'; details.style.borderRadius = '8px'; details.style.background = 'var(--secondary-background-color, transparent)'; } catch {} const applyOpen = (isOpen)=>{ try { if (isOpen) { line.classList.add('open'); details.style.display = 'grid'; exp.style.transform = 'rotate(180deg)'; } else { line.classList.remove('open'); details.style.display = 'none'; exp.style.transform = ''; } } catch {} }; const toggle = (ev)=>{ try { ev?.stopPropagation?.(); } catch {} const nextOpen = !line.classList.contains('open'); applyOpen(nextOpen); try { if (!this._openPopupPresenceSensorRows) this._openPopupPresenceSensorRows = new Set(); if (nextOpen) this._openPopupPresenceSensorRows.add(String(key)); else this._openPopupPresenceSensorRows.delete(String(key)); } catch {} }; exp.addEventListener('click', toggle); summary.style.cursor = 'pointer'; summary.addEventListener('click', (ev)=>{ try { if (ev?.target?.closest?.('button, a, input, select, textarea')) return; } catch {} toggle(ev); }); try { applyOpen(!!this._openPopupPresenceSensorRows?.has(String(key))); } catch {} return { line, details }; }; try { if (!d.presence_sensors || typeof d.presence_sensors !== 'object') d.presence_sensors = {}; } catch {} try { if (!d.presence_sensor_temps || typeof d.presence_sensor_temps !== 'object') d.presence_sensor_temps = {}; } catch {} try { if (!d.presence_sensor_delays || typeof d.presence_sensor_delays !== 'object') d.presence_sensor_delays = {}; } catch {} try { if (!d.presence_sensor_delay_units || typeof d.presence_sensor_delay_units !== 'object') d.presence_sensor_delay_units = {}; } catch {} for (const eid of ents) { const labels = (d.labels && typeof d.labels === 'object') ? d.labels : (this._config?.labels || {}); const friendly = this._hass?.states?.[eid]?.attributes?.friendly_name; const titleText = labels?.[eid] || friendly || (eid.split('.')[1] || eid); const { line, details } = buildFoldout(eid, titleText); try { const fieldWrap = document.createElement('div'); fieldWrap.style.display = 'grid'; fieldWrap.style.gap = '2px'; const tTitle = document.createElement('div'); tTitle.style.fontWeight = '600'; tTitle.textContent = this._t('editor.presence_sensor.entity.title') || 'Presence entity'; const tDesc = document.createElement('div'); tDesc.style.fontSize = '.85rem'; tDesc.style.color = 'var(--secondary-text-color)'; tDesc.textContent = this._t('editor.presence_sensor.entity.desc') || 'Select the binary_sensor that indicates presence in this room.'; fieldWrap.append(tTitle, tDesc); const picker = document.createElement('tt-entity-picker'); try { picker.style.display = 'block'; picker.style.minHeight = '48px'; picker.style.maxWidth = '440px'; } catch {} picker.setAttribute('include-domains', '["binary_sensor"]'); picker.setAttribute('label', this._t('editor.presence_sensor.entity.title') || 'Presence entity'); try { picker.hass = this._hass; } catch {} try { picker.value = String(d.presence_sensors?.[eid] || ''); } catch {} try { picker.disabled = !on; picker.setAttribute('aria-disabled', String(!on)); } catch {} picker.addEventListener('value-changed', (e)=>{ try { const val = String(e?.detail?.value || '').trim(); const next = { ...(d.presence_sensors || {}) }; if (val && String(val).startsWith('binary_sensor.')) next[eid] = val; else delete next[eid]; d.presence_sensors = next; } catch {} }); details.append(fieldWrap, picker); // Temperature override when presence is ON try { const wantF = String(d.temp_unit || this._config?.temp_unit || 'C').toUpperCase() === 'F'; const toDisp = (c)=>{ const v = Number(c); if (!Number.isFinite(v)) return ''; return wantF ? Math.round((v*9/5+32)*10)/10 : v; }; const fromDisp = (x)=>{ const v = Number(x); if (!Number.isFinite(v)) return NaN; return wantF ? ((v-32)*5/9) : v; }; const minC = Number.isFinite(Number(d.min_temp)) ? Number(d.min_temp) : Number(this._config?.min_temp ?? 5); const maxC = Number.isFinite(Number(d.max_temp)) ? Number(d.max_temp) : Number(this._config?.max_temp ?? 25); const tWrap = document.createElement('div'); tWrap.style.display = 'grid'; tWrap.style.gap = '2px'; const tT = document.createElement('div'); tT.style.fontWeight = '600'; tT.textContent = this._t('editor.presence_sensor.on_temp.title') || 'Temperature when presence is ON'; const tD = document.createElement('div'); tD.style.fontSize = '.85rem'; tD.style.color = 'var(--secondary-text-color)'; tD.textContent = this._t('editor.presence_sensor.on_temp.desc') || 'If the sensor is ON, this room will use this temperature instead of the schedule.'; tWrap.append(tT, tD); const inp = document.createElement('input'); inp.type = 'number'; inp.step = '0.5'; inp.className = 'settings-input'; try { if (Number.isFinite(minC)) inp.min = String(toDisp(minC)); if (Number.isFinite(maxC)) inp.max = String(toDisp(maxC)); } catch {} try { const defaultC = 21; let curC = Number(d.presence_sensor_temps?.[eid]); if (!Number.isFinite(curC)) { // Preset default (stored in °C) let v = Number(defaultC); if (Number.isFinite(minC)) v = Math.max(minC, v); if (Number.isFinite(maxC)) v = Math.min(maxC, v); const next = { ...(d.presence_sensor_temps || {}) }; next[eid] = v; d.presence_sensor_temps = next; curC = v; } inp.value = String(toDisp(curC)); } catch {} try { inp.disabled = !on; } catch {} inp.addEventListener('change', (e)=>{ try { const raw = String(e?.target?.value ?? '').trim(); const next = { ...(d.presence_sensor_temps || {}) }; if (!raw) { delete next[eid]; d.presence_sensor_temps = next; return; } const parsed = fromDisp(String(raw).replace(',', '.')); if (!Number.isFinite(parsed)) { e.target.value = ''; delete next[eid]; d.presence_sensor_temps = next; return; } let v = parsed; if (Number.isFinite(minC)) v = Math.max(minC, v); if (Number.isFinite(maxC)) v = Math.min(maxC, v); next[eid] = v; d.presence_sensor_temps = next; try { e.target.value = String(toDisp(v)); } catch {} } catch {} }); details.append(tWrap, inp); } catch {} // Delay settings (stored as seconds) try { const defaultDelaySec = 180; // 3 minutes const dm = (d.presence_sensor_delays && typeof d.presence_sensor_delays === 'object') ? d.presence_sensor_delays : {}; const cur = (dm?.[eid] && typeof dm?.[eid] === 'object') ? dm[eid] : {}; const um = (d.presence_sensor_delay_units && typeof d.presence_sensor_delay_units === 'object') ? d.presence_sensor_delay_units : {}; const unit = (String(um?.[eid] || 'minutes').toLowerCase().trim() === 'seconds') ? 'seconds' : 'minutes'; const fmtVal = (sec)=>{ const v = Number(sec); if (!Number.isFinite(v)) return ''; if (unit === 'seconds') return String(Math.round(v)); return String(Math.round((v/60)*10)/10); }; const parseToSeconds = (raw)=>{ const s = String(raw||'').trim(); if (!s) return NaN; const n = Number(s.replace(',', '.')); if (!Number.isFinite(n)) return NaN; if (unit === 'seconds') return Math.max(0, n); return Math.max(0, n) * 60; }; const setDelaySeconds = (which, seconds)=>{ try { const nextAll = { ...(d.presence_sensor_delays || {}) }; const curObj = (nextAll[eid] && typeof nextAll[eid] === 'object') ? { ...nextAll[eid] } : {}; if (!Number.isFinite(seconds)) { delete curObj[which]; } else { curObj[which] = Math.max(0, Math.round(Number(seconds))); } const out = {}; const onS = Number(curObj.on_s); const offS = Number(curObj.off_s); if (Number.isFinite(onS) && onS >= 0) out.on_s = onS; if (Number.isFinite(offS) && offS >= 0) out.off_s = offS; if (Object.keys(out).length) nextAll[eid] = out; else delete nextAll[eid]; d.presence_sensor_delays = nextAll; } catch {} }; // Unit dropdown (per room) try { const uWrap = document.createElement('div'); uWrap.style.display = 'grid'; uWrap.style.gap = '2px'; const uT = document.createElement('div'); uT.style.fontWeight = '600'; uT.textContent = this._t('away.delay_unit') || 'Unit'; uWrap.append(uT); const sel = document.createElement('select'); sel.className = 'settings-input'; const optM = document.createElement('option'); optM.value = 'minutes'; optM.textContent = this._t('away.delay_unit.minutes') || 'Minutes'; const optS = document.createElement('option'); optS.value = 'seconds'; optS.textContent = this._t('away.delay_unit.seconds') || 'Seconds'; sel.append(optM, optS); try { sel.value = unit; } catch {} try { sel.disabled = !on; } catch {} sel.addEventListener('change', ()=>{ try { const v = String(sel.value||'').toLowerCase().trim(); const next = { ...(d.presence_sensor_delay_units || {}) }; next[eid] = (v === 'seconds') ? 'seconds' : 'minutes'; d.presence_sensor_delay_units = next; this._renderSettingsPopupPresenceSensorTab(); } catch {} }); details.append(uWrap, sel); } catch {} const mkField = (titleKey, descKey, which, currentSec)=>{ const w = document.createElement('div'); w.style.display = 'grid'; w.style.gap = '2px'; const tt = document.createElement('div'); tt.style.fontWeight = '600'; tt.textContent = this._t(titleKey) || ''; const dd = document.createElement('div'); dd.style.fontSize = '.85rem'; dd.style.color = 'var(--secondary-text-color)'; dd.textContent = this._t(descKey) || ''; w.append(tt, dd); const inp = document.createElement('input'); inp.type = 'number'; inp.min = '0'; inp.step = (unit === 'seconds') ? '1' : '0.1'; inp.className = 'settings-input'; try { const sec = Number.isFinite(Number(currentSec)) ? Number(currentSec) : defaultDelaySec; inp.value = fmtVal(sec); } catch {} try { inp.disabled = !on; } catch {} inp.addEventListener('change', (e)=>{ try { if (!String(e?.target?.value || '').trim()) { // Treat empty as 0 (no delay) setDelaySeconds(which, 0); try { e.target.value = fmtVal(0); } catch {} return; } const sec = parseToSeconds(e?.target?.value); if (!Number.isFinite(sec)) { e.target.value = ''; setDelaySeconds(which, 0); return; } setDelaySeconds(which, sec); try { e.target.value = fmtVal(Math.round(sec)); } catch {} } catch {} }); return { w, inp }; }; const fOn = mkField('editor.presence_sensor.on_delay.title', 'editor.presence_sensor.on_delay.desc', 'on_s', cur?.on_s); const fOff = mkField('editor.presence_sensor.off_delay.title', 'editor.presence_sensor.off_delay.desc', 'off_s', cur?.off_s); details.append(fOn.w, fOn.inp, fOff.w, fOff.inp); } catch {} } catch {} wrap.append(line); } } catch {} } _renderSettingsPopupSettingsTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const q = (sel)=> this.shadowRoot.querySelector(sel); const setVal = (sel, v)=>{ const el=q(sel); if (el) el.value = (v ?? '') + ''; }; const setChk = (sel, v)=>{ const el=q(sel); if (el) el.checked = !!v; }; // Show temps in display unit based on draft.temp_unit, but keep draft values in °C const wantF = String(d.temp_unit || 'C').toUpperCase() === 'F'; const toDisp = (c)=>{ try { const v = Number(c); if (!Number.isFinite(v)) return c; return wantF ? this._toDisplayTemp(v) : v; } catch { return c; } }; setVal('.overlay-settings .sp-def', toDisp(d.default_temp)); setVal('.overlay-settings .sp-rowh', d.row_height); setVal('.overlay-settings .sp-minc', toDisp(d.min_temp)); setVal('.overlay-settings .sp-maxc', toDisp(d.max_temp)); setChk('.overlay-settings .sp-auto', d.auto_apply); setChk('.overlay-settings .sp-applyedit', d.apply_on_edit); setChk('.overlay-settings .sp-applydef', d.apply_on_default_change); setChk('.overlay-settings .sp-perroom', d.per_room_defaults); setChk('.overlay-settings .sp-showroomtemp', d.show_room_temp); // Segmented controls: active state try { const b24 = q('.overlay-settings .sp-fmt24'); const b12 = q('.overlay-settings .sp-fmt12'); const is12 = !!d.time_12h; b24 && b24.classList.toggle('active', !is12); b12 && b12.classList.toggle('active', is12); } catch {} try { const bB = q('.overlay-settings .sp-srcBrowser'); const bH = q('.overlay-settings .sp-srcHa'); const useHa = String(d.time_source||'browser')==='ha'; bB && bB.classList.toggle('active', !useHa); bH && bH.classList.toggle('active', useHa); } catch {} try { const bC = q('.overlay-settings .sp-unitC'); const bF = q('.overlay-settings .sp-unitF'); bC && bC.classList.toggle('active', !wantF); bF && bF.classList.toggle('active', wantF); } catch {} setChk('.overlay-settings .sp-profiles', d.profiles_enabled); setChk('.overlay-settings .sp-seasonal', d.seasonal_enabled); // Configuration ID (instance) toggle + label (not part of the popup draft) try { this._hydrateInstanceCfgFromLocal(); const sw = q('.overlay-settings .sp-instance-enable'); if (sw) sw.checked = !!this._config?.instance_enabled; const row = q('.overlay-settings .sp-instance-id-setting'); if (row) row.style.display = this._config?.instance_enabled ? '' : 'none'; const idEl = q('.overlay-settings .sp-instance-id'); if (idEl) { const id = String(this._config?.instance_id || ''); idEl.textContent = id ? `ID: ${id}` : ''; } const b = q('.overlay-settings .sp-instance-regenerate-btn'); if (b) b.style.display = this._config?.instance_enabled ? '' : 'none'; } catch {} // Keep temp field labels in sync with draft unit try { const unitSym = wantF ? '°F' : '°C'; const t = (k)=> (this._t ? this._t(k) : k); const setLbl = (sel, key)=>{ const el=q(sel); if (!el) return; const s = String(t(key)||''); el.textContent = s.includes('°C') ? s.replace('°C', unitSym) : (s + (s.includes('(') ? '' : '')); }; setLbl('.overlay-settings .sp-slabel-def', 'editor.default_c'); setLbl('.overlay-settings .sp-slabel-minc', 'editor.min_c'); setLbl('.overlay-settings .sp-slabel-maxc', 'editor.max_c'); } catch {} } catch {} } _renderSettingsPopupPauseTab(){ try { const d = this._settingsDraft; if (!d || !this.shadowRoot) return; const q = (sel)=> this.shadowRoot.querySelector(sel); const setChk = (sel, v)=>{ const el=q(sel); if (el) el.checked = !!v; }; setChk('.overlay-settings .sp-pausebtn', d.show_pause_button); setChk('.overlay-settings .sp-pausesensor', d.pause_sensor_enabled); // Pause sensor entity row visibility + picker value try { const row = q('.overlay-settings .sp-pausesensor-entity-setting'); if (row) row.style.display = d.pause_sensor_enabled ? '' : 'none'; } catch {} try { const p = q('.overlay-settings .sp-pausesensor-entity'); if (p) { p.hass = this._hass; p.value = String(d.pause_sensor_entity || ''); try { p.setAttribute('label', this._t('editor.pause_sensor.entity_title') || 'Pause sensor'); } catch {} } } catch {} // Wire Pause tab controls const onSw = (key) => (e)=>{ try { if (!this._settingsDraft) return; const checked = !!(e && e.target && e.target.checked); this._settingsDraft[key] = checked; } catch {} }; try { const swPauseBtn = q('.overlay-settings .sp-pausebtn'); if (swPauseBtn) swPauseBtn.onchange = onSw('show_pause_button'); } catch {} try { const swPauseSensor = q('.overlay-settings .sp-pausesensor'); if (swPauseSensor) swPauseSensor.onchange = (e)=>{ try { if (!this._settingsDraft) return; this._settingsDraft.pause_sensor_enabled = !!e?.target?.checked; this._renderSettingsPopupPauseTab(); } catch {} }; } catch {} // Pause sensor entity picker try { const p = q('.overlay-settings .sp-pausesensor-entity'); if (p) { if (!p._ttBoundPausePicker) { p._ttBoundPausePicker = true; p.addEventListener('value-changed', (ev)=>{ try { if (!this._settingsDraft) return; const v = (ev?.detail && ev.detail.value != null) ? String(ev.detail.value) : ''; this._settingsDraft.pause_sensor_entity = v; } catch {} }); } } } catch {} } catch {} } _ttCoerceNum(v, fallback){ const n = Number(v); return Number.isFinite(n) ? n : fallback; } _ttClampNum(v, min, max, fallback){ const n = Number(v); if (!Number.isFinite(n)) return fallback; return Math.max(min, Math.min(max, n)); } _settingsPopupHasUnsaved(){ try { const d = this._settingsDraft; const c = this._config || {}; if (!d) return false; const eqNum = (a,b)=>{ const na = Number(a); const nb = Number(b); if (!Number.isFinite(na) && !Number.isFinite(nb)) return true; return Math.abs(na - nb) < 0.0001; }; // Normalize undefined config fields to the same defaults used in _initSettingsPopupDraft const cDefaultTemp = this._ttCoerceNum(c.default_temp, 20); const cRowHeight = this._ttCoerceNum(c.row_height, 64); const cMinTemp = this._ttCoerceNum(c.min_temp, 5); const cMaxTemp = this._ttCoerceNum(c.max_temp, 25); if (!eqNum(d.default_temp, cDefaultTemp)) return true; if (!eqNum(d.row_height, cRowHeight)) return true; if (!eqNum(d.min_temp, cMinTemp)) return true; if (!eqNum(d.max_temp, cMaxTemp)) return true; if (!!d.auto_apply !== !!c.auto_apply) return true; if (!!d.apply_on_edit !== !!c.apply_on_edit) return true; if (!!d.apply_on_default_change !== !!c.apply_on_default_change) return true; if (!!d.per_room_defaults !== !!c.per_room_defaults) return true; if (!!d.show_pause_button !== !!(c.show_pause_button !== false)) return true; if (!!d.show_room_temp !== !!(c.show_room_temp !== false)) return true; if (!!d.pause_sensor_enabled !== !!c.pause_sensor_enabled) return true; if (String(d.pause_sensor_entity||'') !== String(c.pause_sensor_entity||'')) return true; if (!!d.time_12h !== !!c.time_12h) return true; if (String(d.time_source||'browser') !== String(c.time_source||'browser')) return true; if (String(d.temp_unit||'C').toUpperCase() !== String(c.temp_unit||'C').toUpperCase()) return true; if (!!d.profiles_enabled !== !!c.profiles_enabled) return true; if (!!d.seasonal_enabled !== !!c.seasonal_enabled) return true; if (String(d.seasonal_mode||'winter') !== String(c.seasonal_mode||'winter')) return true; if (!!d.presence_sensor_enabled !== !!c.presence_sensor_enabled) return true; if (!!d.weekdays_enabled !== !!c.weekdays_enabled) return true; if (String(d.weekdays_view||'all_rooms_one_day') !== String(c.weekdays_view||'all_rooms_one_day')) return true; if (String(d.weekdays_selected_room||'') !== String(c.weekdays_selected_room||'')) return true; if (!!d.boiler_enabled !== !!c.boiler_enabled) return true; if (String(d.boiler_switch||'') !== String(c.boiler_switch||'')) return true; // boiler_switch_domain: normalize to switch/input_boolean try { const normDom = (eid, raw)=>{ const fromEid = (()=>{ const dom = String(eid||'').split('.')[0]; return (dom === 'input_boolean') ? 'input_boolean' : 'switch'; })(); const s = String((raw ?? fromEid) || '').toLowerCase().trim(); return (s === 'input_boolean') ? 'input_boolean' : 'switch'; }; const dDom = normDom(d.boiler_switch, d.boiler_switch_domain); const cDom = normDom(c.boiler_switch, c.boiler_switch_domain); if (dDom !== cDom) return true; } catch {} // boiler_rooms: compare sets; default is "all climate rooms" try { const cEnts = Array.isArray(c.entities) ? c.entities.filter(Boolean).map(String) : []; const climateRooms = cEnts.filter(e=>String(e).startsWith('climate.')); const norm = (arr)=>{ const set = new Set((Array.isArray(arr) ? arr : climateRooms).filter(Boolean).map(String)); return climateRooms.filter(eid => set.has(eid)); }; const dRooms = norm(d.boiler_rooms); const cRooms = norm(c.boiler_rooms); if (dRooms.length !== cRooms.length) return true; for (let i=0;iString(e).startsWith('climate.')); const norm = (src)=>{ const map = (src && typeof src === 'object') ? src : {}; const out = {}; for (const eid of climateRooms) { const raw = map?.[eid]; if (!raw || typeof raw !== 'object') continue; const enabled = !!raw.enabled; const sw = String(raw.switch || '').trim(); const domFromEid = (sw ? String(sw).split('.')[0] : ''); const rawDom = String(raw.switch_domain || '').toLowerCase().trim(); const dom = (domFromEid === 'input_boolean' || domFromEid === 'switch') ? domFromEid : ((rawDom === 'input_boolean') ? 'input_boolean' : 'switch'); const onOff = Number.isFinite(Number(raw.on_offset)) ? Math.max(-10, Math.min(10, Number(raw.on_offset))) : this._ttCoerceNum(c.boiler_on_offset, 0); const offOff = Number.isFinite(Number(raw.off_offset)) ? Math.max(-10, Math.min(10, Number(raw.off_offset))) : this._ttCoerceNum(c.boiler_off_offset, 0); const hasAny = enabled || sw || raw.on_offset !== undefined || raw.off_offset !== undefined || raw.switch_domain; if (hasAny) out[eid] = { enabled, switch: sw, switch_domain: dom, on_offset: onOff, off_offset: offOff }; } return out; }; const dMap = norm(d.boiler_room_settings); const cMap = norm(c.boiler_room_settings); if (JSON.stringify(dMap) !== JSON.stringify(cMap)) return true; } catch {} // Colors if (!!d.color_global !== !!c.color_global) return true; try { const dm = (d.color_ranges && typeof d.color_ranges === 'object') ? d.color_ranges : {}; const cm = (c.color_ranges && typeof c.color_ranges === 'object') ? c.color_ranges : {}; const keys = new Set([...Object.keys(dm||{}), ...Object.keys(cm||{})]); for (const k of keys) { const a = Array.isArray(dm[k]) ? dm[k] : []; const b = Array.isArray(cm[k]) ? cm[k] : []; if (a.length !== b.length) return true; for (let i=0;i{ const n = Number(v); if (!Number.isFinite(n)) return fallback; return Math.max(0, Math.min(1440, Math.round(n))); }; if (!eqNum(dn.open_delay_min, clampOw(cn.open_delay_min, 2))) return true; if (!eqNum(dn.close_delay_min, clampOw(cn.close_delay_min, 5))) return true; const ds = (dn.sensors && typeof dn.sensors === 'object') ? dn.sensors : {}; const cs = (cn.sensors && typeof cn.sensors === 'object') ? cn.sensors : {}; const keys = new Set([...Object.keys(ds||{}), ...Object.keys(cs||{})]); for (const k of keys) { const a = Array.isArray(ds[k]) ? ds[k].filter(Boolean).map(String) : []; const b = Array.isArray(cs[k]) ? cs[k].filter(Boolean).map(String) : []; const sa = Array.from(new Set(a)).sort(); const sb = Array.from(new Set(b)).sort(); if (sa.length !== sb.length) return true; for (let i=0;i /^\d{4}-\d{2}-\d{2}$/.test(String(x))))).sort(); const sc = Array.from(new Set(ca.filter(x => /^\d{4}-\d{2}-\d{2}$/.test(String(x))))).sort(); if (sd.length !== sc.length) return true; for (let i=0;i !!dcmb[k]?.enabled).map(String).sort(); const ck = Object.keys(ccmb||{}).filter(k => !!ccmb[k]?.enabled).map(String).sort(); if (dk.length !== ck.length) return true; for (let i=0;iString(x||'').trim()).filter(Boolean) : []; const ce = Array.isArray(c.entities) ? c.entities.map(x=>String(x||'').trim()).filter(Boolean) : []; if (JSON.stringify(de) !== JSON.stringify(ce)) return true; const dm0 = Array.isArray(d.room_use_input_number) ? d.room_use_input_number.map(v=>!!v) : []; const cm0 = Array.isArray(c.room_use_input_number) ? c.room_use_input_number.map(v=>!!v) : []; const normModes = (ents, raw)=> ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return true; if (String(eid).startsWith('climate.')) return false; return !!raw[idx]; }); const dm = normModes(de, dm0); const cm = normModes(ce, cm0); if (JSON.stringify(dm) !== JSON.stringify(cm)) return true; const dl = (d.labels && typeof d.labels === 'object') ? d.labels : {}; const cl = (c.labels && typeof c.labels === 'object') ? c.labels : {}; const normMap = (src)=>{ const out = {}; for (const eid of de) { const v = Object.prototype.hasOwnProperty.call(src, eid) ? String(src[eid]||'').trim() : ''; if (v) out[eid] = v; } return out; }; if (JSON.stringify(normMap(dl)) !== JSON.stringify(normMap(cl))) return true; const dmrg = (d.merges && typeof d.merges === 'object') ? d.merges : {}; const cmrg = (c.merges && typeof c.merges === 'object') ? c.merges : {}; const primSet = new Set(de); const normMerges = (src)=>{ const out = {}; for (let i=0;iString(x).startsWith('climate.') && !primSet.has(String(x))))).sort(); if (cleaned.length) out[primary] = cleaned; } return out; }; if (JSON.stringify(normMerges(dmrg)) !== JSON.stringify(normMerges(cmrg))) return true; const dts = (d.temp_sensors && typeof d.temp_sensors === 'object') ? d.temp_sensors : {}; const cts = (c.temp_sensors && typeof c.temp_sensors === 'object') ? c.temp_sensors : {}; if (JSON.stringify(normMap(dts)) !== JSON.stringify(normMap(cts))) return true; // Presence sensors: one binary_sensor per active room const dps = (d.presence_sensors && typeof d.presence_sensors === 'object') ? d.presence_sensors : {}; const cps = (c.presence_sensors && typeof c.presence_sensors === 'object') ? c.presence_sensors : {}; const normPresence = (src)=>{ const out = {}; for (const eid of de) { const v = Object.prototype.hasOwnProperty.call(src, eid) ? String(src[eid]||'').trim() : ''; if (v && String(v).startsWith('binary_sensor.')) out[eid] = v; } return out; }; if (JSON.stringify(normPresence(dps)) !== JSON.stringify(normPresence(cps))) return true; // Presence override temps (°C): compare numeric mappings for active rooms try { const dpt = (d.presence_sensor_temps && typeof d.presence_sensor_temps === 'object') ? d.presence_sensor_temps : {}; const cpt = (c.presence_sensor_temps && typeof c.presence_sensor_temps === 'object') ? c.presence_sensor_temps : {}; const normTemps = (src)=>{ const out = {}; for (const eid of de) { const v0 = Object.prototype.hasOwnProperty.call(src, eid) ? Number(src[eid]) : NaN; if (!Number.isFinite(v0)) continue; out[eid] = v0; } return out; }; if (JSON.stringify(normTemps(dpt)) !== JSON.stringify(normTemps(cpt))) return true; } catch {} // Presence delays (seconds): compare { on_s, off_s } per active room try { const dpd = (d.presence_sensor_delays && typeof d.presence_sensor_delays === 'object') ? d.presence_sensor_delays : {}; const cpd = (c.presence_sensor_delays && typeof c.presence_sensor_delays === 'object') ? c.presence_sensor_delays : {}; const normDelays = (src)=>{ const out = {}; for (const eid of de) { const v = src?.[eid]; if (!v || typeof v !== 'object') continue; const onS = Number(v.on_s); const offS = Number(v.off_s); const obj = {}; if (Number.isFinite(onS) && onS >= 0) obj.on_s = onS; if (Number.isFinite(offS) && offS >= 0) obj.off_s = offS; if (Object.keys(obj).length) out[eid] = obj; } return out; }; if (JSON.stringify(normDelays(dpd)) !== JSON.stringify(normDelays(cpd))) return true; } catch {} // Delay unit per room: default 'minutes' try { const dpu = (d.presence_sensor_delay_units && typeof d.presence_sensor_delay_units === 'object') ? d.presence_sensor_delay_units : {}; const cpu = (c.presence_sensor_delay_units && typeof c.presence_sensor_delay_units === 'object') ? c.presence_sensor_delay_units : {}; const normUnits = (src)=>{ const out = {}; for (const eid of de) { const u0 = Object.prototype.hasOwnProperty.call(src, eid) ? String(src[eid]||'').toLowerCase().trim() : ''; out[eid] = (u0 === 'seconds') ? 'seconds' : 'minutes'; } return out; }; if (JSON.stringify(normUnits(dpu)) !== JSON.stringify(normUnits(cpu))) return true; } catch {} } catch {} return false; } catch { return false; } } _closeSettingsPopup(force){ try { const mustConfirm = !force && this._settingsPopupHasUnsaved(); if (mustConfirm) { const msg = this._t('ui.unsaved_confirm') || 'You have unsaved changes. Close anyway?'; const ok = window.confirm(msg); if (!ok) return; } try { if (this._settingsPopupKeyHandler) window.removeEventListener('keydown', this._settingsPopupKeyHandler); } catch {} this._settingsPopupKeyHandler = null; try { if (this._settingsPopupWheelHandler?.el && this._settingsPopupWheelHandler?.fn) { this._settingsPopupWheelHandler.el.removeEventListener('wheel', this._settingsPopupWheelHandler.fn); } } catch {} this._settingsPopupWheelHandler = null; this._settingsPopupSuspended = false; const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings'); if (ov) ov.classList.remove('open'); try { this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} this._settingsDraft = null; } catch {} } _suspendSettingsPopup(){ try { const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings'); if (ov) ov.classList.remove('open'); try { this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} this._settingsPopupSuspended = true; try { if (this._settingsPopupKeyHandler) window.removeEventListener('keydown', this._settingsPopupKeyHandler); } catch {} } catch {} } _resumeSettingsPopup(){ try { if (this._disablePopups) return; const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings'); if (!ov) return; ov.classList.add('open'); try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} this._settingsPopupSuspended = false; try { this._applyCardI18n(); } catch {} try { this._renderSettingsPopupTabs(); } catch {} try { this._renderSettingsPopupTab(); } catch {} try { const onKey = (ev) => { if (ev.key === 'Escape') { this._closeSettingsPopup(false); } }; try { if (this._settingsPopupKeyHandler) window.removeEventListener('keydown', this._settingsPopupKeyHandler); } catch {} this._settingsPopupKeyHandler = onKey; window.addEventListener('keydown', onKey); } catch {} } catch {} } async _saveSettingsPopup(){ try { // Robust fallback: sync Away controls directly from popup before applying draft // in case component events were not emitted. try { const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings'); const d = this._settingsDraft; const _ttReadSwitchNow = (el)=>{ try { if (!el) return null; } catch { return null; } try { if (typeof el.checked === 'boolean') return !!el.checked; } catch {} try { const inner = el.shadowRoot?.querySelector?.('input,button,[role="switch"]'); if (inner && typeof inner.checked === 'boolean') return !!inner.checked; } catch {} try { const aria = String(el.getAttribute?.('aria-checked') || '').toLowerCase(); if (aria === 'true') return true; if (aria === 'false') return false; } catch {} try { const innerAria = String(el.shadowRoot?.querySelector?.('[aria-checked]')?.getAttribute?.('aria-checked') || '').toLowerCase(); if (innerAria === 'true') return true; if (innerAria === 'false') return false; } catch {} try { if (el.hasAttribute?.('checked')) return true; } catch {} return null; }; if (ov && d && d.away && typeof d.away === 'object') { const sw = ov.querySelector('.sp-away-enable'); const swV = _ttReadSwitchNow(sw); if (typeof swV === 'boolean') { const v = swV; if (!!d.away.enabled !== v) this._awayTouched = true; d.away.enabled = v; } const adv = ov.querySelector('.sp-away-adv-enable'); const advV = _ttReadSwitchNow(adv); if (typeof advV === 'boolean') { const v = advV; if (!!d.away.advanced_enabled !== v) this._awayTouched = true; d.away.advanced_enabled = v; } const pl = ov.querySelector('.sp-presence-live-enable'); const plV = _ttReadSwitchNow(pl); if (typeof plV === 'boolean') { const v = plV; if (!!(d.presence_live_header ?? true) !== v) this._awayTouched = true; d.presence_live_header = v; } const den = ov.querySelector('.sp-away-delay-enable'); const denV = _ttReadSwitchNow(den); if (typeof denV === 'boolean') { const v = denV; if (!!(d.away_delay_enabled ?? false) !== v) this._awayTouched = true; d.away_delay_enabled = v; } const dval = ov.querySelector('.sp-away-delay-value'); if (dval) { const n = Number(dval.value); const v = Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0; if (Number(d.away_delay_value ?? 0) !== v) this._awayTouched = true; d.away_delay_value = v; } const dunit = ov.querySelector('.sp-away-delay-unit'); if (dunit) { const v = (String(dunit.value || 'minutes') === 'seconds') ? 'seconds' : 'minutes'; if (String(d.away_delay_unit || 'minutes') !== v) this._awayTouched = true; d.away_delay_unit = v; } } } catch {} // IMPORTANT: do not persist on toggle/input; only commit+save here. this._applySettingsPopupDraftToConfig(); if (this._config?.storage_enabled && typeof this._saveStore === 'function') { await this._saveStore(true); // Pull fresh settings back from the integration to avoid any race where UI shows defaults after a hard refresh try { const api = await this._apiFetchState(); const s = api?.settings || null; if (s && typeof s === 'object') { if (typeof s.holidays_enabled === 'boolean') this._config.holidays_enabled = !!s.holidays_enabled; if (typeof s.holidays_source === 'string') this._config.holidays_source = (s.holidays_source === 'manual') ? 'manual' : 'calendar'; if (typeof s.holidays_entity === 'string') this._config.holidays_entity = String(s.holidays_entity || ''); if (Array.isArray(s.holidays_dates)) this._config.holidays_dates = [...s.holidays_dates]; if (s.away && typeof s.away === 'object') { try { this._config.away = JSON.parse(JSON.stringify(s.away)); } catch { this._config.away = { ...(s.away || {}) }; } } if (typeof s.presence_live_header === 'boolean') this._config.presence_live_header = !!s.presence_live_header; if (typeof s.away_delay_enabled === 'boolean') this._config.away = { ...(this._config.away || {}), delay_enabled: !!s.away_delay_enabled }; if (Number.isFinite(Number(s.away_delay_value))) this._config.away = { ...(this._config.away || {}), delay_value: Number(s.away_delay_value) }; if (typeof s.away_delay_unit === 'string') this._config.away = { ...(this._config.away || {}), delay_unit: (String(s.away_delay_unit || 'minutes') === 'seconds') ? 'seconds' : 'minutes' }; if (s.open_window && typeof s.open_window === 'object') this._config.open_window = { ...s.open_window }; } } catch {} } else { try { if (typeof this._debouncedSaveStore === 'function') this._debouncedSaveStore(); } catch {} } } catch {} try { this._closeSettingsPopup(true); } catch {} try { this._render(); } catch {} // Ensure other card instances update immediately after Save (used for Colors "Update schedule") try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} try { document.querySelectorAll('thermostat-timeline-card').forEach(el=>el?.devRefreshFromEditor?.()); } catch {} } // --- Test popup (entity picker + available services) --- _openTestPopup(){ try { const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-test'); const picker = this.shadowRoot && this.shadowRoot.querySelector('.test-picker'); const select = this.shadowRoot && this.shadowRoot.querySelector('.test-select'); if (!ov || !picker) return; ov.classList.add('open'); // If HA's entity picker web component isn't loaded, show a simple to avoid menu overlays escaping the modal const hasPicker = false && !!customElements.get('ha-entity-picker'); if (hasPicker) { try { picker.style.display = ''; } catch {} try { if (select) select.style.display = 'none'; } catch {} // Bind hass to entity picker and set current value try { picker.hass = this._hass; } catch {} try { if (this._testEntity) picker.value = this._testEntity; } catch {} // Attach one-time listener (avoid duplicates) try { if (!picker._ttBound) { picker._ttBound = true; picker.addEventListener('value-changed', (e)=>{ try { this._testEntity = e.detail?.value || e.target?.value || ''; this._renderTestCommands(); } catch {} }); picker.addEventListener('change', (e)=>{ try { this._testEntity = e.target?.value || ''; this._renderTestCommands(); } catch {} }); } } catch {} } else { // Fallback: populate select with all entity ids try { picker.style.display = 'none'; } catch {} if (select) { try { select.style.display = ''; } catch {} // Build options once per open select.innerHTML = ''; const states = this._hass?.states || {}; const items = Object.keys(states).map(id=>{ const fn = states[id]?.attributes?.friendly_name || id; return { id, fn: String(fn) }; }).sort((a,b)=> a.fn.localeCompare(b.fn)); const ph = document.createElement('option'); ph.value=''; ph.textContent = this._t('editor.test_select_placeholder'); select.append(ph); for (const it of items){ const o=document.createElement('option'); o.value=it.id; o.textContent=`${it.fn} (${it.id})`; if (this._testEntity===it.id) o.selected = true; select.append(o); } if (!select._ttBound){ select._ttBound = true; select.addEventListener('change',(e)=>{ try { this._testEntity = e.target?.value || ''; this._renderTestCommands(); } catch {} }); } } } this._renderTestCommands(); this._updateTestExportEnabled(); } catch {} } _renderTestCommands(){ try { const list = this.shadowRoot && this.shadowRoot.querySelector('.test-list'); if (!list) return; list.innerHTML = ''; const eid = String(this._testEntity || '').trim(); if (!eid) { const empty = document.createElement('div'); empty.className = 'test-empty'; empty.textContent = this._t('editor.test_empty'); list.append(empty); return; } const domain = eid.split('.')[0]; const all = (this._hass && this._hass.services) ? this._hass.services : {}; const svcObj = all[domain] || {}; // If chosen entity is a climate, also show generic homeassistant "set_state" fields? Keep to domain services for now const names = Object.keys(svcObj).sort(); if (!names.length) { const empty = document.createElement('div'); empty.className = 'test-empty'; empty.textContent = `Ingen services fundet for domænet "${domain}".`; list.append(empty); return; } for (const s of names){ const def = svcObj[s] || {}; const item = document.createElement('div'); item.className = 'test-item'; const h = document.createElement('h4'); h.textContent = `${domain}.${s}`; item.append(h); const ul = document.createElement('ul'); ul.className = 'test-fields'; const fields = def.fields || {}; const fNames = Object.keys(fields); if (!fNames.length) { const li = document.createElement('li'); li.textContent = this._t('editor.test_no_fields'); ul.append(li); } else { for (const fn of fNames){ const fd = fields[fn] || {}; const li = document.createElement('li'); const req = fd.required ? ' (påkrævet)' : ''; const desc = fd.description ? ` – ${fd.description}` : ''; const ex = typeof fd.example !== 'undefined' ? ` Eksempel: ${JSON.stringify(fd.example)}` : ''; li.textContent = `${fn}${req}${desc}${ex}`; ul.append(li); } } item.append(ul); list.append(item); } this._updateTestExportEnabled(); } catch {} } _updateTestExportEnabled(){ try { const btn = this.shadowRoot && this.shadowRoot.querySelector('.test-export'); if (!btn) return; const eid = String(this._testEntity || '').trim(); const domain = eid && eid.includes('.') ? eid.split('.')[0] : ''; const has = domain && this._hass && this._hass.services && this._hass.services[domain] && Object.keys(this._hass.services[domain]).length; btn.disabled = !(eid && has); } catch {} } _exportTestCommands(){ try { if (this._exporting) return; // throttle to avoid accidental multi-download this._exporting = true; setTimeout(()=>{ this._exporting = false; }, 600); const text = this._buildTestExportText(); if (!text) return; const eid = String(this._testEntity || 'entity').replace(/[^a-zA-Z0-9_.-]+/g,'_'); const name = `${eid}-commands.md`; const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); setTimeout(()=>{ try { document.body.removeChild(a); URL.revokeObjectURL(url); } catch {} }, 0); } catch {} } _buildTestExportText(){ try { const eid = String(this._testEntity || '').trim(); if (!eid) return ''; const st = this._hass?.states?.[eid]; const friendly = st?.attributes?.friendly_name || ''; const domain = eid.split('.')[0]; const date = new Date(); const lines = []; lines.push(`Entity: ${eid}${friendly?` (${friendly})`:''}`); lines.push(`Domain: ${domain}`); lines.push(`Generated: ${date.toISOString()}`); lines.push(''); const svcObj = (this._hass?.services?.[domain]) || {}; const names = Object.keys(svcObj).sort(); if (!names.length) { lines.push('No services for this domain.'); return lines.join('\n'); } for (const s of names){ // Mark command in Markdown-style bold for easier reading lines.push(`**${domain}.${s}**`); const fields = (svcObj[s] && svcObj[s].fields) || {}; const fNames = Object.keys(fields); if (!fNames.length) { lines.push(' (no fields)'); continue; } for (const fn of fNames){ const fd = fields[fn] || {}; const req = fd.required ? 'required' : 'optional'; const desc = fd.description ? ` - ${fd.description}` : ''; const ex = typeof fd.example !== 'undefined' ? ` Example: ${JSON.stringify(fd.example)}` : ''; lines.push(` ${fn} (${req})${desc}${ex}`); } lines.push(''); } return lines.join('\n'); } catch { return ''; } } // Allow editor to nudge a repaint explicitly devRefreshFromEditor(){ try { this._ensureSchedules(); } catch {} try { if (!this._inlineEditing && !this._editing) this._render(); } catch {} } _openEditor(entity, blockId) { this._ensureEditModeOn(); if (!blockId) return this._openNewEditor(entity); this._editing = { entity, blockId }; // Hide any tooltips while editing try { const tips = this.shadowRoot.querySelectorAll('.wk-tooltip'); tips.forEach(t => { try { if (t._hideTimer) clearTimeout(t._hideTimer); } catch {} t.style.display = 'none'; }); } catch {} const row = this._schedules[entity]; if (!row) return; const b = row.blocks.find(x => x.id === blockId); if (!b) return; const edTemp = this.shadowRoot.querySelector(".ed-temp"); const edFrom = this.shadowRoot.querySelector(".ed-from"); const edTo = this.shadowRoot.querySelector(".ed-to"); const fromMer = this.shadowRoot.querySelector('.ed-from-mer'); const toMer = this.shadowRoot.querySelector('.ed-to-mer'); const overlay = this.shadowRoot.querySelector(".overlay"); const title = this.shadowRoot.querySelector('.modal h3'); const delBtn = this.shadowRoot.querySelector('.ed-delete'); if (edTemp) { edTemp.value = String(this._toDisplayTemp(b.temp)); edTemp.max = String(this._maxDisplay()); edTemp.min = String(this._minDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value = this._toTimeInput12h(b.startMin); if (edTo) edTo.value = this._toTimeInput12h(b.endMin); if (fromMer) { fromMer.style.display=''; fromMer.value = (b.startMin>=720)?'PM':'AM'; } if (toMer) { toMer.style.display=''; toMer.value = (b.endMin>=720 && b.endMin<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value = this._toTimeInput(b.startMin); if (edTo) edTo.value = this._toTimeInput(b.endMin); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.edit_block'); if (delBtn) delBtn.style.display = ''; const err = this.shadowRoot.querySelector(".ed-error"); if (err) { err.style.display = "none"; err.textContent = ""; } try { const modalEl = this.shadowRoot.querySelector('.overlay .modal:not(.modal-week)'); if (modalEl) modalEl.classList.toggle('modal-12h', !!this._config?.time_12h); } catch {} overlay.classList.add("open"); try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} try { const onKey = (ev) => { if (ev.key === 'Escape') { this._closeEditor(); } }; window.addEventListener('keydown', onKey, { once: true }); } catch {} // Improved keyboard navigation between time fields (two-step Tab in "Fra") try { const fromInput = this.shadowRoot.querySelector(".ed-from"); const toInput = this.shadowRoot.querySelector(".ed-to"); if (fromInput && toInput) { // Focus "Fra" on open for fast editing setTimeout(() => { try { fromInput.focus(); fromInput.select?.(); } catch {} }, 0); // Two-step Tab inside "Fra": hours -> minutes -> move to "Til" let fraStep = 0; fromInput.addEventListener("keydown", (e) => { if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); try { const val = fromInput.value || ""; if (fraStep === 0) { if (fromInput.setSelectionRange) { setTimeout(() => { try { fromInput.setSelectionRange(3, 5); } catch {} }, 0); } fraStep = 1; } else { fraStep = 0; toInput.focus(); toInput.select?.(); } } catch { if (fraStep == 0) { fraStep = 1; } else { fraStep = 0; toInput.focus(); toInput.select?.(); } } } }); // Shift+Tab from Til -> Fra toInput.addEventListener("keydown", (e) => { if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); fraStep = 1; fromInput.focus(); try { fromInput.setSelectionRange?.(3,5); } catch {} } }); fromInput.addEventListener("input", () => { fraStep = 0; }); } } catch {} try { const onKey = (ev) => { if (ev.key === 'Escape') { this._closeEditor(); } }; window.addEventListener('keydown', onKey, { once: true }); } catch {} // Improved keyboard navigation between time fields try { const fromInput = this.shadowRoot.querySelector(".ed-from"); const toInput = this.shadowRoot.querySelector(".ed-to"); if (fromInput && toInput) { // Focus "Fra" on open for fast editing setTimeout(() => { try { fromInput.focus(); fromInput.select?.(); } catch {} }, 0); // Tab from Fra -> Til (skip any intermediary focusable elements like clock icons) fromInput.addEventListener("keydown", (e) => { if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); toInput.focus(); toInput.select?.(); } }); // Shift+Tab from Til -> Fra toInput.addEventListener("keydown", (e) => { if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); fromInput.focus(); fromInput.select?.(); } }); } } catch {} try { const onKey = (ev) => { if (ev.key === 'Escape') { this._closeEditor(); } }; // Add once to avoid stacking listeners across multiple opens window.addEventListener('keydown', onKey, { once: true }); } catch {} // Also reflect the current option on the global select entity instantly try { const selOpt = (()=>{ const all = this._hass?.states || {}; for (const eid in all){ if (!Object.prototype.hasOwnProperty.call(all, eid)) continue; if (!eid.startsWith('select.')) continue; const st = all[eid]; const fn = String(st?.attributes?.friendly_name || '').toLowerCase(); if (fn === 'thermostat profile' || fn.startsWith('thermostat profile')){ return String(st?.state || ''); } } return null; })(); if (selOpt !== null) { const norm = (v)=>{ const s = String(v||'').trim(); return (s.toLowerCase()==='none' || s==='') ? null : s; }; const want = norm(selOpt); const changed = want !== this._globalProfile; if (changed) { this._globalProfile = want; if (want) this._config.profiles_enabled = true; // Update local schedules so UI reflects change instantly (no save) try { for (const eid of (this._config?.entities || [])){ const r = this._schedules[eid]; if (!r) continue; this._ensureProfilesStruct(r); if (!want) { r.activeProfile = null; continue; } const has = !!(r.profiles && r.profiles[want]); r.activeProfile = has ? want : null; } } catch {} if (!this._inlineEditing && !this._editing) this._render(); this._scheduleNextApply(); // Persist back to integration so sensor version updates immediately try { this._debouncedSaveStore(); } catch {} } } } catch {} } _closeEditor(){ this._editing = null; this.shadowRoot.querySelector(".overlay")?.classList.remove("open"); try { this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} // If we hid the weekday modal for editing, decide whether to restore it // Only restore when the edit was initiated from inside the weekdays popup. // If the edit was initiated from the main timeline (auto-save flow), do not reopen the weekdays popup. if (this._weeklyOverlayHiddenForEditor && this._weeklyDraft) { const cameFromTimeline = !!this._autoSaveWeeklyFromTimeline; this._weeklyOverlayHiddenForEditor = false; if (cameFromTimeline) { // Do not bounce back into weekdays popup on cancel; reset the auto-save flag and close weekly context this._autoSaveWeeklyFromTimeline = false; this._closeWeeklyEditor(); } else { const wov = this.shadowRoot.querySelector('.overlay-week'); if (wov) wov.classList.add('open'); this._renderWeeklyModal(); } } // If we hid the profiles modal for editing, restore it on close if (this._profilesOverlayHiddenForEditor && this._profilesOpen) { this._profilesOverlayHiddenForEditor = false; const pov = this.shadowRoot.querySelector('.overlay-profiles'); if (pov) pov.classList.add('open'); this._renderProfilesModal(); } // If we hid the holiday modal for editing, restore it on close if (this._holidayOverlayHiddenForEditor && this._holidayOpen) { this._holidayOverlayHiddenForEditor = false; const hov = this.shadowRoot.querySelector('.overlay-holiday'); if (hov) hov.classList.add('open'); this._renderHolidayModal(); } // Restore presence modal if it is open try { if (this._presenceOpen) { const pov = this.shadowRoot.querySelector('.overlay-presence'); if (pov) pov.classList.add('open'); this._renderPresenceModal(); } } catch {} } async _deleteFromEditor(){ if (!this._editing) return; const { entity, blockId, weeklyDay, profileName }=this._editing; if (!blockId) { this._closeEditor(); return; } if (weeklyDay && this._weeklyDraft) { // Delete from both _weeklyDraft.days AND _weeklyDraft._weeklyModes to ensure complete removal // First, try to delete from _weeklyDraft.days let foundInDays = false; if (this._weeklyDraft.days?.[weeklyDay]) { const arr = this._weeklyDraft.days[weeklyDay]; const idx = arr.findIndex(x=>x.id===blockId); if (idx >= 0) { arr.splice(idx, 1); foundInDays = true; } } // Also delete from _weeklyDraft._weeklyModes if it exists (should delete from ALL modes to be safe) let foundInModes = false; if (this._weeklyDraft?._weeklyModes) { for (const modeName in this._weeklyDraft._weeklyModes) { const modeBlocks = this._weeklyDraft._weeklyModes[modeName]?.days?.[weeklyDay]; if (Array.isArray(modeBlocks)) { const idx = modeBlocks.findIndex(x=>x.id===blockId); if (idx >= 0) { modeBlocks.splice(idx, 1); foundInModes = true; } } } } // If the weekly editor was opened implicitly from the main timeline, // save directly without applying group sync (which would re-copy deleted blocks) if (this._autoSaveWeeklyFromTimeline) { this._autoSaveWeeklyFromTimeline = false; // Save without applying group propagation const entity = this._weeklyEntity; const row = this._schedules[entity]; if (row) { const mode = this._weeklyDraft.mode || 'weekday_weekend'; this._ensureWeeklyStruct(row); // Save to both weekly and weekly_modes without syncing across group days. // IMPORTANT: main timeline builds _weeklyDraft from weekly_modes, so if we only // update row.weekly.days here, the next edit will reload stale blocks from // row.weekly_modes and "revive" deleted blocks. const daysCopy = JSON.parse(JSON.stringify(this._weeklyDraft.days || {})); // Keep any existing modes, but ensure the active mode reflects this draft. try { if (this._weeklyDraft._weeklyModes) { row.weekly_modes = JSON.parse(JSON.stringify(this._weeklyDraft._weeklyModes)); } } catch {} try { if (!row.weekly_modes || typeof row.weekly_modes !== 'object') row.weekly_modes = {}; row.weekly_modes[mode] = { days: JSON.parse(JSON.stringify(daysCopy)) }; } catch { try { row.weekly_modes = row.weekly_modes || {}; row.weekly_modes[mode] = { days: daysCopy }; } catch {} } row.weekly = { mode, days: JSON.parse(JSON.stringify(daysCopy)) }; this._schedules[entity] = row; this._config.weekdays_mode = mode; const before = this._desiredNowSnapshot(); try { this._persistDraftNow(); } catch {} this._markPendingSave(); if (this._config.apply_on_edit) await this._applyIfDesiredChanged(before); this._scheduleNextApply(); } this._closeEditor(); // Close weekly overlay when editing from main timeline const wov = this.shadowRoot.querySelector('.overlay-week'); if (wov) wov.classList.remove('open'); this._weeklyOpen = false; this._render(); } else { // Regular flow when user is inside the weekdays popup: update preview and stay there this._renderWeeklyModal(); this._closeEditor(); } return; } if (this._editing.presence && this._presenceDraft && this._presenceDraft.rooms) { try { const eid = this._editing.entity; const arr = (this._presenceDraft.rooms||{})[eid] || []; const idx = arr.findIndex(x=>x.id===blockId); if (idx>=0) arr.splice(idx,1); (this._presenceDraft.rooms||{})[eid] = arr; this._render(); this._closeEditor(); this._renderPresenceModal(); } catch { this._closeEditor(); } return; } if (this._editing.holiday && this._holidayDraft && this._holidayDraft.rooms) { try { const eid = this._editing.entity; const arr = (this._holidayDraft.rooms||{})[eid] || []; const idx = arr.findIndex(x=>x.id===blockId); if (idx>=0) arr.splice(idx,1); (this._holidayDraft.rooms||{})[eid] = arr; this._render(); this._closeEditor(); this._renderHolidayModal(); } catch { this._closeEditor(); } return; } if (profileName) { try { // Ensure draft exists and operate on the current room's draft blocks map const isNewProf = (profileName === '__new__'); const curRoom = this._profilesRoom || this._profilesEntity || entity; if (!this._profilesDraft || (!isNewProf && this._profilesDraft.name !== profileName)) { const row = this._schedules[entity]; const roomsMap = {}; for (const e of (this._config?.entities||[])){ const src = !isNewProf ? (this._schedules[e]?.profiles?.[profileName]?.blocks || []) : []; roomsMap[e] = JSON.parse(JSON.stringify(src)); } this._profilesDraft = { name: (isNewProf? null : profileName), rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; } const arr = (this._profilesDraft.rooms||{})[curRoom] || []; const idx = arr.findIndex(x=>x.id===blockId); if (idx>=0) arr.splice(idx,1); (this._profilesDraft.rooms||{})[curRoom] = arr; this._profilesDirty = true; // If deletion was initiated from the main timeline, persist immediately if (this._autoSaveProfileFromTimeline) { try { this._autoSaveProfileFromTimeline = false; await this._profilesSaveDraft(); } catch {} } this._render(); this._closeEditor(); if (this._profilesOpen) this._renderProfilesModal(); } catch { this._closeEditor(); } return; } this._deleteBlock(entity, blockId); this._closeEditor(); } _openNewEditor(entity){ this._ensureEditModeOn(); this._editing = { entity, blockId: null }; const row = this._schedules[entity]; if (!row) return; const edTemp = this.shadowRoot.querySelector(".ed-temp"); const edFrom = this.shadowRoot.querySelector(".ed-from"); const edTo = this.shadowRoot.querySelector(".ed-to"); const overlay = this.shadowRoot.querySelector(".overlay"); const now = this._getNowMin(); const start = this._clamp(Math.round(now), 0, 1380); const end = this._clamp(start + 60, start + 15, 1440); if (edTemp) edTemp.value = String(this._toDisplayTemp(row.defaultTemp ?? 20)); if (edFrom) edFrom.value = this._toTimeInput(start); if (edTo) edTo.value = this._toTimeInput(end); const title = this.shadowRoot.querySelector('.modal h3'); if (title) title.textContent = this._t('ui.add_block'); const delBtn = this.shadowRoot.querySelector('.ed-delete'); if (delBtn) delBtn.style.display = 'none'; const err = this.shadowRoot.querySelector(".ed-error"); if (err) { err.style.display = "none"; err.textContent = ""; } /* popups visible: continue */ // Hide any tooltips while editing try { const tips = this.shadowRoot.querySelectorAll('.wk-tooltip'); tips.forEach(t => { try { if (t._hideTimer) clearTimeout(t._hideTimer); } catch {} t.style.display='none'; }); } catch {} try { const modalEl = this.shadowRoot.querySelector('.overlay .modal:not(.modal-week)'); if (modalEl) modalEl.classList.toggle('modal-12h', !!this._config?.time_12h); } catch {} // Open after a frame to avoid half-rendered UI flash try { requestAnimationFrame(()=> requestAnimationFrame(()=> { overlay.classList.add('open'); })); } catch { overlay.classList.add('open'); } try { const fromMer = this.shadowRoot.querySelector('.ed-from-mer'); const toMer = this.shadowRoot.querySelector('.ed-to-mer'); if (this._config?.time_12h) { if (edFrom) edFrom.value = this._toTimeInput12h(start); if (edTo) edTo.value = this._toTimeInput12h(end); if (fromMer){ fromMer.style.display=''; fromMer.value = (start>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value = (end>=720 && end<1440)?'PM':'AM'; } } else { if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } } catch {} if (edTemp) { edTemp.max = String(this._maxDisplay()); edTemp.min = String(this._minDisplay()); } try { const onKey = (ev) => { if (ev.key === 'Escape') { this._closeEditor(); } }; window.addEventListener('keydown', onKey, { once: true }); } catch {} // Improved keyboard navigation between time fields (two-step Tab in "Fra") try { const fromInput = this.shadowRoot.querySelector(".ed-from"); const toInput = this.shadowRoot.querySelector(".ed-to"); if (fromInput && toInput) { // Focus "Fra" on open for fast editing setTimeout(() => { try { fromInput.focus(); fromInput.select?.(); } catch {} }, 0); // Two-step Tab inside "Fra": hours -> minutes -> move to "Til" let fraStep = 0; fromInput.addEventListener("keydown", (e) => { if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); try { const val = fromInput.value || ""; if (fraStep === 0) { if (fromInput.setSelectionRange) { setTimeout(() => { try { fromInput.setSelectionRange(3, 5); } catch {} }, 0); } fraStep = 1; } else { fraStep = 0; toInput.focus(); toInput.select?.(); } } catch { if (fraStep == 0) { fraStep = 1; } else { fraStep = 0; toInput.focus(); toInput.select?.(); } } } }); // Shift+Tab from Til -> Fra toInput.addEventListener("keydown", (e) => { if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); fraStep = 1; fromInput.focus(); try { fromInput.setSelectionRange?.(3,5); } catch {} } }); fromInput.addEventListener("input", () => { fraStep = 0; }); } } catch {} } // ---------- Weekdays modal ---------- _openWeeklyEditor(entity){ this._ensureEditModeOn(); try { this._applySeasonalModeToRows(); } catch {} this._weeklyEntity = entity; const row = this._schedules[entity] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureWeeklyStruct(row); // Reset any pending cross-room copies for this editing session this._weeklyCopyTargets = []; // Away delay tracking this._awayWant = null; // boolean | null this._awayWantSinceMs = 0; // timestamp when away want last changed // Deep copy as draft from the dataset for current mode try { const curMode = (row.weekly?.mode) || (this._config?.weekdays_mode) || 'weekday_weekend'; const src = row.weekly_modes?.[curMode]?.days || {}; const names = this._weekdayNames(); const draftDays = JSON.parse(JSON.stringify(src)); for (const k of names){ if (!Array.isArray(draftDays[k])) draftDays[k] = []; } this._weeklyDraft = { mode: curMode, days: draftDays }; } catch { this._weeklyDraft = JSON.parse(JSON.stringify(row.weekly)); } this._weeklyDayKey = this._todayKey(); this._weeklyOpen = true; const ov = this.shadowRoot.querySelector('.overlay-week'); // Mark overlaying first to hide background artifacts try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} // Build the modal while hidden this._renderWeeklyModal(); // Open after a paint try { requestAnimationFrame(()=> requestAnimationFrame(()=> { if (ov) ov.classList.add('open'); })); } catch { if (ov) ov.classList.add('open'); } } _closeWeeklyEditor(){ try { this._weeklyOpen = false; this._weeklyEntity = null; this._weeklyDraft = null; this._weeklyDayKey = null; } catch {} try { this.shadowRoot.querySelector('.overlay-week')?.classList.remove('open'); } catch {} try { this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} try { if (this._resumeSettingsAfterWeekdays) { this._resumeSettingsAfterWeekdays = false; this._resumeSettingsPopup?.(); } } catch {} } _weeklySaveAll(){ try { if (!this._weeklyEntity || !this._weeklyDraft) { this._closeWeeklyEditor(); return; } const entity = this._weeklyEntity; const row = this._schedules[entity]; if (!row) { this._closeWeeklyEditor(); return; } // Normalize by mode: propagate grouped days within the selected mode only const mode = this._weeklyDraft.mode || 'weekday_weekend'; const d = this._weeklyDraft.days || {}; const applyGroup = (keys, srcKey)=>{ const src = JSON.parse(JSON.stringify(d[srcKey]||[])); for (const k of keys){ d[k] = JSON.parse(JSON.stringify(src)); } }; if (mode === 'weekday_weekend') { applyGroup(['tue','wed','thu','fri'], 'mon'); applyGroup(['sun'], 'sat'); } else if (mode === 'weekday_sat_sun') { applyGroup(['tue','wed','thu','fri'], 'mon'); } // all_7 -> keep individual this._ensureWeeklyStruct(row); // Save into mode bucket and set active weekly to this mode try { row.weekly_modes[mode] = { days: JSON.parse(JSON.stringify(d)) }; } catch { row.weekly_modes[mode] = { days: d }; } row.weekly = { mode, days: JSON.parse(JSON.stringify(d)) }; this._schedules[entity] = row; // Also update the global fallback mode so other rooms without per-room override follow this selection this._config.weekdays_mode = mode; // Also apply to any pending target rooms selected via "copy week to rooms" const targets = Array.isArray(this._weeklyCopyTargets) ? this._weeklyCopyTargets : []; if (targets.length) { // Make a fresh copy for targets (don't reuse the modified 'd' which already has applyGroup applied) const targetData = JSON.parse(JSON.stringify(this._weeklyDraft.days || {})); for (const eid of targets){ try { const trow = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureWeeklyStruct(trow); // Apply group propagation on the target copy (independent from source) const tdata = JSON.parse(JSON.stringify(targetData)); const applyGroupTarget = (keys, srcKey)=>{ const src = JSON.parse(JSON.stringify(tdata[srcKey]||[])); for (const k of keys){ tdata[k] = JSON.parse(JSON.stringify(src)); } }; if (mode === 'weekday_weekend') { applyGroupTarget(['tue','wed','thu','fri'], 'mon'); applyGroupTarget(['sun'], 'sat'); } else if (mode === 'weekday_sat_sun') { applyGroupTarget(['tue','wed','thu','fri'], 'mon'); } trow.weekly_modes[mode] = { days: JSON.parse(JSON.stringify(tdata)) }; trow.weekly = { mode, days: JSON.parse(JSON.stringify(tdata)) }; this._schedules[eid] = trow; } catch {} } } // Clear pending list after Save this._weeklyCopyTargets = []; const before = this._desiredNowSnapshot(); try { this._persistDraftNow(); } catch {} this._markPendingSave(); this._closeWeeklyEditor(); this._render(); if (this._config.apply_on_edit) this._applyIfDesiredChanged(before); this._scheduleNextApply(); } catch(e){ this._closeWeeklyEditor(); } } _renderWeeklyModal(){ try { const modeSel = this.shadowRoot.querySelector('.week-mode'); const titleEl = this.shadowRoot.querySelector('.week-title'); const roomEl = this.shadowRoot.querySelector('.week-room'); const roomTabs = this.shadowRoot.querySelector('.week-roomtabs'); const note = this.shadowRoot.querySelector('.week-note'); const tabsWrap = this.shadowRoot.querySelector('.week-tabs'); const pasteBar = this.shadowRoot.querySelector('.week-pastebar'); const actionsWrap = this.shadowRoot.querySelector('.wk-actions'); const track = this.shadowRoot.querySelector('.week-track'); const scale = this.shadowRoot.querySelector('.week-scale-inner'); const modalWeek = this.shadowRoot.querySelector('.modal-week'); if (!this._weeklyDraft) return; // i18n if (titleEl) titleEl.textContent = this._t('week.editor.title'); // Room name on the same header line try { if (roomEl) { const eid = this._weeklyEntity; let name = ''; if (eid) { const custom = (this._config?.labels && this._config.labels[eid]) ? this._config.labels[eid] : null; name = custom || this._prettyName(eid); } roomEl.textContent = name || ''; } } catch {} // Build room tabs (switch room in weekdays editor) try { if (roomTabs) { roomTabs.innerHTML = ''; const ents = this._config?.entities || []; if (!this._weeklyEntity || !ents.includes(this._weeklyEntity)) this._weeklyEntity = ents[0] || null; for (const eid of ents){ const btn = document.createElement('button'); btn.type='button'; btn.className = 'wk-tab' + (eid===this._weeklyEntity ? ' active' : ''); let label = ''; try { label = this._config?.labels?.[eid] || (this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1]||eid)); } catch { label = eid; } btn.textContent = label; btn.addEventListener('click', ()=>{ if (eid!==this._weeklyEntity) this._openWeeklyEditor(eid); }); roomTabs.append(btn); } // Hide tabs if only one room roomTabs.style.display = (ents.length>1) ? 'flex' : 'none'; } } catch (e) {} const modeLab = this.shadowRoot.querySelector('.week-mode-label'); if (modeLab) modeLab.textContent = this._t('week.mode'); if (note) note.textContent = this._t('week.editor.preview_note'); if (modeSel) { modeSel.value = this._weeklyDraft.mode || 'weekday_weekend'; // set option labels i18n for (const opt of Array.from(modeSel.options)){ if (opt.value==='weekday_weekend') opt.textContent = this._t('week.mode.weekday_weekend'); if (opt.value==='weekday_sat_sun') opt.textContent = this._t('week.mode.weekday_sat_sun'); if (opt.value==='all_7') opt.textContent = this._t('week.mode.all_7'); } modeSel.onchange = () => { const newMode = modeSel.value; this._weeklyDraft.mode = newMode; // Update global fallback so per-room view reflects latest choice this._config.weekdays_mode = newMode; try { const row = this._schedules[this._weeklyEntity]; this._ensureWeeklyStruct(row); const src = row.weekly_modes?.[newMode]?.days || {}; const names = this._weekdayNames(); const draftDays = JSON.parse(JSON.stringify(src)); for (const k of names){ if (!Array.isArray(draftDays[k])) draftDays[k] = []; } this._weeklyDraft.days = draftDays; } catch {} this._renderWeeklyModal(); }; } // Seasonal selector (Summer/Winter) try { const seasonRow = this.shadowRoot.querySelector('.week-season-row'); const seasonLab = this.shadowRoot.querySelector('.week-season-label'); const seasonSel = this.shadowRoot.querySelector('.week-season'); if (seasonRow && seasonSel && seasonLab) { const on = !!this._config?.seasonal_enabled; seasonRow.style.display = on ? '' : 'none'; if (on) { seasonLab.textContent = this._t('season.mode.title') || 'Season'; const optW = seasonSel.querySelector('.opt-season-winter'); const optS = seasonSel.querySelector('.opt-season-summer'); if (optW) optW.textContent = this._t('season.mode.winter') || 'Winter'; if (optS) optS.textContent = this._t('season.mode.summer') || 'Summer'; seasonSel.value = this._seasonKey(); seasonSel.onchange = ()=>{ try { const next = (seasonSel.value === 'summer') ? 'summer' : 'winter'; this._setSeasonMode(next); this._renderWeeklyModal(); } catch {} }; } } } catch {} // Copy/Paste day logic // Helper to resolve which real days are in a group for current mode const groupDaysForMode = (mode, key)=>{ if (mode === 'weekday_weekend') { if (key==='weekdays') return ['mon','tue','wed','thu','fri']; if (key==='weekend') return ['sat','sun']; } else if (mode === 'weekday_sat_sun') { if (key==='weekdays') return ['mon','tue','wed','thu','fri']; if (key==='sat') return ['sat']; if (key==='sun') return ['sun']; } else if (mode === 'all_7') { return [key]; } return [this._effectiveDayKey(key)]; }; // Build per-day action buttons (unique DOM per tab) const renderActions = () => { if (!actionsWrap) return; actionsWrap.innerHTML = ''; const curGroup = this._weeklyDayKey || 'weekdays'; const mode = this._weeklyDraft.mode || 'weekday_weekend'; // Global action: copy entire week to other rooms const copyAllBtn = document.createElement('button'); copyAllBtn.type = 'button'; copyAllBtn.className = 'btn ghost wk-copyall'; copyAllBtn.textContent = this._t('week.copy_to_rooms'); copyAllBtn.onclick = (e)=>{ e.preventDefault(); this._openCopyWeekToRooms(); }; actionsWrap.append(copyAllBtn); const itemsKeys = []; if (this._weeklyDraft.mode === 'weekday_weekend') itemsKeys.push('weekdays','weekend'); else if (this._weeklyDraft.mode === 'weekday_sat_sun') itemsKeys.push('weekdays','sat','sun'); else itemsKeys.push('mon','tue','wed','thu','fri','sat','sun'); for (const key of itemsKeys){ const box = document.createElement('div'); box.style.display = key === curGroup ? 'flex' : 'none'; box.style.gap = '8px'; box.setAttribute('data-key', key); // Copy/Paste button (contextual) const cp = document.createElement('button'); cp.type = 'button'; cp.className = 'btn'; const setCpStyle = (m)=>{ cp.className='btn'; if (m==='copy') cp.classList.add('ghost'); if (m==='paste') cp.classList.add('success'); }; if (!this._weeklyCopyFromGroup) { cp.textContent = this._t('ui.copy_day'); setCpStyle('copy'); } else if (this._weeklyCopyFromGroup === key) { cp.textContent = this._t('ui.copy_day'); setCpStyle('copy'); } else { cp.textContent = this._t('ui.paste_day'); setCpStyle('paste'); } // Disable on source day while copy mode is active cp.disabled = !!this._weeklyCopyFromGroup && this._weeklyCopyFromGroup === key; cp.onclick = (ev)=>{ ev.preventDefault(); ev.stopPropagation(); if (cp.disabled) return; const modeNow = this._weeklyDraft.mode || 'weekday_weekend'; if (!this._weeklyCopyFromGroup) { const gDays = groupDaysForMode(modeNow, key); const srcDay = (modeNow==='all_7') ? this._effectiveDayKey(key) : gDays[0]; const arr = (this._weeklyDraft?.days?.[srcDay] || []); this._weeklyCopyFromGroup = key; this._weeklyCopyPayload = JSON.parse(JSON.stringify(arr)); renderActions(); return; } if (this._weeklyCopyFromGroup === key) { // Refresh payload from same source, stay in copy mode const gDays = groupDaysForMode(modeNow, key); const srcDay = (modeNow==='all_7') ? this._effectiveDayKey(key) : gDays[0]; const arr = (this._weeklyDraft?.days?.[srcDay] || []); this._weeklyCopyPayload = JSON.parse(JSON.stringify(arr)); return; } // Paste into this key try { const payload = Array.isArray(this._weeklyCopyPayload) ? this._weeklyCopyPayload.map(x=>({ ...x, id: Math.random().toString(36).slice(2,9) })) : []; const targets = (modeNow==='all_7') ? [this._effectiveDayKey(key)] : groupDaysForMode(modeNow, key); const daysClone = { ...(this._weeklyDraft.days || {}) }; for (const dk of targets){ const arr = payload.map(x=>({ ...x })); arr.sort((a,b)=>a.startMin-b.startMin||a.endMin-b.endMin); daysClone[dk] = arr; } this._weeklyDraft.days = daysClone; this._ensureWeeklyStruct(this._schedules[this._weeklyEntity] || { weekly:{} }); } catch(e){} this._weeklyDayKey = key; this._renderWeeklyModal(); }; // Add block button (per day) — doubles as Cancel while in copy mode const add = document.createElement('button'); add.type = 'button'; const applyAddState = ()=>{ const inCopy = !!this._weeklyCopyFromGroup; add.className = 'btn ' + (inCopy ? 'danger' : 'primary'); add.textContent = inCopy ? this._t('ui.cancel') : ('+ ' + this._t('ui.add_block')); }; applyAddState(); add.onclick = ()=>{ if (this._weeklyCopyFromGroup) { // Cancel copy mode this._weeklyCopyFromGroup = null; this._weeklyCopyPayload = null; renderActions(); } else { this._weeklyDayKey = key; this._openWeeklyBlockEditor(null); } }; box.append(cp, add); actionsWrap.append(box); } }; renderActions(); // Ensure pastebar stays hidden with the new contextual control try { const pb = this.shadowRoot.querySelector('.week-pastebar'); if (pb) { pb.style.display='none'; pb.innerHTML=''; } } catch{} // Paste target bar (always real days mon..sun). Visible only in copy mode if (pasteBar) { if (!this._weeklyCopyFrom) { pasteBar.style.display = 'none'; pasteBar.innerHTML = ''; } else { pasteBar.style.display = 'flex'; pasteBar.innerHTML = ''; const names = this._weekdayNames(); for (const k of names){ const btn = document.createElement('button'); btn.type='button'; if (k === this._weeklyCopyFrom) { btn.className = 'btn danger'; btn.textContent = this._t('ui.cancel'); btn.onclick = ()=>{ this._weeklyCopyFrom = null; this._weeklyCopyPayload = null; this._renderWeeklyModal(); }; } else { btn.className = 'btn success'; const lab = (this._t('week.days_full.'+k) || this._t('week.days.'+k) || k); btn.textContent = this._t('ui.paste_day') + ' ' + lab; btn.onclick = ()=>{ try { const payload = Array.isArray(this._weeklyCopyPayload) ? this._weeklyCopyPayload.map(x=>({ ...x, id: Math.random().toString(36).slice(2,9) })) : []; this._weeklyDraft.days[k] = payload.sort((a,b)=>a.startMin-b.startMin||a.endMin-b.endMin); this._renderWeeklyModal(); } catch(e){} }; } pasteBar.append(btn); } } } // Build group/day tabs reflecting mode grouping tabsWrap.innerHTML = ''; const editable = this._weeklyEditableKeys(this._weeklyDraft.mode); const items = []; if (this._weeklyDraft.mode === 'weekday_weekend') { items.push({key:'weekdays', label: (this._t('week.group.weekdays')||'Weekdays')}); items.push({key:'weekend', label: (this._t('week.group.weekend')||'Weekend')}); } else if (this._weeklyDraft.mode === 'weekday_sat_sun') { items.push({key:'weekdays', label: (this._t('week.group.weekdays')||'Weekdays')}); items.push({key:'sat', label: this._t('week.days_full.sat') || this._t('week.days.sat')}); items.push({key:'sun', label: this._t('week.days_full.sun') || this._t('week.days.sun')}); } else { // all_7 for (const k of this._weekdayNames()) items.push({key:k, label:(this._t('week.days_full.'+k) || this._t('week.days.'+k))}); } if (!editable.includes(this._weeklyDayKey)) this._weeklyDayKey = editable[0] || 'weekdays'; // Helper: group -> array of real day keys const groupDays2 = (mode, key)=>{ if (mode === 'weekday_weekend') { if (key==='weekdays') return ['mon','tue','wed','thu','fri']; if (key==='weekend') return ['sat','sun']; } else if (mode === 'weekday_sat_sun') { if (key==='weekdays') return ['mon','tue','wed','thu','fri']; if (key==='sat') return ['sat']; if (key==='sun') return ['sun']; } else if (mode === 'all_7') { return [key]; } return [this._effectiveDayKey(key)]; }; for (const it of items){ const tab = document.createElement('button'); tab.type = 'button'; tab.className = 'wk-tab' + (it.key === this._weeklyDayKey ? ' active' : ''); tab.textContent = it.label; tab.addEventListener('click', ()=>{ this._weeklyDayKey = it.key; this._renderWeeklyModal(); }); tabsWrap.append(tab); } // Rebuild actions so the visible set matches active tab renderActions(); // Compact scale ticks (every 2 hours) if (scale) { scale.innerHTML = ''; for (let i=0;i<=24;i+=2){ const t = document.createElement('div'); t.style.position='absolute'; t.style.left = (i/24*100)+'%'; t.style.top='4px'; t.style.bottom='4px'; t.style.borderLeft='1px solid var(--divider-color)'; const lab = document.createElement('div'); lab.style.position='absolute'; lab.style.top='-2px'; lab.style.transform='translate(-50%,0)'; lab.style.left='0'; lab.style.fontSize='.75rem'; lab.style.color='var(--secondary-text-color)'; if (this._config?.time_12h){ const p=this._timeParts(i===24?0:i*60); lab.innerHTML = `
${p.main}
${p.mer||''}
`; } else { lab.textContent = (i===24? this._label(0) : this._label(i*60)); } t.append(lab); scale.append(t); } } // Render preview track with today's selected day blocks track.innerHTML = ''; const effKey = this._effectiveDayKey(this._weeklyDayKey); const blocks = (this._weeklyDraft.days?.[effKey] || []); // Ensure tooltip exists (scoped to the weekly modal to avoid conflicts with other tooltips) let tooltip = (modalWeek?.querySelector('.wk-tooltip.week')) || this.shadowRoot.querySelector('.wk-tooltip.week'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className='wk-tooltip week'; tooltip.style.display='none'; modalWeek?.append(tooltip); } for (const b of blocks){ const div = document.createElement('div'); div.className = 'block'; div.style.position='absolute'; div.style.top='6px'; div.style.bottom='6px'; div.style.left = (b.startMin/1440*100)+'%'; div.style.width = ((b.endMin-b.startMin)/1440*100)+'%'; // Only show temperature in the block in the weekly popup (hide time) try { const clr = this._colorFor(this._weeklyEntity, b.temp); if (clr) { div.style.background = clr; div.style.borderColor = clr; const txt = this._contrastTextColor(clr); if (txt) div.style.color = txt; } } catch {} UiHelper.genBlockPill( this, div, `${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}` ); // double-click to edit (prevent bubbling to track handler) div.addEventListener('dblclick', (ev)=>{ try { ev.stopPropagation(); ev.preventDefault(); } catch {} this._openWeeklyBlockEditor(b.id); }); // Hover -> show tooltip with full time range + temp (no click needed) const showTip = ()=>{ const txt = `${this._label(b.startMin)} - ${this._label(b.endMin)} • ${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}`; if (!tooltip) return; try { if (tooltip._hideTimer) { clearTimeout(tooltip._hideTimer); tooltip._hideTimer = null; } } catch {} // Do not show while editor popup is open try { const ov = this.shadowRoot.querySelector('.overlay'); if (this._editing || (ov && ov.classList.contains('open'))) return; } catch {} tooltip.textContent = txt; const box = div.getBoundingClientRect(); const cont = modalWeek?.getBoundingClientRect(); const left = box.left + box.width/2 - (cont?.left||0); const top = (box.top - (cont?.top||0)) - 8; // a little above tooltip.style.left = left + 'px'; tooltip.style.top = top + 'px'; tooltip.style.transform = 'translate(-50%,-100%)'; tooltip.style.display = ''; }; const hideTip = ()=>{ if (!tooltip) return; const delay = (window.matchMedia && window.matchMedia('(pointer:coarse)').matches) ? 3000 : 120; try { if (tooltip._hideTimer) clearTimeout(tooltip._hideTimer); } catch {} tooltip._hideTimer = setTimeout(() => { try { tooltip.style.display='none'; tooltip._hideTimer = null; } catch {} }, delay); }; div.addEventListener('mouseenter', showTip); div.addEventListener('mouseleave', hideTip); track.append(div); } // Add click on empty area to add track.addEventListener('dblclick', (e)=>{ try { if (e.target && (e.target.closest && e.target.closest('.block'))) return; const box = track.getBoundingClientRect(); const rel = (e.clientX - box.left) / box.width; const min = this._clamp(Math.round(rel*1440),0,1439); this._openWeeklyBlockEditor(null, min); } catch { this._openWeeklyBlockEditor(null); } }); track.addEventListener('mouseleave', ()=>{ const tip = modalWeek?.querySelector('.wk-tooltip.week'); if (!tip) return; try { if (tip._hideTimer) { clearTimeout(tip._hideTimer); tip._hideTimer = null; } } catch {} const delay = (window.matchMedia && window.matchMedia('(pointer:coarse)').matches) ? 3000 : 120; tip._hideTimer = setTimeout(()=>{ try { tip.style.display='none'; tip._hideTimer = null; } catch{} }, delay); }); } catch(e){} } // ---------- Profiles modal ---------- _openProfilesEditor(entity){ try { this._ensureEditModeOn(); this._profilesEntity = entity; const row = this._schedules[entity] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureProfilesStruct(row); // New creation by default: empty timeline this._profilesSelected = null; this._profilesEditingExisting = false; // Initialize empty draft for ALL rooms const roomsMap = {}; for (const eid of (this._config?.entities||[])) roomsMap[eid] = []; this._profilesDraft = { name: null, rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; this._profilesDirty = false; this._profilesEditingNew = true; this._profilesRoom = (this._config?.entities||[])[0] || entity; this._profilesOpen = true; const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-profiles'); // Mark overlaying, render while hidden, then open after paint try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} this._renderProfilesModal(); try { requestAnimationFrame(()=> requestAnimationFrame(()=> { if (ov) ov.classList.add('open'); })); } catch { if (ov) ov.classList.add('open'); } } catch { /* no-op */ } } _openConfirmCloseProfiles(){ try { const pov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-profiles'); if (!pov) return; // If an existing confirm is open, do nothing if (pov.querySelector('.mini-confirm')) return; const wrap = document.createElement('div'); wrap.className='mini-confirm'; const box = document.createElement('div'); box.className='box'; const h = document.createElement('h4'); h.textContent = this._t('ui.unsaved_confirm'); const btns = document.createElement('div'); btns.className='btns'; const keep = document.createElement('button'); keep.type='button'; keep.className='btn ghost'; keep.textContent = this._t('ui.keep_editing') || 'Keep editing'; const discard = document.createElement('button'); discard.type='button'; discard.className='btn danger'; discard.textContent = this._t('ui.discard_changes') || 'Discard changes'; keep.addEventListener('click', ()=>{ try { wrap.remove(); } catch {} }); discard.addEventListener('click', ()=>{ try { wrap.remove(); } catch {}; this._closeProfilesEditor(); }); btns.append(keep, discard); box.append(h, btns); wrap.append(box); pov.append(wrap); } catch {} } _closeProfilesEditor(){ this._profilesOpen = false; this._profilesEntity = null; this._profilesSelected = null; this.shadowRoot.querySelector('.overlay-profiles')?.classList.remove('open'); try { this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} } _renderProfilesModal(){ try { const title = this.shadowRoot.querySelector('.prof-title'); if (title) title.textContent = this._t('profiles.editor.title'); const roomEl = this.shadowRoot.querySelector('.prof-room'); if (roomEl) { const eid = this._profilesEntity; const custom = (this._config?.labels?.[eid]) || null; roomEl.textContent = custom || this._prettyName(eid||''); } const note = this.shadowRoot.querySelector('.prof-note'); if (note) note.textContent = this._t('profiles.preview_note'); const list = this.shadowRoot.querySelector('.prof-list'); const addBtn = this.shadowRoot.querySelector('.prof-add'); const closeBtn = this.shadowRoot.querySelector('.prof-close'); const saveBtn = this.shadowRoot.querySelector('.prof-save'); const modalProf = this.shadowRoot.querySelector('.modal-profiles'); const scale = modalProf?.querySelector('.week-scale-inner') || this.shadowRoot.querySelector('.week-scale-inner'); const track = modalProf?.querySelector('.week-track') || this.shadowRoot.querySelector('.week-track'); const roomTabs = modalProf?.querySelector('.prof-roomtabs'); if (!list || !track || !this._profilesEntity) return; // Keep the Save button label in sync with current mode try { if (saveBtn) { const isExisting = !!(this._profilesEditingExisting && this._profilesSelected); const key = isExisting ? 'profiles.save_existing' : 'profiles.save_new'; saveBtn.textContent = this._t(key) || this._t('ui.save') || 'Save'; } } catch {} const row = this._schedules[this._profilesEntity] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureProfilesStruct(row); // Build room tabs (one per configured entity) if (roomTabs) { roomTabs.innerHTML = ''; const ents = this._config?.entities || []; if (!this._profilesRoom || !ents.includes(this._profilesRoom)) this._profilesRoom = ents[0] || null; for (const eid of ents){ const btn = document.createElement('button'); btn.type='button'; btn.className='wk-tab' + (eid===this._profilesRoom?' active':''); let label = this._config?.labels?.[eid] || (this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1]||eid)); btn.textContent = label; btn.addEventListener('click', ()=>{ this._profilesRoom = eid; this._renderProfilesModal(); }); roomTabs.append(btn); } } // Build list list.innerHTML = ''; const names = Object.keys(row.profiles || {}); if (!names.length) { const p = document.createElement('div'); p.style.fontSize = '.9rem'; p.style.color='var(--secondary-text-color)'; p.textContent = this._t('profiles.none'); list.append(p); } for (const name of names){ const item = document.createElement('div'); item.className = 'prof-item' + (this._profilesSelected===name ? ' active' : ''); // Name label: intentionally non-interactive (only action buttons should do something) const left = document.createElement('div'); left.className='prof-chip'; left.textContent = name; const actions = document.createElement('div'); actions.className = 'prof-actions'; const editBtn = document.createElement('button'); editBtn.type='button'; editBtn.className='btn ghost'; editBtn.textContent=this._t('profiles.edit'); // Highlight Edit button when this profile is the one currently being edited try { if (this._profilesEditingExisting && this._profilesSelected === name) { editBtn.classList.remove('ghost'); editBtn.classList.add('success'); editBtn.setAttribute('aria-pressed','true'); } else { editBtn.setAttribute('aria-pressed','false'); } } catch {} editBtn.addEventListener('click', ()=>{ // Enter edit mode explicitly (overwriting the selected profile on Save) this._profilesSelected = name; this._profilesEditingExisting = true; this._profilesEditingNew = false; try { const roomsMap = {}; for (const eid of (this._config?.entities||[])){ const src = this._schedules[eid]?.profiles?.[name]?.blocks || []; roomsMap[eid] = JSON.parse(JSON.stringify(src)); } this._profilesDraft = { name, rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; this._profilesDirty = false; } catch { this._profilesDraft = null; this._profilesDirty = false; } this._renderProfilesModal(); try { const trk = modalProf?.querySelector('.week-track'); trk?.scrollIntoView({ behavior:'smooth', block:'center' }); } catch {} }); const act = document.createElement('button'); act.type='button'; act.className = 'btn ' + (row.activeProfile===name?'success':'ghost'); act.textContent = (row.activeProfile===name)? this._t('profiles.deactivate') : this._t('profiles.activate'); act.addEventListener('click', async ()=>{ const before = this._desiredNowSnapshot(); // Toggle global activation: if already globally selected, turn off; else activate this name for all rooms const enable = (this._globalProfile !== name); if (enable) { this._globalProfile = name; this._config.profiles_enabled = true; } else { this._globalProfile = null; // Keep profiles_enabled as-is; user can still have per-room profiles } // Apply across all configured rooms for (const eid of (this._config?.entities || [])){ const r = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureProfilesStruct(r); if (enable) { r.activeProfile = (r.profiles && r.profiles[name]) ? name : null; } else { r.activeProfile = null; } this._schedules[eid] = r; } this._markPendingSave(); this._render(); this._renderProfilesModal(); try { if (this._config?.auto_apply && !this._config?.storage_enabled && !this._isPaused()) { await this._applyIfDesiredChanged(before); } } catch {} this._scheduleNextApply(); // Auto-close profiles modal when a profile is (de)activated try { this._closeProfilesEditor(); } catch {} }); const ren = document.createElement('button'); ren.type='button'; ren.className='btn ghost'; ren.textContent=this._t('profiles.rename'); ren.addEventListener('click', async ()=>{ const cur = name; const txt = prompt(this._t('profiles.rename'), cur) || ''; const nn = txt.trim(); if (!nn || nn===cur) return; if (row.profiles[nn]) { alert(this._t('profiles.name_exists') || 'Name exists'); return; } row.profiles[nn] = row.profiles[cur]; delete row.profiles[cur]; if (row.activeProfile===cur) row.activeProfile = nn; if (this._profilesSelected===cur) this._profilesSelected = nn; this._markPendingSave(); this._renderProfilesModal(); this._render(); }); const del = document.createElement('button'); del.type='button'; del.className='remove-btn'; del.innerHTML = ''+this._t('profiles.delete')+''; del.addEventListener('click', async ()=>{ if (!confirm(this._t('profiles.delete')+'?')) return; delete row.profiles[name]; if (row.activeProfile===name) row.activeProfile=null; if (this._profilesSelected===name) this._profilesSelected = Object.keys(row.profiles||{})[0]||null; this._markPendingSave(); this._renderProfilesModal(); this._render(); this._scheduleNextApply(); }); actions.append(editBtn, act, ren, del); item.append(left, actions); list.append(item); } if (addBtn) { try { const sp = addBtn.querySelector('span'); if (sp) sp.textContent = this._t('profiles.add'); } catch {} addBtn.classList.remove('btn','primary'); addBtn.classList.add('add-entity-btn'); addBtn.onclick = ()=>{ // Reset to a brand‑new draft (empty timeline) try { const eid = this._profilesEntity; const row = this._schedules[eid] || { defaultTemp: this._config.default_temp }; this._profilesSelected = null; this._profilesEditingNew = true; this._profilesEditingExisting = false; this._profilesDraft = { name: null, blocks: [], defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; this._profilesDirty = false; this._renderProfilesModal(); } catch {} }; } if (closeBtn) { try { closeBtn.textContent = this._t('ui.close') || 'Close'; } catch {} } // Draw scale try { if (scale) { scale.innerHTML = ''; // Match the weekly preview: ticks every 2 hours using simple borders and small labels for (let i=0;i<=24;i+=2){ const t = document.createElement('div'); t.style.position='absolute'; t.style.left = (i/24*100)+'%'; t.style.top='4px'; t.style.bottom='4px'; t.style.borderLeft='1px solid var(--divider-color)'; const lab = document.createElement('div'); lab.style.position='absolute'; lab.style.top='-2px'; lab.style.transform='translate(-50%,0)'; lab.style.left='0'; lab.style.fontSize='.75rem'; lab.style.color='var(--secondary-text-color)'; if (this._config?.time_12h){ const p=this._timeParts(i===24?0:i*60); lab.innerHTML = `
${p.main}
${p.mer||''}
`; } else { lab.textContent = (i===24 ? this._label(0) : this._label(i*60)); } t.append(lab); scale.append(t); } } } catch {} // Draw track for selected profile track.innerHTML = ''; const sel = this._profilesSelected; let pblocks = []; const curRoom = this._profilesRoom || this._profilesEntity; if (this._profilesDraft && (!sel || this._profilesDraft.name===sel)) { const rooms = this._profilesDraft.rooms || {}; pblocks = rooms[curRoom] || []; } else if (sel) { // Fallback: show blocks from the current room's saved profile const r = this._schedules[curRoom] || {}; pblocks = (r.profiles?.[sel]?.blocks) || []; } const addBtnInline = document.createElement('button'); addBtnInline.type='button'; addBtnInline.className='btn primary'; addBtnInline.innerHTML = ''+this._t('ui.add_block')+''; addBtnInline.addEventListener('click', ()=>{ this._openProfileBlockEditor(null, this._getNowMin()); }); const toolbar = document.createElement('div'); toolbar.className='prof-toolbar'; toolbar.style.display='flex'; toolbar.style.justifyContent='flex-end'; toolbar.style.gap='8px'; toolbar.append(addBtnInline); const topbar = document.createElement('div'); topbar.className='prof-topbar'; // Move the existing note into the topbar so note (left) + add button (right) are on the same line try { if (note) { note.style.whiteSpace = 'normal'; topbar.append(note); } } catch {} topbar.append(toolbar); // Place topbar ABOVE the time scale (before the .week-scale container) try { const outerScale = (scale && scale.closest) ? scale.closest('.week-scale') : (modalProf?.querySelector('.week-scale') || null); const parent = outerScale?.parentElement || track.parentElement; if (parent) { Array.from(parent.querySelectorAll('.prof-topbar')).forEach(el=>el.remove()); Array.from(parent.querySelectorAll('.prof-toolbar')).forEach(el=>el.remove()); if (outerScale) parent.insertBefore(topbar, outerScale); else parent.insertBefore(topbar, track); } } catch {} // Tooltip (same style as weekdays) let tooltip = this.shadowRoot.querySelector('.wk-tooltip.prof'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className='wk-tooltip prof'; tooltip.style.display='none'; modalProf?.append(tooltip); } for (const b of pblocks){ const pctStart = (b.startMin/1440)*100; const pctW = ((b.endMin-b.startMin)/1440)*100; const bl = document.createElement('div'); bl.className='block'; bl.style.left=`${pctStart}%`; bl.style.width=`${pctW}%`; try { const clr = this._colorFor(this._profilesEntity, b.temp); if (clr){ bl.style.background=clr; bl.style.borderColor=clr; const txt=this._contrastTextColor(clr); if (txt) bl.style.color=txt; } } catch {} // Hide pill via CSS; use hover tooltip like weekdays UiHelper.genBlockPill( this, bl, `${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}` ); bl.addEventListener('dblclick', ()=> this._openProfileBlockEditor(b.id)); const showTip = ()=>{ const txt = `${this._label(b.startMin)} - ${this._label(b.endMin)} • ${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}`; if (!tooltip) return; try { if (tooltip._hideTimer) { clearTimeout(tooltip._hideTimer); tooltip._hideTimer = null; } } catch {} // Suppress while block editor is open try { const ov = this.shadowRoot.querySelector('.overlay'); if (this._editing || (ov && ov.classList.contains('open'))) return; } catch {} tooltip.textContent = txt; const box = bl.getBoundingClientRect(); const cont = modalProf?.getBoundingClientRect(); const left = box.left + box.width/2 - (cont?.left||0); const top = (box.top - (cont?.top||0)) - 8; tooltip.style.left = left + 'px'; tooltip.style.top = top + 'px'; tooltip.style.transform = 'translate(-50%,-100%)'; tooltip.style.display=''; }; const hideTip = ()=>{ if (!tooltip) return; const delay = (window.matchMedia && window.matchMedia('(pointer:coarse)').matches) ? 3000 : 120; try { if (tooltip._hideTimer) clearTimeout(tooltip._hideTimer); } catch {}; tooltip._hideTimer = setTimeout(()=>{ try { tooltip.style.display='none'; tooltip._hideTimer=null; } catch{} }, delay); }; bl.addEventListener('mouseenter', showTip); bl.addEventListener('mouseleave', hideTip); track.append(bl); } } catch { /* ignore */ } } _openProfileBlockEditor(blockId, atMin=null){ try { this._ensureEditModeOn(); const eid = this._profilesRoom || this._profilesEntity; const name = this._profilesSelected; if (!eid) return; const row = this._schedules[eid]; this._ensureProfilesStruct(row); // Use draft while editing if (!this._profilesDraft || this._profilesDraft.name !== name) { try { const roomsMap = {}; for (const e of (this._config?.entities||[])){ const src = name ? (this._schedules[e]?.profiles?.[name]?.blocks || []) : []; roomsMap[e] = JSON.parse(JSON.stringify(src)); } this._profilesDraft = { name, rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; this._profilesDirty = false; } catch { const roomsMap = {}; for (const e of (this._config?.entities||[])) roomsMap[e] = []; this._profilesDraft = { name, rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; this._profilesDirty = false; } } const arr = (this._profilesDraft.rooms||{})[eid] || []; const edTemp = this.shadowRoot.querySelector('.ed-temp'); const edFrom = this.shadowRoot.querySelector('.ed-from'); const edTo = this.shadowRoot.querySelector('.ed-to'); const overlay = this.shadowRoot.querySelector('.overlay'); const title = this.shadowRoot.querySelector('.modal h3'); const delBtn = this.shadowRoot.querySelector('.ed-delete'); const err = this.shadowRoot.querySelector('.ed-error'); const fromMer = this.shadowRoot.querySelector('.ed-from-mer'); const toMer = this.shadowRoot.querySelector('.ed-to-mer'); if (!blockId){ // New block default around now const now = Number.isFinite(atMin) ? atMin : this._getNowMin(); const start = this._clamp(Math.round(now), 0, 1380); const end = this._clamp(start+60, start+15, 1440); this._editing = { entity: eid, blockId: null, profileName: (name || '__new__') }; if (edTemp) { const base = Number(row.defaultTemp||this._config.default_temp||20); edTemp.value = String(this._toDisplayTemp(base)); edTemp.max = String(this._maxDisplay()); edTemp.min = String(this._minDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value=this._toTimeInput12h(start); if (edTo) edTo.value=this._toTimeInput12h(end); if (fromMer){ fromMer.style.display=''; fromMer.value=(start>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value=(end>=720 && end<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value=this._toTimeInput(start); if (edTo) edTo.value=this._toTimeInput(end); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.add_block'); if (delBtn) delBtn.style.display='none'; if (err){ err.style.display='none'; err.textContent=''; } } else { const b = arr.find(x=>x.id===blockId); if (!b) return; this._editing = { entity: eid, blockId, profileName: (name || '__new__') }; if (edTemp) { edTemp.value = String(this._toDisplayTemp(b.temp)); edTemp.max = String(this._maxDisplay()); edTemp.min = String(this._minDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value=this._toTimeInput12h(b.startMin); if (edTo) edTo.value=this._toTimeInput12h(b.endMin); if (fromMer){ fromMer.style.display=''; fromMer.value=(b.startMin>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value=(b.endMin>=720 && b.endMin<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value=this._toTimeInput(b.startMin); if (edTo) edTo.value=this._toTimeInput(b.endMin); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.edit_block'); if (delBtn) delBtn.style.display=''; if (err){ err.style.display='none'; err.textContent=''; } } // Hide profiles overlay while editing const pov = this.shadowRoot.querySelector('.overlay-profiles'); if (pov) pov.classList.remove('open'); this._profilesOverlayHiddenForEditor = true; try { const modalEl = this.shadowRoot.querySelector('.overlay .modal:not(.modal-week)'); if (modalEl) modalEl.classList.toggle('modal-12h', !!this._config?.time_12h); } catch {} try { requestAnimationFrame(()=> requestAnimationFrame(()=> { overlay.classList.add('open'); })); } catch { overlay.classList.add('open'); } } catch {} } async _profilesSaveDraft(){ try { const eid = this._profilesEntity; const sel = this._profilesSelected; const d = this._profilesDraft; if (!eid || !sel || !d || d.name !== sel) return; const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureProfilesStruct(row); // Persist current draft into storage structure const blocks = (d.rooms && d.rooms[eid]) ? d.rooms[eid] : []; row.profiles[sel] = { defaultTemp: row.defaultTemp, blocks: JSON.parse(JSON.stringify(blocks)) }; this._schedules[eid] = row; this._markPendingSave(); // Clear dirty flag and revert Add/Save button immediately this._profilesDirty = false; try { const btn = this.shadowRoot && this.shadowRoot.querySelector('.prof-add'); const sp = btn && btn.querySelector && btn.querySelector('span'); if (btn && sp) { sp.textContent = this._t('profiles.add'); btn.classList.remove('btn','primary'); btn.classList.add('add-entity-btn'); btn.onclick = null; } } catch {} // Refresh UI and reschedule transitions this._render(); this._renderProfilesModal(); this._scheduleNextApply(); } catch (e) {} } async _profilesSave(){ try { // Prevent double-trigger when user presses Enter to confirm the prompt if (this._profilesSaveLock) return; this._profilesSaveLock = true; const eid = this._profilesEntity; if (!eid) return; const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureProfilesStruct(row); const draft = this._profilesDraft || { name:null, rooms:{} }; // New profile: prompt for name if (this._profilesEditingNew || !this._profilesSelected) { const base = this._t('profiles.name_placeholder'); const txt = prompt(base||'Profile name', 'Profile'); const name = (txt||'').trim(); if (!name) return; if (row.profiles[name]) { alert(this._t('profiles.name_exists') || 'Name exists'); return; } // Save this profile for ALL rooms from the draft map for (const rid of (this._config?.entities||[])){ const rrow = this._schedules[rid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureProfilesStruct(rrow); const blocks = (draft.rooms?.[rid] || []); rrow.profiles = rrow.profiles || {}; rrow.profiles[name] = { defaultTemp: rrow.defaultTemp, blocks: JSON.parse(JSON.stringify(blocks)) }; this._schedules[rid] = rrow; } // Persist and reset timeline for a fresh new draft (ready for next) this._profilesDirty = false; this._markPendingSave(); try { this._profilesSelected = null; this._profilesEditingNew = true; this._profilesEditingExisting = false; const roomsMap = {}; for (const rid of (this._config?.entities||[])) roomsMap[rid] = []; this._profilesDraft = { name: null, rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; } catch {} // Move focus away from Save to avoid immediate re-click from Enter try { (this.shadowRoot && this.shadowRoot.activeElement && this.shadowRoot.activeElement.blur && this.shadowRoot.activeElement.blur()); } catch {} this._renderProfilesModal(); this._render(); this._scheduleNextApply(); return; } // Edit existing: save without renaming, then reset timeline for next const sel = this._profilesSelected; for (const rid of (this._config?.entities||[])){ const rrow = this._schedules[rid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureProfilesStruct(rrow); const blocks = (draft.rooms?.[rid] || []); rrow.profiles = rrow.profiles || {}; rrow.profiles[sel] = { defaultTemp: rrow.defaultTemp, blocks: JSON.parse(JSON.stringify(blocks)) }; this._schedules[rid] = rrow; } this._profilesDirty = false; this._markPendingSave(); try { (this.shadowRoot && this.shadowRoot.activeElement && this.shadowRoot.activeElement.blur && this.shadowRoot.activeElement.blur()); } catch {} // Clear selection and show empty preview ready for a new schedule try { this._profilesSelected = null; this._profilesEditingNew = true; this._profilesEditingExisting = false; const roomsMap = {}; for (const rid of (this._config?.entities||[])) roomsMap[rid] = []; this._profilesDraft = { name: null, rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; } catch {} this._renderProfilesModal(); this._render(); this._scheduleNextApply(); } catch (e) {} finally { setTimeout(()=>{ this._profilesSaveLock = false; }, 300); } } _openCopyWeekToRooms(){ try { // Build list of target rooms (exclude current) const list = this.shadowRoot.querySelector('.copyrooms-list'); const title = this.shadowRoot.querySelector('.copyrooms-title'); const desc = this.shadowRoot.querySelector('.copyrooms-desc'); const ov = this.shadowRoot.querySelector('.overlay-copyrooms'); if (title) title.textContent = this._t('week.copy_to_rooms.title'); if (desc) desc.textContent = this._t('week.copy_to_rooms.select'); if (list) { list.innerHTML = ''; const ents = (this._config?.entities || []); for (const eid of ents){ if (eid === this._weeklyEntity) continue; const lbl = (this._config?.labels && this._config.labels[eid]) ? this._config.labels[eid] : this._prettyName(eid); const row = document.createElement('label'); const cb = document.createElement('input'); cb.type='checkbox'; cb.value=eid; const span = document.createElement('span'); span.textContent = lbl; row.append(cb, span); list.append(row); } } const cancel = this.shadowRoot.querySelector('.copyrooms-cancel'); const apply = this.shadowRoot.querySelector('.copyrooms-apply'); if (cancel) cancel.onclick = ()=>{ ov?.classList.remove('open'); }; if (apply) apply.onclick = ()=> this._applyCopyWeekToRooms(); ov?.classList.add('open'); } catch(e){} } async _applyCopyWeekToRooms(){ try { const list = this.shadowRoot.querySelector('.copyrooms-list'); const ov = this.shadowRoot.querySelector('.overlay-copyrooms'); if (!this._weeklyDraft) { ov?.classList.remove('open'); return; } const checked = Array.from(list?.querySelectorAll('input[type=checkbox]:checked') || []).map(x=>x.value); // Queue the targets; actual copy occurs on Save in weekly editor this._weeklyCopyTargets = checked; ov?.classList.remove('open'); // Optionally re-render header/note to reflect queued action (no persistence yet) this._renderWeeklyModal(); } catch(e){} } _openWeeklyBlockEditor(blockId, atMin=null){ if (!this._weeklyDraft) return; this._ensureEditModeOn(); const day = this._effectiveDayKey(this._weeklyDayKey || 'mon'); const arr = this._weeklyDraft.days?.[day] || []; if (!blockId){ // open new const start = Number.isFinite(atMin) ? this._clamp(Math.round(atMin),0,1380) : this._clamp(Math.round(this._getNowMin()),0,1380); const end = this._clamp(start+60, start+15, 1440); const row = this._schedules[this._weeklyEntity] || { defaultTemp: this._config.default_temp }; const edTemp = this.shadowRoot.querySelector('.ed-temp'); const edFrom = this.shadowRoot.querySelector('.ed-from'); const edTo = this.shadowRoot.querySelector('.ed-to'); const overlay = this.shadowRoot.querySelector('.overlay'); const title = this.shadowRoot.querySelector('.modal h3'); const delBtn = this.shadowRoot.querySelector('.ed-delete'); const err = this.shadowRoot.querySelector('.ed-error'); const fromMer = this.shadowRoot.querySelector('.ed-from-mer'); const toMer = this.shadowRoot.querySelector('.ed-to-mer'); this._editing = { entity: this._weeklyEntity, blockId: null, weeklyDay: day }; // Hide any tooltips while editing try { const tips = this.shadowRoot.querySelectorAll('.wk-tooltip'); tips.forEach(t => { try { if (t._hideTimer) clearTimeout(t._hideTimer); } catch {} t.style.display='none'; }); } catch {} if (edTemp) { edTemp.value = String(this._toDisplayTemp(row.defaultTemp||20)); edTemp.max= String(this._maxDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value = this._toTimeInput12h(start); if (edTo) edTo.value = this._toTimeInput12h(end); if (fromMer){ fromMer.style.display=''; fromMer.value = (start>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value = (end>=720 && end<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value = this._toTimeInput(start); if (edTo) edTo.value = this._toTimeInput(end); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.add_block'); if (delBtn) delBtn.style.display='none'; if (err){ err.style.display='none'; err.textContent=''; } // Hide weekday overlay while editing to avoid double-popups const wov = this.shadowRoot.querySelector('.overlay-week'); if (wov) wov.classList.remove('open'); this._weeklyOverlayHiddenForEditor = true; try { const modalEl = this.shadowRoot.querySelector('.overlay .modal:not(.modal-week)'); if (modalEl) modalEl.classList.toggle('modal-12h', !!this._config?.time_12h); } catch {} try { requestAnimationFrame(()=> requestAnimationFrame(()=> { overlay.classList.add('open'); })); } catch { overlay.classList.add('open'); } } else { let b = arr.find(x=>x.id===blockId); // If not found in weekly.days, try searching in _weeklyModes if (!b && this._weeklyDraft?._weeklyModes) { for (const modeName in this._weeklyDraft._weeklyModes) { const modeBlocks = this._weeklyDraft._weeklyModes[modeName]?.days?.[day]; if (Array.isArray(modeBlocks)) { b = modeBlocks.find(x=>x.id===blockId); if (b) break; } } } if (!b) return; const edTemp = this.shadowRoot.querySelector('.ed-temp'); const edFrom = this.shadowRoot.querySelector('.ed-from'); const edTo = this.shadowRoot.querySelector('.ed-to'); const overlay = this.shadowRoot.querySelector('.overlay'); const title = this.shadowRoot.querySelector('.modal h3'); const delBtn = this.shadowRoot.querySelector('.ed-delete'); const err = this.shadowRoot.querySelector('.ed-error'); const fromMer = this.shadowRoot.querySelector('.ed-from-mer'); const toMer = this.shadowRoot.querySelector('.ed-to-mer'); this._editing = { entity: this._weeklyEntity, blockId, weeklyDay: day }; // Hide any tooltips while editing try { const tips = this.shadowRoot.querySelectorAll('.wk-tooltip'); tips.forEach(t => { try { if (t._hideTimer) clearTimeout(t._hideTimer); } catch {} t.style.display='none'; }); } catch {} if (edTemp) { edTemp.value = String(this._toDisplayTemp(b.temp)); edTemp.max= String(this._maxDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value = this._toTimeInput12h(b.startMin); if (edTo) edTo.value = this._toTimeInput12h(b.endMin); if (fromMer){ fromMer.style.display=''; fromMer.value = (b.startMin>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value = (b.endMin>=720 && b.endMin<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value = this._toTimeInput(b.startMin); if (edTo) edTo.value = this._toTimeInput(b.endMin); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.edit_block'); if (delBtn) delBtn.style.display=''; if (err){ err.style.display='none'; err.textContent=''; } const wov = this.shadowRoot.querySelector('.overlay-week'); if (wov) wov.classList.remove('open'); this._weeklyOverlayHiddenForEditor = true; try { const modalEl = this.shadowRoot.querySelector('.overlay .modal:not(.modal-week)'); if (modalEl) modalEl.classList.toggle('modal-12h', !!this._config?.time_12h); } catch {} overlay.classList.add('open'); } } // ---------- Holidays modal ---------- _openHolidayEditor(entity){ try { this._ensureEditModeOn(); const ents = this._config?.entities || []; this._holidayRoom = entity || ents[0] || null; // Build a rooms map draft from all rooms const roomsMap = {}; for (const eid of ents){ const r = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureHolidayStruct(r); roomsMap[eid] = Array.isArray(r.holiday?.blocks) ? JSON.parse(JSON.stringify(r.holiday.blocks)) : []; } this._holidayDraft = { rooms: roomsMap }; this._holidayOpen = true; const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-holiday'); // Mark card overlay state first, then render while hidden try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} this._renderHolidayModal(); // Open after a paint so timeline is fully laid out try { requestAnimationFrame(()=> requestAnimationFrame(()=> { if (ov) ov.classList.add('open'); })); } catch { if (ov) ov.classList.add('open'); } } catch {} } _closeHolidayEditor(){ try { this._holidayOpen = false; this._holidayEntity = null; this._holidayRoom = null; this._holidayDraft = null; } catch {} try { this.shadowRoot.querySelector('.overlay-holiday')?.classList.remove('open'); } catch {} try { this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} try { if (this._resumeSettingsAfterHoliday) { this._resumeSettingsAfterHoliday = false; this._resumeSettingsPopup?.(); } } catch {} } _renderHolidayModal(){ try { const title = this.shadowRoot && this.shadowRoot.querySelector('.holiday-title'); if (title) title.textContent = this._t('holidays.editor.title'); const roomEl = this.shadowRoot && this.shadowRoot.querySelector('.holiday-room'); if (roomEl) { const eid = this._holidayRoom; const custom = (this._config?.labels?.[eid]) || null; roomEl.textContent = custom || this._prettyName(eid||''); } const note = this.shadowRoot && this.shadowRoot.querySelector('.holiday-note'); if (note) note.textContent = this._t('week.editor.preview_note'); try { const sv = this.shadowRoot.querySelector('.holiday-save'); if (sv) sv.textContent = this._t('ui.save'); const cl = this.shadowRoot.querySelector('.holiday-close'); if (cl) cl.textContent = this._t('ui.cancel'); } catch {} const modalHost = this.shadowRoot && this.shadowRoot.querySelector('.modal-holiday'); const scale = modalHost?.querySelector('.week-scale-inner') || this.shadowRoot.querySelector('.week-scale-inner'); const track = modalHost?.querySelector('.week-track') || this.shadowRoot.querySelector('.week-track'); if (!track) return; // Build room tabs const tabs = modalHost?.querySelector('.holiday-roomtabs'); if (tabs) { tabs.innerHTML = ''; const ents = this._config?.entities || []; if (!this._holidayRoom || !ents.includes(this._holidayRoom)) this._holidayRoom = ents[0] || null; for (const eid of ents){ const btn=document.createElement('button'); btn.type='button'; btn.className='wk-tab'+(eid===this._holidayRoom?' active':''); let label = this._config?.labels?.[eid] || (this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1]||eid)); btn.textContent=label; btn.addEventListener('click', ()=>{ this._holidayRoom=eid; this._renderHolidayModal(); }); tabs.append(btn); } } // Draw scale every 2 hours if (scale) { scale.innerHTML = ''; for (let i=0;i<=24;i+=2){ const t = document.createElement('div'); t.style.position='absolute'; t.style.left=(i/24*100)+'%'; t.style.top='4px'; t.style.bottom='4px'; t.style.borderLeft='1px solid var(--divider-color)'; const lab = document.createElement('div'); lab.style.position='absolute'; lab.style.top='-2px'; lab.style.transform='translate(-50%,0)'; lab.style.left='0'; lab.style.fontSize='.75rem'; lab.style.color='var(--secondary-text-color)'; if (this._config?.time_12h){ const p=this._timeParts(i===24?0:i*60); lab.innerHTML = `
${p.main}
${p.mer||''}
`; } else { lab.textContent = (i===24 ? this._label(0) : this._label(i*60)); } t.append(lab); scale.append(t); } } // Toolbar with Add const addBtnInline = document.createElement('button'); addBtnInline.type='button'; addBtnInline.className='btn primary'; addBtnInline.innerHTML = ''+this._t('ui.add_block')+''; addBtnInline.addEventListener('click', ()=>{ this._openHolidayBlockEditor(null, this._getNowMin()); }); const copyBtn = document.createElement('button'); copyBtn.type='button'; copyBtn.className='btn ghost'; copyBtn.innerHTML = ''+ (this._t('holidays.copy_from') || this._t('ui.copy_plan')) +''; copyBtn.addEventListener('click', ()=> this._openHolidayCopy()); const toolbar = document.createElement('div'); toolbar.className='prof-toolbar'; toolbar.style.display='flex'; toolbar.style.justifyContent='flex-end'; toolbar.style.gap='8px'; toolbar.append(copyBtn, addBtnInline); try { const outerScale = (scale && scale.closest) ? scale.closest('.week-scale') : (modalHost?.querySelector('.week-scale') || null); const parent = outerScale?.parentElement || track.parentElement; if (parent) { Array.from(parent.querySelectorAll('.prof-toolbar')).forEach(el=>el.remove()); if (outerScale) parent.insertBefore(toolbar, outerScale); else parent.insertBefore(toolbar, track); } } catch {} // Draw blocks track.innerHTML = ''; const blocks = (this._holidayDraft && this._holidayDraft.rooms) ? (this._holidayDraft.rooms[this._holidayRoom] || []) : []; // Tooltip like weekly let tooltip = this.shadowRoot.querySelector('.wk-tooltip.holiday'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className='wk-tooltip holiday'; tooltip.style.display='none'; modalHost?.append(tooltip); } for (const b of blocks){ const pctStart=(b.startMin/1440)*100, pctW=((b.endMin-b.startMin)/1440)*100; const bl=document.createElement('div'); bl.className='block'; bl.style.left=pctStart+'%'; bl.style.width=pctW+'%'; try { const clr=this._colorFor(this._holidayRoom, b.temp); if (clr){ bl.style.background=clr; bl.style.borderColor=clr; const txt=this._contrastTextColor(clr); if (txt) bl.style.color=txt; } } catch {} UiHelper.genBlockPill( this, bl, `${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}` ); bl.addEventListener('dblclick', ()=> this._openHolidayBlockEditor(b.id)); const showTip = ()=>{ const txt=`${this._label(b.startMin)} - ${this._label(b.endMin)} • ${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}`; if (!tooltip) return; try { if (tooltip._hideTimer) clearTimeout(tooltip._hideTimer); tooltip._hideTimer=null; } catch {} const box=bl.getBoundingClientRect(); const cont=modalHost?.getBoundingClientRect(); const left=box.left+box.width/2-(cont?.left||0); const top=(box.top-(cont?.top||0))-8; tooltip.textContent=txt; tooltip.style.left=left+'px'; tooltip.style.top=top+'px'; tooltip.style.transform='translate(-50%,-100%)'; tooltip.style.display=''; }; const hideTip = ()=>{ if (!tooltip) return; const delay=(window.matchMedia&&window.matchMedia('(pointer:coarse)').matches)?3000:120; try{ if(tooltip._hideTimer) clearTimeout(tooltip._hideTimer);}catch{} tooltip._hideTimer=setTimeout(()=>{ try{ tooltip.style.display='none'; tooltip._hideTimer=null; }catch{} }, delay); }; bl.addEventListener('mouseenter', showTip); bl.addEventListener('mouseleave', hideTip); track.append(bl); } // Add on empty area track.addEventListener('dblclick', (e)=>{ try { if (e.target && (e.target.closest && e.target.closest('.block'))) return; const box=track.getBoundingClientRect(); const rel=(e.clientX-box.left)/box.width; const min=this._clamp(Math.round(rel*1440),0,1439); this._openHolidayBlockEditor(null, min); } catch { this._openHolidayBlockEditor(null); } }); } catch {} } // ---------- Presence (advanced away) modal ---------- _openPresenceEditor(entity){ try { this._ensureEditModeOn(); // Default to first room const ents = this._config?.entities || []; this._presenceRoom = entity || ents[0] || null; // Select current active combo if any; else first combo const combos = this._presenceAllCombos(); const activeKey = this._activePresenceComboKey(); this._presenceCombo = activeKey || (combos[0]?.key || null); // Build draft map for current combo from all rooms const roomsMap = {}; for (const eid of ents){ const r = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensurePresenceStruct(r); const arr = (this._presenceCombo && r.presence && r.presence[this._presenceCombo]) ? (r.presence[this._presenceCombo].blocks || []) : []; roomsMap[eid] = JSON.parse(JSON.stringify(arr)); } this._presenceDraft = { combo: this._presenceCombo, rooms: roomsMap }; this._presenceOpen = true; const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-presence'); try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} this._renderPresenceModal(); try { requestAnimationFrame(()=> requestAnimationFrame(()=> { if (ov) ov.classList.add('open'); })); } catch { if (ov) ov.classList.add('open'); } } catch {} } _closePresenceEditor(){ this._presenceOpen = false; this._presenceCombo = null; this._presenceDraft = null; this._presenceRoom = null; this.shadowRoot.querySelector('.overlay-presence')?.classList.remove('open'); try { this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} } _renderPresenceModal(){ try { const title = this.shadowRoot && this.shadowRoot.querySelector('.presence-title'); if (title) title.textContent = this._t('presence.editor.title'); const roomEl = this.shadowRoot && this.shadowRoot.querySelector('.presence-room'); if (roomEl) { const eid = this._presenceRoom; const custom = (this._config?.labels?.[eid]) || null; roomEl.textContent = custom || this._prettyName(eid||''); } // Hide toggle in modal (selection happens in editor) try { const advRow = this.shadowRoot && this.shadowRoot.querySelector('.presence-adv-row'); if (advRow) advRow.style.display = 'none'; } catch {} const list = this.shadowRoot && this.shadowRoot.querySelector('.presence-combos'); const wrapActive = this.shadowRoot && this.shadowRoot.querySelector('.presence-combo-active'); const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-presence'); const modalHost = this.shadowRoot && this.shadowRoot.querySelector('.overlay-presence .modal'); const scale = modalHost?.querySelector('.week-scale-inner'); const track = modalHost?.querySelector('.week-track'); const tabs = modalHost?.querySelector('.presence-roomtabs'); const btnSave = modalHost?.querySelector('.presence-save'); const btnClose = modalHost?.querySelector('.presence-close'); const seasonRow = modalHost?.querySelector('.presence-season-row'); const seasonLab = modalHost?.querySelector('.presence-season-label'); const seasonSel = modalHost?.querySelector('.presence-season'); const buildPresenceDraft = (comboKey)=>{ try { const roomsMap = {}; for (const eid of (this._config?.entities||[])){ const r = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensurePresenceStruct(r); const arr = (comboKey && r.presence && r.presence[comboKey]) ? (r.presence[comboKey].blocks || []) : []; roomsMap[eid] = JSON.parse(JSON.stringify(arr)); } this._presenceDraft = { combo: comboKey, rooms: roomsMap }; } catch {} }; // Build readonly vertical list of enabled combos (click to select) if (list) { list.innerHTML=''; const all = this._presenceAllCombos(); const conf = (this._config?.away?.combos) || {}; const enabled = all.filter(it => !!conf[it.key]?.enabled); const grid = document.createElement('div'); grid.className = 'presence-rows'; grid.style.border='1px solid var(--divider-color)'; grid.style.borderRadius='8px'; grid.style.padding='6px'; grid.style.maxHeight='180px'; grid.style.overflow='auto'; const titleRow = document.createElement('div'); titleRow.style.fontSize='.9rem'; titleRow.style.color='var(--secondary-text-color)'; titleRow.textContent = this._t('presence.combos'); list.append(titleRow); for (const it of enabled){ const btn = document.createElement('button'); btn.type='button'; btn.className='presence-row' + (it.key===this._presenceCombo?' active':''); const full = it.label; btn.textContent = full; btn.title = full; btn.addEventListener('click', ()=>{ this._presenceCombo = it.key; buildPresenceDraft(it.key); this._renderPresenceModal(); }); grid.append(btn); } list.append(grid); } // Seasonal selector (Summer/Winter) try { if (seasonRow && seasonSel && seasonLab) { const on = !!this._config?.seasonal_enabled; seasonRow.style.display = on ? '' : 'none'; if (on) { seasonLab.textContent = this._t('season.mode.title') || 'Season'; const optW = seasonSel.querySelector('.opt-season-winter'); const optS = seasonSel.querySelector('.opt-season-summer'); if (optW) optW.textContent = this._t('season.mode.winter') || 'Winter'; if (optS) optS.textContent = this._t('season.mode.summer') || 'Summer'; seasonSel.value = this._seasonKey(); seasonSel.onchange = ()=>{ try { const next = (seasonSel.value === 'summer') ? 'summer' : 'winter'; this._setSeasonMode(next); buildPresenceDraft(this._presenceCombo); this._renderPresenceModal(); } catch {} }; } } } catch {} // Active combo label if (wrapActive) { const combos = this._presenceAllCombos(); const conf=(this._config?.away?.combos)||{}; const cur = combos.find(x=>x.key===this._presenceCombo && !!conf[x.key]?.enabled); if (cur) { const nameOf = (eid)=>{ try { return this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1]||eid); } catch { return eid; } }; const homeNames = (cur.home||[]).map(nameOf).join(', ') || '-'; const awayNames = (cur.away||[]).map(nameOf).join(', ') || '-'; const lh = this._t('presence.who_home') || 'Who is home'; const la = this._t('presence.who_away') || 'Who is not home'; wrapActive.innerHTML = `
${lh}: ${homeNames}
${la}: ${awayNames}
`; } else { wrapActive.textContent = ''; wrapActive.title = ''; } } // (combo tabs removed; selection happens via the list above) // Room tabs if (tabs) { tabs.innerHTML=''; const ents = this._config?.entities || []; if (!this._presenceRoom || !ents.includes(this._presenceRoom)) this._presenceRoom = ents[0] || null; for (const eid of ents){ const btn=document.createElement('button'); btn.type='button'; btn.className='wk-tab'+(eid===this._presenceRoom?' active':''); let label = this._config?.labels?.[eid] || (this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1]||eid)); btn.textContent=label; btn.addEventListener('click', ()=>{ this._presenceRoom=eid; this._renderPresenceModal(); }); tabs.append(btn); } try { tabs.style.display = (ents.length>1) ? 'flex' : 'none'; } catch {} } // Scale if (scale) { scale.innerHTML=''; for (let i=0;i<=24;i+=2){ const t=document.createElement('div'); t.style.position='absolute'; t.style.left=(i/24*100)+'%'; t.style.top='4px'; t.style.bottom='4px'; t.style.borderLeft='1px solid var(--divider-color)'; const lab=document.createElement('div'); lab.style.position='absolute'; lab.style.top='-2px'; lab.style.transform='translate(-50%,0)'; lab.style.left='0'; lab.style.fontSize='.75rem'; lab.style.color='var(--secondary-text-color)'; if (this._config?.time_12h){ const p=this._timeParts(i===24?0:i*60); lab.innerHTML=`
${p.main}
${p.mer||''}
`; } else { lab.textContent=(i===24? this._label(0) : this._label(i*60)); } t.append(lab); scale.append(t);} } // Toolbar Add if (modalHost) { const addBtnInline = document.createElement('button'); addBtnInline.type='button'; addBtnInline.className='btn primary'; addBtnInline.innerHTML=''+this._t('ui.add_block')+''; addBtnInline.addEventListener('click', ()=>{ this._openPresenceBlockEditor(null, this._getNowMin()); }); const toolbar = document.createElement('div'); toolbar.className='prof-toolbar'; toolbar.style.display='flex'; toolbar.style.justifyContent='flex-end'; toolbar.style.gap='8px'; toolbar.append(addBtnInline); const outerScale = (scale && scale.closest) ? scale.closest('.week-scale') : (modalHost.querySelector('.week-scale') || null); const parent = outerScale?.parentElement || track?.parentElement; if (parent) { Array.from(parent.querySelectorAll('.prof-toolbar')).forEach(el=>el.remove()); if (outerScale) parent.insertBefore(toolbar, outerScale); else if (track) parent.insertBefore(toolbar, track); } } // Track blocks for current room/combo if (track) { track.innerHTML=''; const combo = this._presenceCombo; const room = this._presenceRoom; const arr = (this._presenceDraft && this._presenceDraft.rooms) ? (this._presenceDraft.rooms[room] || []) : []; // Tooltip reuse let tooltip = this.shadowRoot.querySelector('.wk-tooltip.presence'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className='wk-tooltip presence'; tooltip.style.display='none'; (modalHost||document.body).append(tooltip); } for (const b of arr){ const pctStart=(b.startMin/1440)*100, pctW=((b.endMin-b.startMin)/1440)*100; const bl=document.createElement('div'); bl.className='block'; bl.style.left=pctStart+'%'; bl.style.width=pctW+'%'; try { const clr=this._colorFor(room, b.temp); if (clr){ bl.style.background=clr; bl.style.borderColor=clr; const txt=this._contrastTextColor(clr); if (txt) bl.style.color=txt; } } catch {} UiHelper.genBlockPill( this, bl, `${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}` ); bl.addEventListener('dblclick', ()=> this._openPresenceBlockEditor(b.id)); const showTip = ()=>{ const txt=`${this._label(b.startMin)} - ${this._label(b.endMin)} • ${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}`; if (!tooltip) return; try { if (tooltip._hideTimer) clearTimeout(tooltip._hideTimer); tooltip._hideTimer=null; } catch {} const box=bl.getBoundingClientRect(); const cont=modalHost?.getBoundingClientRect(); const left=box.left+box.width/2-(cont?.left||0); const top=(box.top-(cont?.top||0))-8; tooltip.textContent=txt; tooltip.style.left=left+'px'; tooltip.style.top=top+'px'; tooltip.style.transform='translate(-50%,-100%)'; tooltip.style.display=''; }; const hideTip = ()=>{ if (!tooltip) return; const delay=(window.matchMedia&&window.matchMedia('(pointer:coarse)').matches)?3000:120; try{ if(tooltip._hideTimer) clearTimeout(tooltip._hideTimer); }catch{} tooltip._hideTimer=setTimeout( ()=>{ try{ tooltip.style.display='none'; tooltip._hideTimer=null; } catch{} }, delay ); }; bl.addEventListener('mouseenter', showTip); bl.addEventListener('mouseleave', hideTip); track.append(bl); } // dblclick to add track.addEventListener('dblclick', (e)=>{ try { if (e.target && (e.target.closest && e.target.closest('.block'))) return; const box=track.getBoundingClientRect(); const rel=(e.clientX-box.left)/box.width; const min=this._clamp(Math.round(rel*1440),0,1439); this._openPresenceBlockEditor(null, min); } catch { this._openPresenceBlockEditor(null); } }); } // Save/Close if (btnSave) btnSave.onclick = ()=> this._presenceSave(); if (btnClose) btnClose.onclick = ()=> this._closePresenceEditor(); } catch {} } _openPresenceBlockEditor(blockId, atMin=null){ try { this._ensureEditModeOn(); const eid = this._presenceRoom; if (!eid) return; if (!this._presenceDraft || !this._presenceDraft.rooms) { const roomsMap={}; for (const rid of (this._config?.entities||[])){ const r=this._schedules[rid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensurePresenceStruct(r); const arr=(this._presenceCombo && r.presence && r.presence[this._presenceCombo]) ? (r.presence[this._presenceCombo].blocks || []) : []; roomsMap[rid]=JSON.parse(JSON.stringify(arr)); } this._presenceDraft = { combo: this._presenceCombo, rooms: roomsMap }; } const arr = (this._presenceDraft.rooms||{})[eid] || []; const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; const edTemp = this.shadowRoot.querySelector('.ed-temp'); const edFrom = this.shadowRoot.querySelector('.ed-from'); const edTo = this.shadowRoot.querySelector('.ed-to'); const overlay = this.shadowRoot.querySelector('.overlay'); const title = this.shadowRoot.querySelector('.modal h3'); const delBtn = this.shadowRoot.querySelector('.ed-delete'); const err = this.shadowRoot.querySelector('.ed-error'); const fromMer = this.shadowRoot.querySelector('.ed-from-mer'); const toMer = this.shadowRoot.querySelector('.ed-to-mer'); if (!blockId){ const now = Number.isFinite(atMin) ? atMin : this._getNowMin(); const start = this._clamp(Math.round(now), 0, 1380); const end = this._clamp(start+60, start+15, 1440); this._editing = { entity: eid, blockId: null, presence: true }; if (edTemp) { const base = Number(row.defaultTemp||this._config.default_temp||20); edTemp.value = String(this._toDisplayTemp(base)); edTemp.max = String(this._maxDisplay()); edTemp.min = String(this._minDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value=this._toTimeInput12h(start); if (edTo) edTo.value=this._toTimeInput12h(end); if (fromMer){ fromMer.style.display=''; fromMer.value=(start>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value=(end>=720 && end<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value=this._toTimeInput(start); if (edTo) edTo.value=this._toTimeInput(end); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.add_block'); if (delBtn) delBtn.style.display='none'; if (err){ err.style.display='none'; err.textContent=''; } } else { const b = arr.find(x=>x.id===blockId); if (!b) return; this._editing = { entity: eid, blockId, presence: true }; if (edTemp) { edTemp.value = String(this._toDisplayTemp(b.temp)); edTemp.max = String(this._maxDisplay()); edTemp.min = String(this._minDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value=this._toTimeInput12h(b.startMin); if (edTo) edTo.value=this._toTimeInput12h(b.endMin); if (fromMer){ fromMer.style.display=''; fromMer.value=(b.startMin>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value=(b.endMin>=720 && b.endMin<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value=this._toTimeInput(b.startMin); if (edTo) edTo.value=this._toTimeInput(b.endMin); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.edit_block'); if (delBtn) delBtn.style.display=''; if (err){ err.style.display='none'; err.textContent=''; } } // Hide presence overlay while editing const pov = this.shadowRoot.querySelector('.overlay-presence'); if (pov) pov.classList.remove('open'); this._profilesOverlayHiddenForEditor = false; this._holidayOverlayHiddenForEditor = false; // independent try { const modalEl = this.shadowRoot.querySelector('.overlay .modal:not(.modal-week)'); if (modalEl) modalEl.classList.toggle('modal-12h', !!this._config?.time_12h); } catch {} try { requestAnimationFrame(()=> requestAnimationFrame(()=> { overlay.classList.add('open'); })); } catch { overlay.classList.add('open'); } } catch {} } async _presenceSave(){ try { if (!this._presenceDraft || !this._presenceDraft.rooms || !this._presenceCombo) { this._closePresenceEditor(); return; } // Persist for each room under row.presence[combo].blocks for (const eid of (this._config?.entities||[])){ const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensurePresenceStruct(row); const arr = JSON.parse(JSON.stringify(this._presenceDraft.rooms?.[eid] || [])); row.presence = row.presence || {}; row.presence[this._presenceCombo] = { blocks: arr }; this._schedules[eid] = row; } const before = this._desiredNowSnapshot(); this._markPendingSave(); this._closePresenceEditor(); this._render(); if (this._config.apply_on_edit) await this._applyIfDesiredChanged(before); this._scheduleNextApply(); } catch {} } _openHolidayBlockEditor(blockId, atMin=null){ try { this._ensureEditModeOn(); const eid = this._holidayRoom; if (!eid) return; const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureHolidayStruct(row); if (!this._holidayDraft || !this._holidayDraft.rooms) { const roomsMap={}; for (const rid of (this._config?.entities||[])){ const src=this._schedules[rid]?.holiday?.blocks || []; roomsMap[rid]=JSON.parse(JSON.stringify(src)); } this._holidayDraft = { rooms: roomsMap }; } const arr = (this._holidayDraft.rooms||{})[eid] || []; const edTemp = this.shadowRoot.querySelector('.ed-temp'); const edFrom = this.shadowRoot.querySelector('.ed-from'); const edTo = this.shadowRoot.querySelector('.ed-to'); const overlay = this.shadowRoot.querySelector('.overlay'); const title = this.shadowRoot.querySelector('.modal h3'); const delBtn = this.shadowRoot.querySelector('.ed-delete'); const err = this.shadowRoot.querySelector('.ed-error'); const fromMer = this.shadowRoot.querySelector('.ed-from-mer'); const toMer = this.shadowRoot.querySelector('.ed-to-mer'); if (!blockId){ const now = Number.isFinite(atMin) ? atMin : this._getNowMin(); const start = this._clamp(Math.round(now), 0, 1380); const end = this._clamp(start+60, start+15, 1440); this._editing = { entity: eid, blockId: null, holiday: true }; if (edTemp) { const base = Number(row.defaultTemp||this._config.default_temp||20); edTemp.value = String(this._toDisplayTemp(base)); edTemp.max = String(this._maxDisplay()); edTemp.min = String(this._minDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value=this._toTimeInput12h(start); if (edTo) edTo.value=this._toTimeInput12h(end); if (fromMer){ fromMer.style.display=''; fromMer.value=(start>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value=(end>=720 && end<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value=this._toTimeInput(start); if (edTo) edTo.value=this._toTimeInput(end); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.add_block'); if (delBtn) delBtn.style.display='none'; if (err){ err.style.display='none'; err.textContent=''; } } else { const b = arr.find(x=>x.id===blockId); if (!b) return; this._editing = { entity: eid, blockId, holiday: true }; if (edTemp) { edTemp.value = String(this._toDisplayTemp(b.temp)); edTemp.max = String(this._maxDisplay()); edTemp.min = String(this._minDisplay()); } if (this._config?.time_12h) { if (edFrom) edFrom.value=this._toTimeInput12h(b.startMin); if (edTo) edTo.value=this._toTimeInput12h(b.endMin); if (fromMer){ fromMer.style.display=''; fromMer.value=(b.startMin>=720)?'PM':'AM'; } if (toMer){ toMer.style.display=''; toMer.value=(b.endMin>=720 && b.endMin<1440)?'PM':'AM'; } } else { if (edFrom) edFrom.value=this._toTimeInput(b.startMin); if (edTo) edTo.value=this._toTimeInput(b.endMin); if (fromMer) fromMer.style.display='none'; if (toMer) toMer.style.display='none'; } if (title) title.textContent = this._t('ui.edit_block'); if (delBtn) delBtn.style.display=''; if (err){ err.style.display='none'; err.textContent=''; } } // Hide holiday overlay while editing to avoid double-popups const hov = this.shadowRoot.querySelector('.overlay-holiday'); if (hov) hov.classList.remove('open'); this._holidayOverlayHiddenForEditor = true; try { const modalEl = this.shadowRoot.querySelector('.overlay .modal:not(.modal-week)'); if (modalEl) modalEl.classList.toggle('modal-12h', !!this._config?.time_12h); } catch {} try { requestAnimationFrame(()=> requestAnimationFrame(()=> { overlay.classList.add('open'); })); } catch { overlay.classList.add('open'); } } catch {} } async _holidaySave(){ try { if (!this._holidayDraft || !this._holidayDraft.rooms) { this._closeHolidayEditor(); return; } const rooms = this._holidayDraft.rooms || {}; for (const eid of (this._config?.entities||[])){ const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureHolidayStruct(row); row.holiday = { blocks: JSON.parse(JSON.stringify(rooms[eid] || [])) }; this._schedules[eid] = row; } const before = this._desiredNowSnapshot(); this._markPendingSave(); this._closeHolidayEditor(); this._render(); if (this._config.apply_on_edit) await this._applyIfDesiredChanged(before); this._scheduleNextApply(); } catch {} } // ---- Holiday: Copy from other schedule types ---- _holidayCopySourcesForRoom(eid){ const out = []; try { const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureWeeklyStruct(row); this._ensureProfilesStruct(row); this._ensurePresenceStruct(row); this._ensureHolidayStruct(row); // Main plan out.push({ group: 'main', label: this._t('holidays.copy.group.main'), items: [{ value: 'main::main', label: this._t('holidays.copy.group.main') }] }); // Weekdays – list all 7 days, regardless of mode try { const days = this._weekdayNames(); const items = days.map(k=>({ value: `weekday::${k}`, label: (this._t('week.days_full.'+k) || this._t('week.days.'+k) || k) })); out.push({ group: 'weekdays', label: this._t('holidays.copy.group.weekdays'), items }); } catch {} // Profiles try { const names = Object.keys(row.profiles || {}); if (names.length) { const items = []; // Put active profile first if any const ap = row.activeProfile && names.includes(row.activeProfile) ? row.activeProfile : null; if (ap) items.push({ value: `profile::${ap}`, label: `${(this._t('profiles.button')||'Manual schedule')}: ${ap}` }); for (const n of names){ if (n!==ap) items.push({ value: `profile::${n}`, label: n }); } out.push({ group: 'profiles', label: this._t('holidays.copy.group.profiles'), items }); } } catch {} // Presence combinations (show enabled + any with saved blocks) try { const conf = (this._config?.away?.combos) || {}; const all = this._presenceAllCombos(); const items = []; for (const it of all){ const meta = conf[it.key] || {}; const hasBlocks = !!(row.presence && row.presence[it.key] && Array.isArray(row.presence[it.key].blocks) && row.presence[it.key].blocks.length); if (meta.enabled || hasBlocks) { const lab = meta.enabled ? it.label : `${it.label} (off)`; items.push({ value:`presence::${it.key}`, label: lab }); } } if (items.length) out.push({ group: 'presence', label: this._t('holidays.copy.group.presence'), items }); } catch {} } catch {} return out; } _holidayCopyBlocks(eid, value){ try { const [type, keyRaw] = String(value||'').split('::'); const row = this._schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; this._ensureWeeklyStruct(row); this._ensureProfilesStruct(row); this._ensurePresenceStruct(row); if (type === 'main') return JSON.parse(JSON.stringify(row.blocks||[])); if (type === 'weekday') { const k = keyRaw || 'mon'; const src = row.weekly?.days?.[k] || []; return JSON.parse(JSON.stringify(src)); } if (type === 'profile') { const src = row.profiles?.[keyRaw]?.blocks || []; return JSON.parse(JSON.stringify(src)); } if (type === 'presence') { const src = row.presence?.[keyRaw]?.blocks || null; if (Array.isArray(src)) return JSON.parse(JSON.stringify(src)); // Fallback: all‑day away const awayC = Number(this._config?.away?.target_c ?? 17); const fallback = Number.isFinite(awayC) ? awayC : (row.defaultTemp || this._config.default_temp || 20); return [{ id: '__presence_away__', startMin: 0, endMin: 1440, temp: fallback }]; } } catch {} return []; } _openHolidayCopy(){ try { this._ensureEditModeOn(); const eid = this._holidayRoom; if (!eid) return; const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-hcopy'); try { this.shadowRoot.querySelector('ha-card')?.classList.add('overlay-open'); } catch {} this._holidayCopyOpen = true; this._holidayCopySel = 'main::main'; this._renderHolidayCopy(); try { requestAnimationFrame(()=> requestAnimationFrame(()=> { ov?.classList.add('open'); })); } catch { ov?.classList.add('open'); } } catch {} } _closeHolidayCopy(){ try { this._holidayCopyOpen = false; this._holidayCopySel = null; this.shadowRoot.querySelector('.overlay-hcopy')?.classList.remove('open'); this.shadowRoot.querySelector('ha-card')?.classList.remove('overlay-open'); } catch {} } _renderHolidayCopy(){ try { const modal = this.shadowRoot && this.shadowRoot.querySelector('.overlay-hcopy .modal-hcopy'); if (!modal) return; const eid = this._holidayRoom; const rowName = this._config?.labels?.[eid] || (this._hass?.states?.[eid]?.attributes?.friendly_name || (eid?.split?.('.')[1]||eid)); const title = modal.querySelector('.hcopy-title'); if (title) title.textContent = this._t('holidays.copy.title'); const roomEl = modal.querySelector('.hcopy-room'); if (roomEl) roomEl.textContent = rowName || ''; const selLab = modal.querySelector('.hcopy-label'); if (selLab) selLab.textContent = this._t('holidays.copy.select'); const applyBtn = modal.querySelector('.hcopy-apply'); if (applyBtn) { applyBtn.textContent = this._t('holidays.copy.apply'); applyBtn.onclick = ()=>{ try { const cur = (this._holidayDraft?.rooms?.[eid] || []); if (cur.length) { const ok = confirm(this._t('holidays.copy.replace_confirm')); if (!ok) return; } } catch {} const blocks = this._holidayCopyBlocks(eid, this._holidayCopySel); if (!this._holidayDraft || !this._holidayDraft.rooms) { const roomsMap = {}; for (const r of (this._config?.entities||[])) { const arr = (this._schedules[r]?.holiday?.blocks || []); roomsMap[r] = JSON.parse(JSON.stringify(arr)); } this._holidayDraft = { rooms: roomsMap }; } this._holidayDraft.rooms[eid] = blocks.map(b=>({ ...b, id: (b.id && String(b.id).startsWith('__')? b.id : Math.random().toString(36).slice(2)) })); this._renderHolidayModal(); this._closeHolidayCopy(); }; } const closeBtn = modal.querySelector('.hcopy-close'); if (closeBtn) { closeBtn.textContent = this._t('ui.cancel'); closeBtn.onclick = ()=> this._closeHolidayCopy(); } // Build select options const select = modal.querySelector('.hcopy-select'); if (select) { select.innerHTML = ''; const groups = this._holidayCopySourcesForRoom(eid); for (const g of groups){ const og = document.createElement('optgroup'); og.label = g.label || g.group; for (const it of (g.items||[])){ const opt = document.createElement('option'); opt.value = it.value; opt.textContent = it.label; if (this._holidayCopySel===it.value) opt.selected = true; og.append(opt); } select.append(og); } select.onchange = ()=>{ this._holidayCopySel = String(select.value||''); this._renderHolidayCopy(); }; } // Draw scale and preview const scale = modal.querySelector('.week-scale-inner'); const track = modal.querySelector('.week-track'); if (scale) { scale.innerHTML = ''; for (let i=0;i<=24;i+=2){ const t = document.createElement('div'); t.style.position='absolute'; t.style.left=(i/24*100)+'%'; t.style.top='4px'; t.style.bottom='4px'; t.style.borderLeft='1px solid var(--divider-color)'; const lab = document.createElement('div'); lab.style.position='absolute'; lab.style.top='-2px'; lab.style.transform='translate(-50%,0)'; lab.style.left='0'; lab.style.fontSize='.75rem'; lab.style.color='var(--secondary-text-color)'; if (this._config?.time_12h){ const p=this._timeParts(i===24?0:i*60); lab.innerHTML = `
${p.main}
${p.mer||''}
`; } else { lab.textContent = (i===24 ? this._label(0) : this._label(i*60)); } t.append(lab); scale.append(t); } } if (track) { track.innerHTML = ''; const blocks = this._holidayCopyBlocks(eid, this._holidayCopySel); for (const b of blocks){ const pctStart=(b.startMin/1440)*100, pctW=((b.endMin-b.startMin)/1440)*100; const bl=document.createElement('div'); bl.className='block'; bl.style.left=pctStart+'%'; bl.style.width=pctW+'%'; try { const clr=this._colorFor(eid, b.temp); if (clr){ bl.style.background=clr; bl.style.borderColor=clr; const txt=this._contrastTextColor(clr); if (txt) bl.style.color=txt; } } catch {} UiHelper.genBlockPill( this, bl, `${this._toDisplayTemp(b.temp)} ${this._unitSymbol()}` ); track.append(bl); } } } catch {} } async _saveEditor(){ const errElGlobal = this.shadowRoot?.querySelector('.ed-error'); try { if (!this._editing) return; const { entity, blockId, weeklyDay, profileName, holiday } = this._editing; const row = this._schedules[entity]; if (!row) return; let b = null; let sourceBlocks = row.blocks; if (profileName) { this._ensureProfilesStruct(row); // Use the current draft for overlap/checks instead of stored profile const curRoom = this._profilesRoom || this._profilesEntity || entity; const draftArr = (this._profilesDraft?.rooms?.[curRoom]) || []; sourceBlocks = draftArr; b = blockId ? draftArr.find(x=>x.id===blockId) : null; } else if (weeklyDay && this._weeklyDraft) { sourceBlocks = (this._weeklyDraft.days?.[weeklyDay] || []); b = blockId ? sourceBlocks.find(x=>x.id===blockId) : null; if (!b && this._weeklyDraft?._weeklyModes) { for (const modeName in this._weeklyDraft._weeklyModes) { const modeBlocks = this._weeklyDraft._weeklyModes[modeName]?.days?.[weeklyDay]; if (Array.isArray(modeBlocks)) { b = modeBlocks.find(x=>x.id===blockId); if (b) { sourceBlocks = modeBlocks; break; } } } } } else if (holiday && this._holidayDraft?.rooms) { sourceBlocks = (this._holidayDraft.rooms?.[entity] || []); b = blockId ? sourceBlocks.find(x=>x.id===blockId) : null; } else { if (blockId) b = row.blocks.find(x => x.id === blockId); } const edTemp = this.shadowRoot.querySelector(".ed-temp"); const edFrom = this.shadowRoot.querySelector(".ed-from"); const edTo = this.shadowRoot.querySelector(".ed-to"); const fromMerSel = this.shadowRoot.querySelector('.ed-from-mer'); const toMerSel = this.shadowRoot.querySelector('.ed-to-mer'); const rawFrom = String(edFrom.value || ""); const rawTo = String(edTo.value || ""); let start = this._fromTimeInput(rawFrom); let end = this._fromTimeInput(rawTo); // If 12h mode, adjust using AM/PM selects if (this._config?.time_12h) { const merF = (fromMerSel?.value || 'AM').toUpperCase(); const merT = (toMerSel?.value || 'AM').toUpperCase(); let sh = Math.floor(start/60), sm = start%60; let eh = Math.floor(end/60), em = end%60; if (sh <= 12) { if (merF==='AM' && sh===12) sh = 0; if (merF==='PM' && sh<12) sh += 12; } if (eh <= 12) { if (merT==='AM' && eh===12) eh = 0; if (merT==='PM' && eh<12) eh += 12; } start = sh*60 + sm; end = eh*60 + em; } // Treat end time 00:00 as end of day (24:00 -> 1440 minutes) if (rawTo === '00:00') end = 1440; let tempRaw = String(edTemp.value || "").replace(",", "."); let temp = parseFloat(tempRaw); if (isNaN(start)) start = 0; if (isNaN(end)) end = 60; if (isNaN(temp)) temp = row.defaultTemp || 20; const maxLimit = this._maxDisplay(); const minLimit = this._minDisplay(); if (Number.isFinite(maxLimit)) temp = Math.min(temp, maxLimit); if (Number.isFinite(minLimit)) temp = Math.max(temp, minLimit); start = this._clamp(Math.floor(start), 0, 1439); end = this._clamp(Math.ceil(end), 1, 1440); // Helper to check overlap in an array of blocks (ignore current 'b' if provided) const hasOverlap = (arr, s, e, ignoreId=null) => { const othersArr = (arr||[]).filter(x=>!ignoreId || x.id !== ignoreId); return othersArr.find(o => !(e <= o.startMin || s >= o.endMin)); }; // If interval crosses midnight (end <= start), split into two blocks const crossesMidnight = end <= start; // Early overlap check only for base/weekly editors; profiles and holiday are handled below using the draft state if (!crossesMidnight && !profileName && !holiday) { const overlap = hasOverlap(sourceBlocks, start, end, b?.id); if (overlap) { const overlapStart = Math.max(start, overlap.startMin); const overlapEnd = Math.min(end, overlap.endMin); const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const msg = this._t('ui.overlap_msg').replace('{start}', this._label(overlapStart)).replace('{end}', this._label(overlapEnd)); const fixStartLab = this._t('ui.fix_start_to').replace('{time}', this._label(overlap.endMin)); const fixEndLab = this._t('ui.fix_end_to').replace('{time}', this._label(overlap.startMin)); // Decide which fixes are relevant const startInside = start >= overlap.startMin && start < overlap.endMin; const endInside = end > overlap.startMin && end <= overlap.endMin; const showStart = startInside || (!startInside && endInside && end > overlap.endMin); // overlap at right edge const showEnd = endInside || (!endInside && startInside && start < overlap.startMin); // overlap at left edge let btns = ''; if (showStart) btns += ``; if (showEnd) btns += ``; // No extra cancel button here; bottom Cancel handles it errEl.innerHTML = `
${msg}
${btns}
`; errEl.style.display = 'block'; const applyFix = (kind)=>{ // Adjust inputs then re-run save try { const edFrom = this.shadowRoot.querySelector('.ed-from'); const edTo = this.shadowRoot.querySelector('.ed-to'); const fromMerSel = this.shadowRoot.querySelector('.ed-from-mer'); const toMerSel = this.shadowRoot.querySelector('.ed-to-mer'); if (kind==='start') { const ns = overlap.endMin; if (this._config?.time_12h) { if (edFrom) edFrom.value = this._toTimeInput12h(ns); if (fromMerSel) fromMerSel.value = (ns>=720 && ns<1440)?'PM':'AM'; } else { if (edFrom) edFrom.value = this._toTimeInput(ns); } } else if (kind==='end') { const ne = overlap.startMin; if (this._config?.time_12h) { if (edTo) edTo.value = this._toTimeInput12h(ne); if (toMerSel) toMerSel.value = (ne>=720 && ne<1440)?'PM':'AM'; } else { if (edTo) edTo.value = this._toTimeInput(ne); } } } catch {} try { errEl.style.display='none'; errEl.textContent=''; } catch {} // Defer to allow DOM to update setTimeout(()=>{ this._saveEditor(); }, 0); }; errEl.querySelector('.ed-fix-start')?.addEventListener('click', ()=>applyFix('start')); errEl.querySelector('.ed-fix-end')?.addEventListener('click', ()=>applyFix('end')); // No extra cancel handler } return; } } temp = this._fromDisplayTemp(temp); const before = this._desiredNowSnapshot(); if (profileName) { // Save into a draft for the selected profile; persist only when user presses Save const hasOverlap = (arr, s, e, ignoreId=null) => { const othersArr = (arr||[]).filter(x=>!ignoreId || x.id !== ignoreId); return othersArr.find(o => !(e <= o.startMin || s >= o.endMin)); }; const crossesMidnight = end <= start; // Ensure draft exists for current room; don't re-init when creating a new (unnamed) profile const isNewProf = (profileName === '__new__'); const curRoom = this._profilesRoom || this._profilesEntity || Object.keys(this._schedules)[0]; if (!this._profilesDraft || (!isNewProf && this._profilesDraft.name !== profileName)) { try { const roomsMap = {}; for (const e of (this._config?.entities||[])){ const src = !isNewProf ? (this._schedules[e]?.profiles?.[profileName]?.blocks || []) : []; roomsMap[e] = JSON.parse(JSON.stringify(src)); } this._profilesDraft = { name: (isNewProf? null : profileName), rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; } catch { const roomsMap = {}; for (const e of (this._config?.entities||[])) roomsMap[e] = []; this._profilesDraft = { name: (isNewProf? null : profileName), rooms: roomsMap, defaultTemp: Number(row.defaultTemp||this._config.default_temp||20) }; } } const arr = (this._profilesDraft.rooms||{})[curRoom] || []; // Find the block within the draft, not the stored profile let b = blockId ? arr.find(x=>x.id===blockId) : null; if (crossesMidnight) { const ov1 = hasOverlap(arr, start, 1440, b?.id); const ov2 = hasOverlap(arr, 0, end, null); if (ov1 || ov2) { const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const overlap = ov1 || ov2; const msg = this._t('ui.overlap_msg').replace('{start}', this._label(ov1?Math.max(start, ov1.startMin):0)).replace('{end}', this._label(ov1?Math.min(1440, ov1.endMin):end)); errEl.innerHTML = `
${msg}
`; errEl.style.display='block'; } return; } if (!b) { const id1=Math.random().toString(36).slice(2,9); const id2=Math.random().toString(36).slice(2,9); arr.push({ id:id1, startMin:start, endMin:1440, temp: this._fromDisplayTemp(temp) }); arr.push({ id:id2, startMin:0, endMin:end, temp: this._fromDisplayTemp(temp) }); } else { b.temp=this._fromDisplayTemp(temp); b.startMin=start; b.endMin=1440; const id2=Math.random().toString(36).slice(2,9); arr.push({ id:id2, startMin:0, endMin:end, temp: this._fromDisplayTemp(temp) }); } } else { const overlap = hasOverlap(arr, start, end, b?.id); if (overlap) { const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const msg = this._t('ui.overlap_msg').replace('{start}', this._label(Math.max(start, overlap.startMin))).replace('{end}', this._label(Math.min(end, overlap.endMin))); errEl.innerHTML = `
${msg}
`; errEl.style.display='block'; } return; } if (!b) { const id=Math.random().toString(36).slice(2,9); arr.push({ id, startMin:start, endMin:end, temp:this._fromDisplayTemp(temp) }); } else { b.temp=this._fromDisplayTemp(temp); b.startMin=start; b.endMin=end; } } (this._profilesDraft.rooms||{})[curRoom] = arr.sort((a,bx)=>a.startMin-bx.startMin||a.endMin-bx.endMin); this._profilesDirty = true; // If the profile edit originated from the main timeline, persist immediately if (this._autoSaveProfileFromTimeline) { try { this._autoSaveProfileFromTimeline = false; // Persist draft for current room/profile await this._profilesSaveDraft(); // Apply immediately if edit affects current time try { if (this._config?.auto_apply && this._config?.apply_on_edit) { const nowMin = this._getNowMin(); const arrNow = Array.isArray((this._profilesDraft?.rooms||{})[curRoom]) ? this._profilesDraft.rooms[curRoom] : []; const hit = arrNow.find(x => nowMin >= x.startMin && nowMin < x.endMin); if (hit) { await this._applyImmediateForEntity(entity, Number(hit.temp), nowMin); this._scheduleNextApply(); } } } catch {} this._closeEditor(); return; } catch { // Fall back to non-persisting flow } } this._render(); this._closeEditor(); this._renderProfilesModal(); return; } if (weeklyDay && this._weeklyDraft) { if (crossesMidnight) { // Split into two parts on the SAME day: [start..1440] and [0..end] const dayKey = weeklyDay; const arrToday = this._weeklyDraft.days?.[dayKey] || []; // Check overlaps for each part against the same day const ov1 = hasOverlap(arrToday, start, 1440, b?.id); const ov2 = hasOverlap(arrToday, 0, end, b?.id); if (ov1 || ov2){ const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const overlap = ov1 || ov2; const msg = this._t('ui.overlap_msg').replace('{start}', this._label(Math.max((ov1?start:0), overlap.startMin))).replace('{end}', this._label(Math.min((ov1?1440:end), overlap.endMin))); errEl.innerHTML = `
${msg}
`; errEl.style.display='block'; } return; } // Apply changes on same day if (!b) { const id1 = Math.random().toString(36).slice(2,9); const id2 = Math.random().toString(36).slice(2,9); arrToday.push({ id:id1, startMin:start, endMin:1440, temp }); arrToday.push({ id:id2, startMin:0, endMin:end, temp }); } else { b.temp = temp; b.startMin = start; b.endMin = 1440; const id2 = Math.random().toString(36).slice(2,9); arrToday.push({ id:id2, startMin:0, endMin:end, temp }); } this._weeklyDraft.days[dayKey] = arrToday.sort((a,bx)=>a.startMin-bx.startMin||a.endMin-bx.endMin); } else { if (!b) { const id = Math.random().toString(36).slice(2,9); b = { id, startMin: start, endMin: end, temp }; sourceBlocks.push(b); } else { b.temp = temp; b.startMin = start; b.endMin = end; } sourceBlocks.sort((a,bx)=>a.startMin - bx.startMin || a.endMin - bx.endMin); } // If started from main timeline, auto-save the whole weekly plan now if (this._autoSaveWeeklyFromTimeline) { this._autoSaveWeeklyFromTimeline = false; this._closeEditor(); // Persist weekly draft (propagate groups) and apply if needed this._weeklySaveAll(); } else { // Close editor and re-render weekly preview; no immediate apply this._closeEditor(); this._renderWeeklyModal(); // If "apply on edit" is enabled, and this weekly edit affects "right now", // apply the new temperature immediately without requiring full Week Save. try { if (this._config?.auto_apply && this._config?.apply_on_edit) { const today = this._todayKey(); // Determine if the edit impacts today given the current week mode and selected group const mode = (this._weeklyDraft?.mode) || (row.weekly?.mode) || (this._config?.weekdays_mode) || 'weekday_weekend'; const selKey = this._weeklyDayKey || weeklyDay || today; // group key the user was editing const isWeekday = ['mon','tue','wed','thu','fri'].includes(today); let editImpactsToday = false; let sourceKeyForToday = today; // which draft day array reflects today after grouping if (mode === 'weekday_weekend') { if (selKey === 'weekdays' && isWeekday) { editImpactsToday = true; sourceKeyForToday = 'mon'; } if (selKey === 'weekend' && (today==='sat' || today==='sun')) { editImpactsToday = true; sourceKeyForToday = 'sat'; } if (selKey === today) { editImpactsToday = true; sourceKeyForToday = today; } } else if (mode === 'weekday_sat_sun') { if (selKey === 'weekdays' && isWeekday) { editImpactsToday = true; sourceKeyForToday = 'mon'; } if ((selKey === 'sat' && today==='sat') || (selKey==='sun' && today==='sun')) { editImpactsToday = true; sourceKeyForToday = today; } if (selKey === today) { editImpactsToday = true; sourceKeyForToday = today; } } else { // all_7 if (selKey === today) { editImpactsToday = true; sourceKeyForToday = today; } } if (editImpactsToday) { const nowMin = this._getNowMin(); const arrNow = Array.isArray(this._weeklyDraft?.days?.[sourceKeyForToday]) ? this._weeklyDraft.days[sourceKeyForToday] : []; const hit = arrNow.find(x => nowMin >= x.startMin && nowMin < x.endMin); if (hit) { // Apply immediately to reflect the new block that currently covers "now" await this._applyImmediateForEntity(entity, Number(hit.temp), nowMin); this._scheduleNextApply(); } } } } catch {} } return; } if (this._editing.presence && this._presenceDraft) { // Work on presence draft only; persist on Presence Save const eid = this._editing.entity; const arr = (this._presenceDraft.rooms||{})[eid] || []; // Ensure we reference the correct block when editing (so overlap checks ignore itself) let b = blockId ? arr.find(x=>x.id===blockId) : null; if (crossesMidnight) { const ov1 = hasOverlap(arr, start, 1440, b?.id); const ov2 = hasOverlap(arr, 0, end, null); if (ov1 || ov2) { const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const overlap = ov1 || ov2; const msg = this._t('ui.overlap_msg').replace('{start}', this._label(ov1?Math.max(start, ov1.startMin):0)).replace('{end}', this._label(ov1?Math.min(1440, ov1.endMin):end)); errEl.innerHTML = `
${msg}
`; errEl.style.display='block'; } return; } if (!b) { const id1=Math.random().toString(36).slice(2,9); const id2=Math.random().toString(36).slice(2,9); arr.push({ id:id1, startMin:start, endMin:1440, temp }); arr.push({ id:id2, startMin:0, endMin:end, temp }); } else { b.temp=temp; b.startMin=start; b.endMin=1440; const id2=Math.random().toString(36).slice(2,9); arr.push({ id:id2, startMin:0, endMin:end, temp }); } } else { const overlap = hasOverlap(arr, start, end, b?.id); if (overlap) { const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const msg = this._t('ui.overlap_msg').replace('{start}', this._label(Math.max(start, overlap.startMin))).replace('{end}', this._label(Math.min(end, overlap.endMin))); errEl.innerHTML = `
${msg}
`; errEl.style.display='block'; } return; } if (!b) { const id=Math.random().toString(36).slice(2,9); arr.push({ id, startMin:start, endMin:end, temp }); } else { b.temp=temp; b.startMin=start; b.endMin=end; } } (this._presenceDraft.rooms||{})[eid] = arr.sort((a,bx)=>a.startMin-bx.startMin||a.endMin-bx.endMin); this._render(); this._closeEditor(); if (this._presenceOpen) this._renderPresenceModal(); return; } if (holiday && this._holidayDraft?.rooms) { // Work on holiday draft only; persist on Holiday Save const eid = entity; const arr = (this._holidayDraft.rooms||{})[eid] || []; if (crossesMidnight) { const ov1 = hasOverlap(arr, start, 1440, b?.id); const ov2 = hasOverlap(arr, 0, end, null); if (ov1 || ov2) { const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const overlap = ov1 || ov2; const msg = this._t('ui.overlap_msg').replace('{start}', this._label(ov1?Math.max(start, ov1.startMin):0)).replace('{end}', this._label(ov1?Math.min(1440, ov1.endMin):end)); errEl.innerHTML = `
${msg}
`; errEl.style.display='block'; } return; } if (!b) { const id1=Math.random().toString(36).slice(2,9); const id2=Math.random().toString(36).slice(2,9); arr.push({ id:id1, startMin:start, endMin:1440, temp: this._fromDisplayTemp(temp) }); arr.push({ id:id2, startMin:0, endMin:end, temp: this._fromDisplayTemp(temp) }); } else { b.temp=this._fromDisplayTemp(temp); b.startMin=start; b.endMin=1440; const id2=Math.random().toString(36).slice(2,9); arr.push({ id:id2, startMin:0, endMin:end, temp: this._fromDisplayTemp(temp) }); } } else { const overlap = hasOverlap(arr, start, end, b?.id); if (overlap) { const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const msg = this._t('ui.overlap_msg').replace('{start}', this._label(Math.max(start, overlap.startMin))).replace('{end}', this._label(Math.min(end, overlap.endMin))); errEl.innerHTML = `
${msg}
`; errEl.style.display='block'; } return; } if (!b) { const id=Math.random().toString(36).slice(2,9); arr.push({ id, startMin:start, endMin:end, temp:this._fromDisplayTemp(temp) }); } else { b.temp=this._fromDisplayTemp(temp); b.startMin=start; b.endMin=end; } } (this._holidayDraft.rooms||{})[eid] = arr.sort((a,bx)=>a.startMin-bx.startMin||a.endMin-bx.endMin); this._render(); this._closeEditor(); this._renderHolidayModal(); return; } // Normal (non-weekly) save if (crossesMidnight) { // Check overlaps for both parts const ov1 = hasOverlap(row.blocks, start, 1440, b?.id); const ov2 = hasOverlap(row.blocks, 0, end, null); if (ov1 || ov2) { const errEl = this.shadowRoot.querySelector('.ed-error'); if (errEl) { const overlap = ov1 || ov2; const msg = this._t('ui.overlap_msg').replace('{start}', this._label(ov1?Math.max(start, ov1.startMin):0)).replace('{end}', this._label(ov1?Math.min(1440, ov1.endMin):end)); const fixStartLab = this._t('ui.fix_start_to').replace('{time}', this._label((ov1?1440:overlap.endMin))); const fixEndLab = this._t('ui.fix_end_to').replace('{time}', this._label((ov2?0:overlap.startMin))); // Relevance: if first part [start..1440] overlaps -> offer start fix; if second part [0..end] overlaps -> offer end fix const showStart = !!ov1; const showEnd = !!ov2; let btns = ''; if (showStart) btns += ``; if (showEnd) btns += ``; // No extra cancel here; use the modal cancel button errEl.innerHTML = `
${msg}
${btns}
`; errEl.style.display='block'; const applyFix = (kind)=>{ try { const edFrom = this.shadowRoot.querySelector('.ed-from'); const edTo = this.shadowRoot.querySelector('.ed-to'); const fromMerSel = this.shadowRoot.querySelector('.ed-from-mer'); const toMerSel = this.shadowRoot.querySelector('.ed-to-mer'); if (kind==='start') { const ns = ov1 ? Math.max(start, (ov1.endMin||start)) : (overlap.endMin||start); if (this._config?.time_12h) { if (edFrom) edFrom.value = this._toTimeInput12h(ns); if (fromMerSel) fromMerSel.value = (ns>=720 && ns<1440)?'PM':'AM'; } else { if (edFrom) edFrom.value = this._toTimeInput(ns); } } else { const ne = ov2 ? Math.min(end, (ov2.startMin||end)) : (overlap.startMin||end); if (this._config?.time_12h) { if (edTo) edTo.value = this._toTimeInput12h(ne); if (toMerSel) toMerSel.value = (ne>=720 && ne<1440)?'PM':'AM'; } else { if (edTo) edTo.value = this._toTimeInput(ne); } } } catch {} try { errEl.style.display='none'; errEl.textContent=''; } catch {} setTimeout(()=>{ this._saveEditor(); }, 0); }; errEl.querySelector('.ed-fix-start')?.addEventListener('click', ()=>applyFix('start')); errEl.querySelector('.ed-fix-end')?.addEventListener('click', ()=>applyFix('end')); // No extra cancel handler } return; } if (!b) { const id1 = Math.random().toString(36).slice(2,9); const id2 = Math.random().toString(36).slice(2,9); row.blocks.push({ id:id1, startMin:start, endMin:1440, temp }); row.blocks.push({ id:id2, startMin:0, endMin:end, temp }); } else { // Update current b to first part and add second b.temp = temp; b.startMin = start; b.endMin = 1440; const id2 = Math.random().toString(36).slice(2,9); row.blocks.push({ id:id2, startMin:0, endMin:end, temp }); } row.blocks.sort((a,bx)=>a.startMin-bx.startMin||a.endMin-bx.endMin); } else { if (!b) { const id = Math.random().toString(36).slice(2,9); b = { id, startMin: start, endMin: end, temp }; row.blocks.push(b); } else { b.temp = temp; b.startMin = start; b.endMin = end; } this._applyNoOverlapResize(entity, b, "left", b.startMin); this._applyNoOverlapResize(entity, b, "right", b.endMin); } // Persist local draft immediately for resilience try { this._persistDraftNow(); } catch {} this._markPendingSave(); this._render(); this._closeEditor(); // Immediate apply if a block now covers the current time (non-weekly path) try { if (this._config?.auto_apply && this._config?.apply_on_edit) { const nowMin = this._getNowMin(); const arrNow = Array.isArray(row.blocks) ? row.blocks : []; const hitNow = arrNow.find(x => nowMin >= x.startMin && nowMin < x.endMin); if (hitNow) await this._applyImmediateForEntity(entity, Number(hitNow.temp), nowMin); } } catch {} if (this._config.apply_on_edit) await this._applyIfDesiredChanged(before); this._scheduleNextApply(); } catch (e) { if (errElGlobal) { errElGlobal.style.display = 'block'; errElGlobal.textContent = this._t('editor.error_prefix') + (e && e.message ? e.message : String(e)); } }} _toTimeInput(min){ if (!Number.isFinite(min)) min = 0; const m = ((Math.floor(min) % 1440) + 1440) % 1440; const hh=Math.floor(m/60), mm=Math.floor(m%60); return `${String(hh).padStart(2,"0")}:${String(mm).padStart(2,"0")}`; } _fromTimeInput(str){ const m=/(\d{1,2}):(\d{2})$/.exec(str||""); if(!m) return 0; const h=Math.max(0,Math.min(23,parseInt(m[1],10))); const mi=Math.max(0,Math.min(59,parseInt(m[2],10))); return h*60+mi; } _toTimeInput12h(min){ if (!Number.isFinite(min)) min=0; const m=((Math.floor(min)%1440)+1440)%1440; let hh=Math.floor(m/60); const mm=Math.floor(m%60); let h=hh%12; if(h===0) h=12; return `${String(h).padStart(2,'0')}:${String(mm).padStart(2,'0')}`; } // ---------- Onboarding ---------- _onboardStorageKey(){ return 'thermostat_timeline_onboard_seen_v1'; } _maybeOpenOnboard(){ try { // Respect explicit disable flag if (this._disableOnboard) return; const flag = localStorage.getItem(this._onboardStorageKey()); if (!flag) this._openOnboard(); } catch {} } _openOnboard(){ try { this._onboardOpen = true; this._onboardStep = 0; this._renderOnboard(); const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-onboard'); if (ov) ov.classList.add('open'); } catch {} } _closeOnboard(markSeen=false){ try { const ov = this.shadowRoot && this.shadowRoot.querySelector('.overlay-onboard'); if (ov) ov.classList.remove('open'); this._onboardOpen = false; // store preference const cb = this.shadowRoot && this.shadowRoot.querySelector('.onb-hide'); if (markSeen || (cb && cb.checked)) { try { localStorage.setItem(this._onboardStorageKey(), '1'); } catch {} } } catch {} } _renderOnboard(){ try { const img = this.shadowRoot && this.shadowRoot.querySelector('.onb-img'); const vid = this.shadowRoot && this.shadowRoot.querySelector('.onb-vid'); const hero = this.shadowRoot && this.shadowRoot.querySelector('.ob-hero'); const txt = this.shadowRoot && this.shadowRoot.querySelector('.onb-text'); const dots = this.shadowRoot && this.shadowRoot.querySelector('.onb-dots'); const next = this.shadowRoot && this.shadowRoot.querySelector('.onb-next'); const done = this.shadowRoot && this.shadowRoot.querySelector('.onb-done'); const step = Math.max(0, Math.min(this._onboardSlides.length-1, this._onboardStep)); const slide = this._onboardSlides[step] || { key:'onboard.step_main', img:'', fallback:'' }; if (img && vid) { // First try HACS/relative asset; on error, try /local fallback if provided const nextUrl = slide.img || slide.fallback || ''; if (this._onboardLastUrl !== nextUrl) { this._onboardLastUrl = nextUrl; const isVideo = /\.(mp4|webm|mov)(\?|#|$)/i.test(nextUrl); // Reset state img.style.display = 'none'; vid.style.display = 'none'; // Remove any previous message try { const m = hero && hero.querySelector('.onb-missing'); if (m) m.remove(); } catch {} if (isVideo) { // Setup video vid.onerror = null; vid.onloadeddata = null; vid.oncanplay = null; vid.muted = true; vid.playsInline = true; vid.loop = true; vid.autoplay = true; vid.onloadeddata = ()=>{ try { vid.style.display=''; img.style.display='none'; vid.play().catch(()=>{}); } catch{} }; vid.onerror = ()=>{ try { if (slide.fallback && vid.src !== slide.fallback) { vid.src = slide.fallback; vid.load?.(); return; } vid.style.display='none'; if (hero) { let msg = hero.querySelector('.onb-missing'); if (!msg){ msg=document.createElement('div'); msg.className='onb-missing'; msg.style.fontSize='.9rem'; msg.style.color='var(--secondary-text-color)'; msg.style.padding='8px'; hero.append(msg); } msg.textContent=this._t('onboard.video_not_found') || 'Video not found'; } } catch{} }; vid.src = nextUrl; vid.load?.(); } else { // Setup image img.onerror = null; img.onload = null; img.onerror = ()=>{ try { if (slide.fallback && img.src !== slide.fallback) { img.src = slide.fallback; return; } img.style.display='none'; if (hero) { let msg = hero.querySelector('.onb-missing'); if (!msg){ msg=document.createElement('div'); msg.className='onb-missing'; msg.style.fontSize='.9rem'; msg.style.color='var(--secondary-text-color)'; msg.style.padding='8px'; hero.append(msg); } msg.textContent=this._t('onboard.image_not_found') || 'Image not found'; } } catch{} }; img.onload = ()=>{ try { img.style.display=''; vid.style.display='none'; } catch{} }; img.src = nextUrl; } } } if (txt) txt.textContent = this._t(slide.key); if (dots) { dots.innerHTML = ''; for (let i=0;ib.id!==id); if(this._active&&this._active.entity===entity&&this._active.id===id) this._active=null; this._debouncedSaveStore(); this._render(); if (this._config.apply_on_edit) this._applyIfDesiredChanged(before); this._scheduleNextApply(); } _t(k){ return ttLocalize(k, this._hass || this._lang || 'en'); } _applyCardI18n(){ try { const t = (k)=> this._t(k); // Main modal: Edit block const modal = this.shadowRoot && this.shadowRoot.querySelector('.overlay:not(.overlay-week):not(.overlay-profiles):not(.overlay-holiday):not(.overlay-presence):not(.overlay-copyrooms):not(.overlay-onboard):not(.overlay-pause):not(.overlay-test):not(.overlay-settings) .modal'); if (modal) { modal.setAttribute('aria-label', t('ui.edit_block')); const h3 = modal.querySelector('.ed-modal-title'); if (h3) h3.textContent = t('ui.edit_block'); const fromLab = modal.querySelector('.ed-from-label'); if (fromLab) fromLab.textContent = t('ui.from'); const toLab = modal.querySelector('.ed-to-label'); if (toLab) toLab.textContent = t('ui.to'); const tempLab = modal.querySelector('.ed-temp-label'); if (tempLab) tempLab.textContent = t('ui.temperature') + ' ('+ this._unitSymbol() +')'; const del = modal.querySelector('.ed-delete'); if (del) del.textContent = t('ui.delete'); const cancel = modal.querySelector('.ed-cancel'); if (cancel) cancel.textContent = t('ui.cancel'); const save = modal.querySelector('.ed-save'); if (save) save.textContent = t('ui.save'); } // Week modal try { const wt = this.shadowRoot && this.shadowRoot.querySelector('.week-title'); if (wt) wt.textContent = t('week.editor.title'); const wml = this.shadowRoot && this.shadowRoot.querySelector('.week-mode-label'); if (wml) wml.textContent = t('week.mode'); const wnote = this.shadowRoot && this.shadowRoot.querySelector('.week-note'); if (wnote) wnote.textContent = t('week.editor.preview_note'); const wsel = this.shadowRoot && this.shadowRoot.querySelector('.week-mode'); if (wsel) { const opt1 = wsel.querySelector('.opt-weekday-weekend'); if (opt1) opt1.textContent = t('week.mode.weekday_weekend'); const opt2 = wsel.querySelector('.opt-weekday-sat-sun'); if (opt2) opt2.textContent = t('week.mode.weekday_sat_sun'); const opt3 = wsel.querySelector('.opt-all-7'); if (opt3) opt3.textContent = t('week.mode.all_7'); } const wcancelBtn = this.shadowRoot && this.shadowRoot.querySelector('.wk-cancel'); if (wcancelBtn) wcancelBtn.textContent = t('ui.cancel'); const wsaveBtn = this.shadowRoot && this.shadowRoot.querySelector('.wk-save'); if (wsaveBtn) wsaveBtn.textContent = t('ui.save'); } catch {} // Profiles modal try { const proftitle = this.shadowRoot && this.shadowRoot.querySelector('.prof-title'); if (proftitle) proftitle.textContent = t('profiles.editor.title'); const profadd = this.shadowRoot && this.shadowRoot.querySelector('.prof-add-text'); if (profadd) profadd.textContent = t('profiles.add'); const ps = this.shadowRoot && this.shadowRoot.querySelector('.prof-save'); if (ps) { const isExisting = !!(this._profilesEditingExisting && this._profilesSelected); const key = isExisting ? 'profiles.save_existing' : 'profiles.save_new'; ps.textContent = t(key) || t('ui.save'); } const pc = this.shadowRoot && this.shadowRoot.querySelector('.prof-close'); if (pc) pc.textContent = t('ui.close'); } catch {} // Holiday modal try { const ht = this.shadowRoot && this.shadowRoot.querySelector('.holiday-title'); if (ht) ht.textContent = t('holidays.button'); const hs = this.shadowRoot && this.shadowRoot.querySelector('.holiday-save'); if (hs) hs.textContent = t('ui.save'); const hc = this.shadowRoot && this.shadowRoot.querySelector('.holiday-close'); if (hc) hc.textContent = t('ui.close'); } catch {} // Presence modal try { const pttl = this.shadowRoot && this.shadowRoot.querySelector('.presence-title'); if (pttl) pttl.textContent = t('presence.editor.title'); const padv = this.shadowRoot && this.shadowRoot.querySelector('.presence-adv-label'); if (padv) padv.textContent = t('presence.enable_advanced'); const ps = this.shadowRoot && this.shadowRoot.querySelector('.presence-save'); if (ps) ps.textContent = t('ui.save'); const pcl = this.shadowRoot && this.shadowRoot.querySelector('.presence-close'); if (pcl) pcl.textContent = t('ui.close'); } catch {} // Copy to rooms modal try { const crt = this.shadowRoot && this.shadowRoot.querySelector('.copyrooms-title'); if (crt) crt.textContent = t('week.copy_to_rooms.title'); const crd = this.shadowRoot && this.shadowRoot.querySelector('.copyrooms-desc'); if (crd) crd.textContent = t('week.copy_to_rooms.select'); const crc = this.shadowRoot && this.shadowRoot.querySelector('.copyrooms-cancel'); if (crc) crc.textContent = t('ui.cancel'); const cra = this.shadowRoot && this.shadowRoot.querySelector('.copyrooms-apply'); if (cra) cra.textContent = t('week.copy_to_rooms.apply'); } catch {} // Onboarding modal try { const ttl = this.shadowRoot && this.shadowRoot.querySelector('.onb-title'); if (ttl) ttl.textContent = t('onboard.title'); const prev = this.shadowRoot && this.shadowRoot.querySelector('.onb-prev'); if (prev) prev.textContent = t('onboard.prev'); const next = this.shadowRoot && this.shadowRoot.querySelector('.onb-next'); if (next) next.textContent = t('onboard.next'); const skip = this.shadowRoot && this.shadowRoot.querySelector('.onb-skip'); if (skip) skip.textContent = t('onboard.skip'); const done = this.shadowRoot && this.shadowRoot.querySelector('.onb-done'); if (done) done.textContent = t('onboard.done'); const hideLbl = this.shadowRoot && this.shadowRoot.querySelector('.onb-hide-lbl'); if (hideLbl) hideLbl.textContent = t('onboard.dont_show'); } catch {} // Pause modal try { const pt = this.shadowRoot && this.shadowRoot.querySelector('.pause-title'); if (pt) pt.textContent = t('pause.menu_title'); const p1 = this.shadowRoot && this.shadowRoot.querySelector('.pause-1h'); if (p1) p1.textContent = t('pause.off_1h'); const p5 = this.shadowRoot && this.shadowRoot.querySelector('.pause-5h'); if (p5) p5.textContent = t('pause.off_5h'); const pcu = this.shadowRoot && this.shadowRoot.querySelector('.pause-custom'); if (pcu) pcu.textContent = t('pause.off_custom'); const pi = this.shadowRoot && this.shadowRoot.querySelector('.pause-indef'); if (pi) pi.textContent = t('pause.off_indef'); const pr = this.shadowRoot && this.shadowRoot.querySelector('.pause-resume'); if (pr) pr.textContent = t('pause.resume'); const px = this.shadowRoot && this.shadowRoot.querySelector('.pause-close'); if (px) px.textContent = t('pause.close'); } catch {} // Test modal try { const testtitle = this.shadowRoot && this.shadowRoot.querySelector('.test-title'); if (testtitle) testtitle.textContent = t('editor.test_tool') || 'Test tool'; const testlabel = this.shadowRoot && this.shadowRoot.querySelector('.test-label'); if (testlabel) testlabel.textContent = t('editor.test_select_entity') || 'Select entity'; const testempty = this.shadowRoot && this.shadowRoot.querySelector('.test-empty'); if (testempty) testempty.textContent = t('editor.test_empty') || 'Select an entity to see available commands…'; const testexport = this.shadowRoot && this.shadowRoot.querySelector('.test-export-btn'); if (testexport) testexport.textContent = t('editor.test_export') || 'Export .md'; const testclose = this.shadowRoot && this.shadowRoot.querySelector('.test-close'); if (testclose) testclose.textContent = t('ui.close'); } catch {} // Settings modal (in-card) try { const sm = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .modal'); if (sm) sm.setAttribute('aria-label', t('editor.tabs.settings')); const st = this.shadowRoot && this.shadowRoot.querySelector('.settings-title'); if (st) st.textContent = t('editor.tabs.settings'); // Integration availability status chip (moved from HA editor into popup header) try { const chip = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-int-status'); const icon = chip?.querySelector('ha-icon'); const txt = chip?.querySelector('.text'); const hasService = !!(this._hass && this._hass.services && this._hass.services['thermostat_timeline']); if (chip && icon && txt) { if (hasService) { chip.classList.remove('bad'); chip.classList.add('ok'); icon.setAttribute('icon','mdi:cloud-check-outline'); txt.textContent = t('editor.integration.connected') || 'Connected to integration'; } else { chip.classList.remove('ok'); chip.classList.add('bad'); icon.setAttribute('icon','mdi:cloud-off-outline'); txt.textContent = t('editor.integration.disconnected') || 'Not connected to integration'; } } } catch {} // Use the draft unit for the settings popup (so unit toggles update labels immediately, before Save). const popupUnit = (() => { try { if (this._settingsDraft && this._settingsDraft.temp_unit != null) { const u = String(this._settingsDraft.temp_unit).toUpperCase(); if (u === 'F' || u === 'FAHRENHEIT') return '°F'; if (u === 'C' || u === 'CELSIUS') return '°C'; } } catch {} try { return this._unitSymbol(); } catch {} return '°C'; })(); // Tab strip (same labels as editor tabs; excludes Sync/Backup intentionally) const tabMap = { settings: 'editor.tabs.settings', pause: 'editor.tabs.pause', weekdays: 'editor.tabs.weekdays', rooms: 'editor.tabs.rooms', boiler: 'editor.tabs.boiler', colors: 'editor.tabs.colors', presence_sensor: 'editor.tabs.presence_sensor', owd: 'editor.tabs.owd', holidays: 'editor.tabs.holidays', away: 'editor.tabs.away', reset: 'editor.tabs.reset' }; const tabFallback = { settings: 'Settings', pause: 'Pause', weekdays: 'Weekdays', rooms: 'Rooms', boiler: 'Boiler', colors: 'Colors', presence_sensor: 'Presence sensor', owd: 'Open Window Detection', holidays: 'Holidays', away: 'Away from home', reset: 'Reset' }; const btns = this.shadowRoot ? Array.from(this.shadowRoot.querySelectorAll('.overlay-settings .settings-tab-btn')) : []; for (const b of btns){ const tab = String(b.getAttribute('data-tab') || ''); const key = tabMap[tab] || null; if (!key) continue; b.textContent = t(key) || tabFallback[tab] || tab; } const ss = this.shadowRoot && this.shadowRoot.querySelector('.settings-save'); if (ss) ss.textContent = t('ui.save'); const sc = this.shadowRoot && this.shadowRoot.querySelector('.settings-close'); if (sc) sc.textContent = t('ui.close'); // Settings tab content labels const defLab = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-slabel-def'); if (defLab) defLab.textContent = String(t('editor.default_c')||'').replace('°C', popupUnit); const rowHLab = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-slabel-rowh'); if (rowHLab) rowHLab.textContent = t('editor.row_height_px'); const minLab = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-slabel-minc'); if (minLab) minLab.textContent = String(t('editor.min_c')||'').replace('°C', popupUnit); const maxLab = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-slabel-maxc'); if (maxLab) maxLab.textContent = String(t('editor.max_c')||'').replace('°C', popupUnit); const at = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-auto-title'); if (at) at.textContent = t('editor.auto_apply.title'); const ad = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-auto-desc'); if (ad) ad.textContent = t('editor.auto_apply.desc'); const et = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-applyedit-title'); if (et) et.textContent = t('editor.apply_edit.title'); const ed = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-applyedit-desc'); if (ed) ed.textContent = t('editor.apply_edit.desc'); const dt = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-applydef-title'); if (dt) dt.textContent = t('editor.apply_default.title'); const dd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-applydef-desc'); if (dd) dd.textContent = t('editor.apply_default.desc'); const prt = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-perroom-title'); if (prt) prt.textContent = t('editor.perroom.title'); const prd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-perroom-desc'); if (prd) prd.textContent = t('editor.perroom.desc'); const srt = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-showroomtemp-title'); if (srt) srt.textContent = t('editor.show_room_temp.title'); const srd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-showroomtemp-desc'); if (srd) srd.textContent = t('editor.show_room_temp.desc'); const pbt = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-pausebtn-title'); if (pbt) pbt.textContent = t('editor.pausebtn.title'); const pbd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-pausebtn-desc'); if (pbd) pbd.textContent = t('editor.pausebtn.desc'); const pst = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-pausesensor-title'); if (pst) pst.textContent = t('editor.pause_sensor.title'); const psd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-pausesensor-desc'); if (psd) psd.textContent = t('editor.pause_sensor.desc'); const psEt = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-pausesensor-entity-title'); if (psEt) psEt.textContent = t('editor.pause_sensor.entity_title'); const psEd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-pausesensor-entity-desc'); if (psEd) psEd.textContent = t('editor.pause_sensor.entity_desc'); // New popup-only settings (moved from HA editor) const tft = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-timefmt-title'); if (tft) tft.textContent = t('editor.timefmt.title'); const tfd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-timefmt-desc'); if (tfd) tfd.textContent = t('editor.timefmt.desc'); const tst = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-timesrc-title'); if (tst) tst.textContent = t('editor.timesrc.title'); const tsd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-timesrc-desc'); if (tsd) tsd.textContent = t('editor.timesrc.desc'); const sb = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-srcBrowser'); if (sb) sb.textContent = t('editor.timesrc.browser'); const sh = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-srcHa'); if (sh) sh.textContent = t('editor.timesrc.ha'); const tut = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-tempfmt-title'); if (tut) tut.textContent = t('editor.tempfmt.title'); const tud = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-tempfmt-desc'); if (tud) tud.textContent = t('editor.tempfmt.desc'); const pt = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-profiles-title'); if (pt) pt.textContent = t('profiles.enable'); const pd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-profiles-desc'); if (pd) pd.textContent = t('profiles.enable.desc'); const seasonTitle = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-seasonal-title'); if (seasonTitle) seasonTitle.textContent = t('season.enable.title'); const seasonDesc = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-seasonal-desc'); if (seasonDesc) seasonDesc.textContent = t('season.enable.desc'); const instTitle = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-instance-title'); if (instTitle) instTitle.textContent = t('editor.instance.title'); const instDesc = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-instance-desc'); if (instDesc) instDesc.textContent = t('editor.instance.desc'); const instBtn = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-instance-regenerate-btn'); if (instBtn) { const label = instBtn.querySelector('span') || instBtn; if (label) label.textContent = t('editor.instance.new_id'); } // Weekdays tab content labels const weT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-enable-title'); if (weT) weT.textContent = t('week.enable'); const weD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-enable-desc'); if (weD) weD.textContent = t('week.enable.desc'); const wvT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-view-title'); if (wvT) wvT.textContent = t('week.view.title'); const wvR = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-view-rooms'); if (wvR) wvR.textContent = t('week.view.rooms_one_day'); const wvD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-view-days'); if (wvD) wvD.textContent = t('week.view.days_one_room'); const wvsT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-view-switch-title'); if (wvsT) wvsT.textContent = t('week.view_switch.title'); const wvsD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-view-switch-desc'); if (wvsD) wvsD.textContent = t('week.view_switch.desc'); const woT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-open-title'); if (woT) woT.textContent = t('week.editor.title'); const woD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-open-desc'); if (woD) woD.textContent = t('week.open_editor.desc'); const woB = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-week-open-btn'); if (woB) woB.textContent = t('week.open_editor.btn'); // Boiler tab content labels const beT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-enable-title'); if (beT) beT.textContent = t('boiler.enable'); const beD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-enable-desc'); if (beD) beD.textContent = t('boiler.enable.desc'); const bsT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-switch-title'); if (bsT) bsT.textContent = t('boiler.switch'); const bsdL = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-switch-domain-label'); if (bsdL) bsdL.textContent = t('boiler.switch_type'); try { const sel = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-switch-domain'); if (sel) { const oSw = sel.querySelector('option[value="switch"]'); if (oSw) oSw.textContent = t('boiler.switch_type.switch') || 'Switch'; const oIb = sel.querySelector('option[value="input_boolean"]'); if (oIb) oIb.textContent = t('boiler.switch_type.input_boolean') || 'Input boolean'; } } catch {} const brT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-rooms-title'); if (brT) brT.textContent = t('boiler.rooms'); const brD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-rooms-desc'); if (brD) brD.textContent = t('boiler.rooms.desc'); const bmT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-multi-title'); if (bmT) bmT.textContent = t('boiler.multi_enable'); const bmD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-multi-desc'); if (bmD) bmD.textContent = t('boiler.multi_enable.desc'); const bmrT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-multi-rooms-title'); if (bmrT) bmrT.textContent = t('boiler.multi_rooms'); const bmrD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-multi-rooms-desc'); if (bmrD) bmrD.textContent = t('boiler.multi_rooms.desc'); const boT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-offsets-title'); if (boT) boT.textContent = t('boiler.offsets'); const boD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-offsets-desc'); if (boD) boD.textContent = t('boiler.offsets.desc'); const bon = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-on-offset-label'); if (bon) bon.textContent = String(t('boiler.on_offset')||'').replace('°C', popupUnit); const bof = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-off-offset-label'); if (bof) bof.textContent = String(t('boiler.off_offset')||'').replace('°C', popupUnit); const btT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-sensor-title'); if (btT) btT.textContent = t('boiler.temp_sensor'); const btD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-sensor-desc'); if (btD) btD.textContent = t('boiler.temp_sensor.desc'); const bmin = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-min-temp-label'); if (bmin) bmin.textContent = String(t('boiler.min_temp')||'').replace('°C', popupUnit); const bmax = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-boiler-max-temp-label'); if (bmax) bmax.textContent = String(t('boiler.max_temp')||'').replace('°C', popupUnit); // Open Window Detection tab content labels const owdT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-owd-enable-title'); if (owdT) owdT.textContent = t('owd.enable'); const owdD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-owd-enable-desc'); if (owdD) owdD.textContent = t('owd.enable.desc'); const owdS = this.shadowRoot ? Array.from(this.shadowRoot.querySelectorAll('.overlay-settings .sp-owd-sensors-label')) : []; for (const el of owdS) { try { el.textContent = t('owd.sensors'); } catch {} } const owdDT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-owd-delays-title'); if (owdDT) owdDT.textContent = t('owd.delays.title'); const owdDD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-owd-delays-desc'); if (owdDD) owdDD.textContent = t('owd.delays.desc'); const owdOpen = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-owd-open-delay-label'); if (owdOpen) owdOpen.textContent = t('owd.open_delay'); const owdClose = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-owd-close-delay-label'); if (owdClose) owdClose.textContent = t('owd.close_delay'); // Holidays tab content labels const heT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-enable-title'); if (heT) heT.textContent = t('holidays.enable'); const heD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-enable-desc'); if (heD) heD.textContent = t('holidays.desc'); const hsT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-source-title'); if (hsT) hsT.textContent = t('holidays.source'); const hCal = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-source-cal'); if (hCal) hCal.textContent = t('holidays.source.calendar'); const hMan = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-source-man'); if (hMan) hMan.textContent = t('holidays.source.manual'); const hEntT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-entity-title'); if (hEntT) hEntT.textContent = t('holidays.entity'); const hEntD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-entity-desc'); if (hEntD) hEntD.textContent = t('holidays.entity_desc'); const hDatesT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-dates-title'); if (hDatesT) hDatesT.textContent = t('holidays.dates'); const hSinglesT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-single-title'); if (hSinglesT) hSinglesT.textContent = t('holidays.single_dates'); const hAdd = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-add-date'); if (hAdd) hAdd.textContent = t('holidays.add_date'); const hAddR = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-add-range'); if (hAddR) hAddR.textContent = t('holidays.add_range'); const hRangesT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-ranges-title'); if (hRangesT) hRangesT.textContent = t('holidays.ranges'); const hEditT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-edit-title'); if (hEditT) hEditT.textContent = t('holidays.editor.title'); const hEditB = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-holidays-edit-btn'); if (hEditB) hEditB.textContent = t('holidays.edit'); // Away tab content labels const aeT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-enable-title'); if (aeT) aeT.textContent = t('away.enable'); const aeD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-enable-desc'); if (aeD) aeD.textContent = t('away.desc'); const atT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-temp-title'); if (atT) atT.textContent = String(t('away.temp')||'').replace('°C', popupUnit); const apT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-persons-title'); if (apT) apT.textContent = t('away.persons'); const adT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-delay-title'); if (adT) adT.textContent = t('away.delay_enable'); const adD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-delay-desc'); if (adD) adD.textContent = t('away.delay_desc'); const adVL = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-delay-value-label'); if (adVL) adVL.textContent = t('away.delay_value'); const adUL = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-delay-unit-label'); if (adUL) adUL.textContent = t('away.delay_unit'); try { const sel = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-delay-unit'); if (sel) { const opts = sel.querySelectorAll('option'); if (opts[0]) opts[0].textContent = t('away.delay_unit.seconds'); if (opts[1]) opts[1].textContent = t('away.delay_unit.minutes'); } } catch {} const aaT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-adv-title'); if (aaT) aaT.textContent = t('presence.enable_advanced'); const aaD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-away-adv-desc'); if (aaD) aaD.textContent = t('presence.enable_advanced.desc'); const plT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-presence-live-title'); if (plT) plT.textContent = t('presence.live_header'); const plD = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-presence-live-desc'); if (plD) plD.textContent = t('presence.live_header.desc'); const pcT = this.shadowRoot && this.shadowRoot.querySelector('.overlay-settings .sp-presence-combos-title'); if (pcT) pcT.textContent = t('presence.combos'); } catch {} } catch (e) { /* ignore */ } } _colorFor(eid, tempC){ try { // Onboarding i18n try { const ttl = this.shadowRoot && this.shadowRoot.querySelector('.onb-title'); if (ttl) ttl.textContent = t('onboard.title'); const prev = this.shadowRoot && this.shadowRoot.querySelector('.onb-prev'); if (prev) prev.textContent = t('onboard.prev'); const next = this.shadowRoot && this.shadowRoot.querySelector('.onb-next'); if (next) next.textContent = t('onboard.next'); const skip = this.shadowRoot && this.shadowRoot.querySelector('.onb-skip'); if (skip) skip.textContent = t('onboard.skip'); const done = this.shadowRoot && this.shadowRoot.querySelector('.onb-done'); if (done) done.textContent = t('onboard.done'); const hideLbl = this.shadowRoot && this.shadowRoot.querySelector('.onb-hide-lbl'); if (hideLbl) hideLbl.textContent = t('onboard.dont_show'); } catch {} // Support merged entities by always resolving to primary const primary = this._groupPrimaryOf ? this._groupPrimaryOf(eid) : eid; const cr = (this._config?.color_ranges) || {}; const globalFirst = !!this._config?.color_global; const candidates = globalFirst ? ['*', 'default', primary, eid] : [primary, eid, '*', 'default']; let ranges = []; for (const k of candidates){ if (Array.isArray(cr[k]) && cr[k].length){ ranges = cr[k]; break; } } for (const r of ranges){ const from = Number(r.from); const to = Number(r.to); if (!Number.isFinite(from) || !Number.isFinite(to)) continue; if (tempC >= Math.min(from,to) && tempC <= Math.max(from,to)) return r.color || ''; } } catch {} return ''; } // Compute readable text color for a given background _contrastTextColor(bg){ try { const rgb = (c)=>{ if(!c) return null; let s=String(c).trim(); if (s.startsWith('rgb')){ const m=s.match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); if(!m) return null; return {r:+m[1], g:+m[2], b:+m[3]}; } if (s[0]==='#') s=s.slice(1); if (s.length===3) s = s.split('').map(x=>x+x).join(''); if (s.length!==6) return null; const r=parseInt(s.slice(0,2),16), g=parseInt(s.slice(2,4),16), b=parseInt(s.slice(4,6),16); if ([r,g,b].some(x=>Number.isNaN(x))) return null; return {r,g,b}; }; const o = rgb(bg); if (!o) return null; // Perceived luminance (sRGB) const lum = (v)=>{ v/=255; return v<=0.03928? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); }; const L = 0.2126*lum(o.r) + 0.7152*lum(o.g) + 0.0722*lum(o.b); return L > 0.5 ? '#000000' : '#ffffff'; } catch { return null; } } } // Avoid hard failures if the resource is loaded multiple times. try { if (!customElements.get("thermostat-timeline-card")) { customElements.define("thermostat-timeline-card", ThermostatTimelineCard); } } catch {} /* ----------------- LIGHTWEIGHT PICKER (TTEntityPicker) ----------------- */ class TTEntityPicker extends HTMLElement { static get observedAttributes(){ return ['include-domains','label','disabled']; } constructor(){ super(); this.attachShadow({ mode: 'open' }); this._hass = null; this._value = ''; this._filter = null; // function this._domains = []; this._disabled = false; } set hass(h){ this._hass = h; this._render(); } get hass(){ return this._hass; } set value(v){ this._value = v || ''; this._render(); } get value(){ return this._value || ''; } set entityFilter(fn){ this._filter = typeof fn === 'function' ? fn : null; this._render(); } get entityFilter(){ return this._filter; } set disabled(v){ this._disabled = !!v; this._render(); } get disabled(){ return !!this._disabled; } attributeChangedCallback(name, _old, val){ if (name === 'disabled') this._disabled = val !== null; this._render(); } connectedCallback(){ this._render(); } _parseDomains(){ try { const a = this.getAttribute('include-domains'); if (!a) return []; const v = typeof a === 'string' ? JSON.parse(a) : a; return Array.isArray(v) ? v.map(x=>String(x)) : []; } catch { return []; } } _render(){ const label = this.getAttribute('label') || ''; this._domains = this._parseDomains(); const opts = []; try { const states = this._hass?.states || {}; for (const id of Object.keys(states)){ const dom = id.split('.')[0]; if (this._domains.length && !this._domains.includes(dom)) continue; const st = states[id]; if (this._filter && !this._filter(st)) continue; const name = st?.attributes?.friendly_name || (id.split('.')[1] || id); opts.push({ id, name }); } } catch {} opts.sort((a,b)=> a.name.localeCompare(b.name,'en',{sensitivity:'base'})); const selOpts = [''].concat( opts.map(o=>``) ); const css = ` :host{ display:block; } .wrap{ width:100%; min-width:250px; max-width:440px; } .sel{ position: relative; } select{ width:100%; height:36px; min-height:36px; appearance:none; -webkit-appearance:none; -moz-appearance:none; padding:6px 34px 6px 10px; border:1px solid var(--divider-color); border-radius:8px; background: var(--card-background-color); color: var(--primary-text-color); box-sizing: border-box; } .backup-slot-select{ width:auto; min-width:260px; height:40px; min-height:40px; padding:8px 42px 8px 14px; font-size:14px; font-weight:500; border:1px solid var(--divider-color); border-radius:10px; background: var(--card-background-color); color: var(--primary-text-color); box-shadow: 0 1px 2px rgba(0,0,0,0.08) inset, 0 1px 4px rgba(0,0,0,0.12); transition: border-color 120ms ease, box-shadow 120ms ease; } .backup-slot-select:focus{ outline:none; border-color: var(--primary-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary-color) 60%, transparent), 0 1px 4px rgba(0,0,0,0.14); } select:focus{ outline:none; border-color: var(--primary-color); box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary-color) 35%, transparent); } select:hover{ border-color: color-mix(in oklab, var(--primary-text-color) 20%, var(--divider-color)); } select:disabled{ opacity:.6; cursor: not-allowed; } .dd-arrow{ position:absolute; right:12px; top:50%; transform: translateY(-50%); width:0; height:0; border-left:6px solid transparent; border-right:6px solid transparent; border-top:6px solid var(--secondary-text-color); pointer-events:none; } select:disabled + .dd-arrow{ opacity:.6; } .label{ font-size:.85rem; color: var(--secondary-text-color); margin: 2px 0 4px; display:none; } `; this.shadowRoot.innerHTML = `
`; const select = this.shadowRoot.querySelector('select'); if (select) { try { select.value = this._value || ''; } catch {} try { select.disabled = !!this._disabled; } catch {} // Signal picker open early (helps pause editor re-render on macOS in Safari/Chrome) try { select.addEventListener('pointerdown', () => { this.dispatchEvent(new CustomEvent('picker-opened', { bubbles: true, composed: true })); }, { passive: true }); select.addEventListener('focus', () => { this.dispatchEvent(new CustomEvent('picker-opened', { bubbles: true, composed: true })); }, { passive: true }); } catch {} select.onchange = (e)=>{ this._value = e.target.value || ''; this.dispatchEvent(new CustomEvent('value-changed', { detail: { value: this._value }, bubbles: true, composed: true })); // Signal picker closed after a selection is made try { this.dispatchEvent(new CustomEvent('picker-closed', { bubbles: true, composed: true })); } catch {} }; // Also signal closed on blur (if user clicks away without changing) try { select.addEventListener('blur', ()=>{ this.dispatchEvent(new CustomEvent('picker-closed', { bubbles: true, composed: true })); }); } catch {} } } } // Avoid hard failures if another resource already defined the picker. try { if (!customElements.get('tt-entity-picker')) { customElements.define('tt-entity-picker', TTEntityPicker); } } catch {} /* ----------------- CONFIG EDITOR ----------------- */ class ThermostatTimelineCardEditor extends HTMLElement { setConfig(config) { const hadUid = !!(config && typeof config === 'object' && String(config.instance_uid || '').trim()); this._config = { ...ThermostatTimelineCard.getStubConfig(), ...(config||{}) }; this._storageLoaded = false; this._render(); // If this card predates instance_uid, persist it immediately so local per-card // storage keys remain stable across reloads. if (!hadUid) { try { this._emit(); } catch {} } } // Helper to check if API is available _apiSupported(){ try { return !!(this._hass?.callApi); } catch { return false; } } _normInstanceId(raw){ try { const s = String(raw ?? '').trim(); if (!s) return 'default'; const out = []; for (const ch of s) { const ok = (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_' || ch === '-' || ch === '.'; out.push(ok ? ch : '_'); } const s2 = out.join('').slice(0, 64); return s2 || 'default'; } catch { return 'default'; } } _instanceId(){ try { if (!this._config?.instance_enabled) return 'default'; return this._normInstanceId(this._config?.instance_id || 'default'); } catch { return 'default'; } } _localStoreKey(){ try { const iid = this._instanceId(); return (this._config?.instance_enabled) ? `thermostat_timeline_store:${iid}` : 'thermostat_timeline_store'; } catch { return 'thermostat_timeline_store'; } } async _apiFetchState(instanceId=null){ if (!this._apiSupported()) return null; try { const iid = instanceId ? this._normInstanceId(instanceId) : this._instanceId(); const q = (this._config?.instance_enabled && iid) ? `?instance_id=${encodeURIComponent(iid)}` : ''; return await this._hass.callApi('GET', `thermostat_timeline/state${q}`); } catch { return null; } } set hass(hass) { this._hass = hass; this._lang = ttGetLangFromHass(hass); // On first hass set, merge settings from file storage via API if (!this._storageLoaded && this._config?.storage_enabled) { this._storageLoaded = true; (async () => { try { const resp = await this._apiFetchState(); const s = resp?.settings; if (s && typeof s === 'object') { if (typeof s.time_source === 'string') this._config.time_source = (s.time_source === 'ha' ? 'ha' : 'browser'); if (typeof s.time_12h === 'boolean') this._config.time_12h = !!s.time_12h; if (typeof s.temp_unit === 'string') this._config.temp_unit = (String(s.temp_unit).toUpperCase()==='F' ? 'F' : 'C'); if (Number.isFinite(s.default_temp)) this._config.default_temp = Number(s.default_temp); if (Number.isFinite(s.row_height)) this._config.row_height = Math.max(30, Math.min(200, Math.round(Number(s.row_height)))); if (Number.isFinite(s.min_temp)) this._config.min_temp = Number(s.min_temp); if (Number.isFinite(s.max_temp)) this._config.max_temp = Number(s.max_temp); if (typeof s.auto_apply_enabled === 'boolean') this._config.auto_apply = !!s.auto_apply_enabled; if (typeof s.profiles_enabled === 'boolean') this._config.profiles_enabled = !!s.profiles_enabled; if (typeof s.weekdays_enabled === 'boolean') this._config.weekdays_enabled = !!s.weekdays_enabled; if (typeof s.holidays_enabled === 'boolean') this._config.holidays_enabled = !!s.holidays_enabled; if (typeof s.show_pause_button === 'boolean') this._config.show_pause_button = !!s.show_pause_button; if (typeof s.pause_sensor_enabled === 'boolean') this._config.pause_sensor_enabled = !!s.pause_sensor_enabled; if (typeof s.pause_sensor_entity === 'string') this._config.pause_sensor_entity = String(s.pause_sensor_entity || ''); if (typeof s.apply_on_edit === 'boolean') this._config.apply_on_edit = !!s.apply_on_edit; if (typeof s.apply_on_default_change === 'boolean') this._config.apply_on_default_change = !!s.apply_on_default_change; if (typeof s.per_room_defaults === 'boolean') this._config.per_room_defaults = !!s.per_room_defaults; if (typeof s.backup_auto_enabled === 'boolean') this._config.backup_auto_enabled = !!s.backup_auto_enabled; if (Number.isFinite(s.backup_interval_days)) this._config.backup_interval_days = s.backup_interval_days; if (s.open_window && typeof s.open_window === 'object') this._config.open_window = s.open_window; } // Re-render after merging settings try { this._render(); } catch {} } catch (err) {} })(); } // Avoid clobbering inputs while user is typing (preserve previous behavior) const ae = this.shadowRoot && this.shadowRoot.activeElement; if (this._suspendRender) return; if (ae) { try { if (ae.classList?.contains("title") || ae.classList?.contains('label-input') || ae.classList?.contains('mi-from') || ae.classList?.contains('mi-to') || ae.classList?.contains('mi-temp') || (ae.closest && ae.closest('.mini-ed'))) return; if (ae.closest && (ae.closest('ha-entity-picker') || ae.closest('tt-entity-picker'))) return; } catch {} } // Keep title preset in sync with language when user hasn't customized it try { if (this._config) { const cur = String(this._config.title || ''); if (!cur || this._isDefaultTitle(cur)) { const next = ttLocalize('card.title_default', this._hass || this._lang || 'en'); if (next && next !== cur) { this._config.title = next; this._emit(); } } } } catch {} // Full render and i18n apply (normal path) this._render(); this._applyEditorI18n(); // Ensure entity pickers are hydrated with hass after hard refresh try { this._forcePickerRefresh && this._forcePickerRefresh(); } catch {} // Also run a lightweight service-availability-only check in case services changed while editor open try { const root = this.shadowRoot; if (root) { const hasService = !!(this._hass && this._hass.services && this._hass.services['thermostat_timeline']); if (!hasService) { const warnText = this._t ? this._t('editor.store_missing') : null; const msg = warnText && warnText !== 'editor.store_missing' ? warnText : 'Integration "Thermostat Pro Timeline Sync" is not installed. Shared storage is unavailable.'; let warn = root.querySelector('.store-missing'); if (!warn) { warn = document.createElement('div'); warn.className = 'store-missing'; warn.style.color = 'var(--error-color)'; warn.style.fontSize = '.9rem'; warn.style.marginTop = '6px'; const controls = root.querySelector('.store-controls'); if (controls && controls.parentNode) controls.parentNode.insertBefore(warn, controls); } while (warn.firstChild) warn.removeChild(warn.firstChild); warn.appendChild(document.createTextNode(msg.replace(/\.$/, '') + '. ')); (function(){ let link = document.createElement('a'); // Use my.home-assistant.io deep link to the HACS repository page link.href = 'https://my.home-assistant.io/redirect/hacs_repository/?owner=qlerup&repository=thermostat-pro-timeline-sync'; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.style.color = 'var(--error-color)'; link.style.textDecoration = 'underline'; link.style.fontWeight = '500'; link.textContent = this._t('editor.open_in_hacs'); warn.appendChild(link); })(); const sw = root.querySelector('.store-enable'); if (sw) sw.disabled = true; const picker = root.querySelector('.storage'); if (picker) picker.disabled = true; } else { const rootWarn = root.querySelector('.store-missing'); if (rootWarn && rootWarn.parentNode) rootWarn.parentNode.removeChild(rootWarn); const sw = root.querySelector('.store-enable'); if (sw) sw.disabled = false; const picker = root.querySelector('.storage'); if (picker) picker.disabled = false; } // Update integration status chip in tabs try { const chip = root.querySelector('.int-status'); const icon = chip?.querySelector('ha-icon'); const txt = chip?.querySelector('.text'); if (chip && icon && txt) { if (hasService) { chip.classList.remove('bad'); chip.classList.add('ok'); icon.setAttribute('icon','mdi:cloud-check-outline'); txt.textContent = this._t ? this._t('editor.integration.connected') : 'Connected to integration'; } else { chip.classList.remove('ok'); chip.classList.add('bad'); icon.setAttribute('icon','mdi:cloud-off-outline'); txt.textContent = this._t ? this._t('editor.integration.disconnected') : 'Not connected to integration'; } // Click to jump to Sync tab try { chip.style.userSelect='none'; chip.onclick = ()=>{ this._activeTab='sync'; this._render(); }; } catch {} } } catch {} } } catch {} } constructor() { super(); this.attachShadow({ mode: "open" }); this._openCount = 0; this._pendingEmit = false; this._openRows = new Set(); this._openColorRows = new Set(); this._openOwdRows = new Set(); this._suspendRender = false; this._activeTab = 'settings'; // Holidays inline editor state this._holDraft = null; // { rooms: { [eid]: [ {id,startMin,endMin,temp} ] } } this._holRoom = null; // active room eid this._holEditing = null; // { room, blockId } this._holMini = null; // { room, blk } // Import state (Backup tab) this._importStoreError = ''; this._importStoreBusy = false; this.shadowRoot.innerHTML = `
Titel
Shared storage + background control
Saves schedules to file storage and enables background control.
`; this._applyEditorI18n(); const qs = (s)=>this.shadowRoot.querySelector(s); qs(".title")?.addEventListener("change", e => this._upd("title", e.target.value)); // Note: storage_entity_* pickers removed - file-based storage is always used qs('.store-enable')?.addEventListener('change', e => { const on = !!e.target.checked; this._upd('storage_enabled', on); // Disable the bottom "Clear local only" button when shared storage is ON try { const clrLocal = this.shadowRoot.querySelector('.clear-local-only'); if (clrLocal) { clrLocal.disabled = on; clrLocal.setAttribute('aria-disabled', on ? 'true' : 'false'); clrLocal.title = on ? 'Disabled while shared storage is ON' : ''; // Hide button entirely when shared storage is enabled (requested) clrLocal.style.display = on ? 'none' : ''; } } catch {} // Show/hide storage controls when shared storage is toggled try { const ctrls = this.shadowRoot.querySelector('.store-controls'); if (ctrls) ctrls.style.display = on ? 'flex' : 'none'; } catch {} // Sync controls removed (instant-only via Save button) this._applyEditorI18n(); // Re-render so Colors tab shared-clear button visibility updates immediately try { this._render(); } catch {} // Ensure integration side knows current auto-apply state when enabling shared storage try { if (on) this._pushSettingsToStoreDebounced(); } catch {} // On enabling shared storage: offer to migrate local browser data to storage (async ()=>{ try { if (on) { const localRaw = localStorage.getItem(this._localStoreKey()); const hasLocal = !!(localRaw && localRaw.length > 2); if (hasLocal) { const ok = confirm(this._t('editor.migrate_confirm')); if (ok) { // Properly unwrap the local payload and send both schedules and settings let parsed = {}; try { parsed = JSON.parse(localRaw || '{}'); } catch { parsed = {}; } let schedules = parsed && typeof parsed === 'object' && parsed.schedules ? parsed.schedules : (parsed || {}); let settings = parsed && typeof parsed === 'object' && parsed.settings ? parsed.settings : { time_12h: this._config.time_12h, temp_unit: this._config.temp_unit, entities: Array.isArray(this._config.entities) ? this._config.entities : [], room_use_input_number: Array.isArray(this._config.room_use_input_number) ? this._config.room_use_input_number : [], room_use_temp_sensor: Array.isArray(this._config.room_use_temp_sensor) ? this._config.room_use_temp_sensor : [], color_ranges: this._config.color_ranges, color_global: !!this._config.color_global, row_height: Number(this._config.row_height ?? 64), default_temp: Number(this._config.default_temp||20), min_temp: Number(this._config.min_temp ?? 5), max_temp: Number(this._config.max_temp ?? 25), away: this._config.away, merges: this._config.merges, labels: this._config.labels, temp_sensors: this._config.temp_sensors, per_room_defaults: !!(this._config.per_room_defaults ?? false), apply_on_default_change: !!(this._config.apply_on_default_change ?? true), holidays_enabled: !!this._config.holidays_enabled, holidays_source: this._config.holidays_source || 'calendar', holidays_entity: this._config.holidays_entity || '', holidays_dates: Array.isArray(this._config.holidays_dates) ? this._config.holidays_dates : [], sync_mode: (this._config.storage_sync_mode||'instant'), sync_delay_min: Number(Math.max(0, Math.round((this._config.storage_sync_sec||0)/60))), sync_delay_sec: Number(this._config.storage_sync_sec||0) }; let colors = parsed && typeof parsed === 'object' && parsed.colors ? parsed.colors : { color_ranges: settings?.color_ranges || this._config.color_ranges, color_global: typeof settings?.color_global === 'boolean' ? settings.color_global : !!this._config.color_global }; settings = { ...settings, entities: Array.isArray(settings?.entities) ? settings.entities : (Array.isArray(this._config.entities) ? this._config.entities : []), room_use_input_number: Array.isArray(settings?.room_use_input_number) ? settings.room_use_input_number : (Array.isArray(this._config.room_use_input_number) ? this._config.room_use_input_number : []), room_use_temp_sensor: Array.isArray(settings?.room_use_temp_sensor) ? settings.room_use_temp_sensor : (Array.isArray(this._config.room_use_temp_sensor) ? this._config.room_use_temp_sensor : []), row_height: Number(settings?.row_height ?? (this._config.row_height ?? 64)), min_temp: Number(settings?.min_temp ?? (this._config.min_temp ?? 5)), max_temp: Number(settings?.max_temp ?? (this._config.max_temp ?? 25)), apply_on_default_change: (typeof settings?.apply_on_default_change === 'boolean') ? settings.apply_on_default_change : !!(this._config.apply_on_default_change ?? true), show_pause_button: !!(this._config.show_pause_button ?? true), show_room_temp: !!(this._config.show_room_temp ?? true), pause_sensor_enabled: !!(this._config.pause_sensor_enabled ?? false), pause_sensor_entity: String(this._config.pause_sensor_entity || ''), auto_apply_enabled: !!(this._config.auto_apply ?? true), pause_indef: !!this._pauseIndef, pause_until_ms: Number(this._pauseUntilMs||0) }; if (this._hass) { // Use set_store service directly - file-based storage const p = { schedules, settings, colors }; if (this._config?.instance_enabled) { p.instance_id = this._instanceId(); p.activate = true; } await this._hass.callService('thermostat_timeline', 'set_store', p); } } else { // If user cancels, offer to import schedules FROM storage into local browser try { // Fetch from API instead of sensor states const resp = await this._apiFetchState(); if (resp && typeof resp === 'object') { let sch = resp.schedules || {}; let set = resp.settings || { time_12h: this._config.time_12h, temp_unit: this._config.temp_unit, entities: Array.isArray(this._config.entities) ? this._config.entities : [], room_use_input_number: Array.isArray(this._config.room_use_input_number) ? this._config.room_use_input_number : [], room_use_temp_sensor: Array.isArray(this._config.room_use_temp_sensor) ? this._config.room_use_temp_sensor : [], color_ranges: this._config.color_ranges, color_global: !!this._config.color_global, row_height: Number(this._config.row_height ?? 64), default_temp: Number(this._config.default_temp||20), min_temp: Number(this._config.min_temp ?? 5), max_temp: Number(this._config.max_temp ?? 25), away: this._config.away, merges: this._config.merges, labels: this._config.labels, temp_sensors: this._config.temp_sensors, per_room_defaults: !!(this._config.per_room_defaults ?? false), apply_on_default_change: !!(this._config.apply_on_default_change ?? true), holidays_enabled: !!this._config.holidays_enabled, holidays_source: this._config.holidays_source || 'calendar', holidays_entity: this._config.holidays_entity || '', holidays_dates: Array.isArray(this._config.holidays_dates) ? this._config.holidays_dates : [], sync_mode: (this._config.storage_sync_mode||'instant'), sync_delay_min: Number(Math.max(0, Math.round((this._config.storage_sync_sec||0)/60))), sync_delay_sec: Number(this._config.storage_sync_sec||0) }; let colors = resp.colors || { color_ranges: set.color_ranges || this._config.color_ranges, color_global: !!set.color_global }; if (Object.keys(sch || {}).length) { const pull = confirm(this._t('editor.migrate_pull_confirm')); if (pull) { try { localStorage.setItem(this._localStoreKey(), JSON.stringify({ schedules: sch, settings: set, colors })); } catch {} } } } } catch {} } } } } catch {} })(); }); // Sync mode controls removed (instant-only via Save button) // (reload buttons removed) // Holidays enable (migrated into in-card settings popup) // (clear storage only button removed) // Clear all data (storage + local) qs('.clear-all')?.addEventListener('click', async () => { const msg = this._t('editor.clear_all_confirm'); if (!confirm(msg)) return; try { try { await this._clearAllDataEverywhereConfirmed(); } catch {} } catch (e) {} }); // Clear local only (bottom) qs('.clear-local-only')?.addEventListener('click', async (ev) => { // Hard guard: do nothing if shared storage is enabled if (this._config?.storage_enabled) { try { ev.preventDefault(); ev.stopPropagation(); } catch {} return; } const msg = this._t('editor.clear_local_only_confirm'); if (!confirm(msg)) return; try { try { localStorage.removeItem(this._localStoreKey()); } catch {} // Clear in-memory schedules when using local storage only this._schedules = {}; this._profilesSelected = null; this._profilesDraft = null; this._profilesDirty = false; this._profilesEditingNew = false; this._emit(true); try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} } catch {} }); // Reset onboarding (show intro again) qs('.reset-onboard')?.addEventListener('click', async () => { try { localStorage.removeItem('thermostat_timeline_onboard_seen_v1'); } catch {} try { alert(this._t('onboard.reset_ok') || 'Welcome will be shown next time.'); } catch {} }); qs('.migrate-to-store')?.addEventListener('click', async (ev) => { try { // File-based storage only - no sensor entity IDs needed const raw = localStorage.getItem(this._localStoreKey()) || ''; if (!raw) { alert(this._t('editor.no_local_data')); return; } const ok = confirm(this._t('editor.migrate_confirm')); if (!ok) return; const parsed = JSON.parse(raw || '{}'); const schedules = parsed.schedules || parsed || {}; const weekdays = parsed.weekdays || {}; const profiles = parsed.profiles || {}; let settings = parsed.settings || { time_12h: this._config.time_12h, temp_unit: this._config.temp_unit, color_ranges: this._config.color_ranges, color_global: !!this._config.color_global, row_height: Number(this._config.row_height ?? 64), default_temp: Number(this._config.default_temp||20), min_temp: Number(this._config.min_temp ?? 5), max_temp: Number(this._config.max_temp ?? 25), away: this._config.away, merges: this._config.merges, labels: this._config.labels, temp_sensors: this._config.temp_sensors, per_room_defaults: !!(this._config.per_room_defaults ?? false), apply_on_default_change: !!(this._config.apply_on_default_change ?? true), profiles_enabled: !!this._config.profiles_enabled, holidays_enabled: !!this._config.holidays_enabled, holidays_source: this._config.holidays_source || 'calendar', holidays_entity: this._config.holidays_entity || '', holidays_dates: Array.isArray(this._config.holidays_dates) ? this._config.holidays_dates : [] }; let colors = parsed.colors || { color_ranges: settings?.color_ranges || this._config.color_ranges, color_global: typeof settings?.color_global === 'boolean' ? settings.color_global : !!this._config.color_global }; settings = { ...settings, row_height: Number(settings?.row_height ?? (this._config.row_height ?? 64)), min_temp: Number(settings?.min_temp ?? (this._config.min_temp ?? 5)), max_temp: Number(settings?.max_temp ?? (this._config.max_temp ?? 25)), apply_on_default_change: (typeof settings?.apply_on_default_change === 'boolean') ? settings.apply_on_default_change : !!(this._config.apply_on_default_change ?? true), show_pause_button: !!(this._config.show_pause_button ?? true), pause_sensor_enabled: !!(this._config.pause_sensor_enabled ?? false), pause_sensor_entity: String(this._config.pause_sensor_entity || ''), auto_apply_enabled: !!(this._config.auto_apply ?? true), pause_indef: !!this._pauseIndef, pause_until_ms: Number(this._pauseUntilMs||0) }; if (this._hass) { // Use set_store service directly - file-based storage const p = { schedules, weekdays, profiles, settings, colors }; if (this._config?.instance_enabled) { p.instance_id = this._instanceId(); p.activate = true; } await this._hass.callService('thermostat_timeline', 'set_store', p); } } catch (e) {} }); // Tabs behavior is handled via robust click delegation in _render() } _roomUsesInputNumber(idx){ try { const ent = (this._config?.entities||[])[idx] || ''; const inferred = String(ent).startsWith('input_number.'); const arr = Array.isArray(this._config?.room_use_input_number) ? this._config.room_use_input_number : []; const explicit = (typeof arr[idx] === 'boolean') ? arr[idx] : null; return (explicit === null) ? inferred : explicit; } catch { return false; } } _setRoomUseInputNumber(idx, on){ try { const ents = [...(this._config.entities||[])]; const modes = Array.isArray(this._config.room_use_input_number) ? [...this._config.room_use_input_number] : []; while (modes.length < ents.length) modes.push(false); modes[idx] = !!on; const oldEid = ents[idx]; // If existing selection doesn't match new mode, clear and clean mappings const mismatch = !!oldEid && ((!!on && !String(oldEid).startsWith('input_number.')) || (!on && !String(oldEid).startsWith('climate.'))); if (mismatch) { // Clean up labels/merges/temp_sensors for the old primary try { const labels = { ...(this._config.labels || {}) }; if (oldEid && labels[oldEid]) delete labels[oldEid]; this._config.labels = labels; } catch {} try { const merges = { ...(this._config.merges || {}) }; if (oldEid && merges[oldEid]) delete merges[oldEid]; for (const k of Object.keys(merges)) { merges[k] = (merges[k] || []).filter((e) => e !== oldEid); if (!merges[k].length) delete merges[k]; } this._config.merges = merges; } catch {} try { const sensors = { ...(this._config.temp_sensors || {}) }; if (oldEid && sensors[oldEid]) delete sensors[oldEid]; this._config.temp_sensors = sensors; } catch {} ents[idx] = ''; } this._config.entities = ents; this._config.room_use_input_number = modes; this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} } catch { this._emit(true); } } // --- Local helpers for unit/time conversions (editor context) --- _isF(){ try { return (this._config?.temp_unit||'C')==='F'; } catch { return false; } } _unitSymbol(){ return this._isF() ? '°F' : '°C'; } _toDisplayTemp(c){ // Always round display values to avoid long decimals (e.g. whole °F becomes fractional °C internally). try { const v = Number(c); if (!Number.isFinite(v)) return c; const disp = this._isF() ? (v*9/5+32) : v; return Math.round(disp * 10) / 10; } catch { return c; } } _fromDisplayTemp(v){ try { return this._isF() ? ((Number(v)-32)*5/9) : Number(v); } catch { return Number(v); } } // Force re-hydration of entity pickers on the Rooms tab with the latest hass // reference. This helps cases where the tab was hidden during first render // or after a hard refresh and the picker didn't populate. _forcePickerRefresh(){ try { const tab = this.shadowRoot && this.shadowRoot.querySelector('.tab-rooms'); if (!tab) return; const pickers = Array.from(tab.querySelectorAll('ha-entity-picker, tt-entity-picker')); for (const p of pickers){ try { p.hass = this._hass; p.style.display = 'block'; p.style.minHeight = '48px'; if (typeof p.requestUpdate === 'function') p.requestUpdate(); // Fallback: if element still has no height, poke its internal input later setTimeout(()=>{ try { if (p.offsetHeight < 8 && typeof p.requestUpdate === 'function') p.requestUpdate(); } catch {} }, 50); } catch {} } } catch {} } connectedCallback(){ this._render(); try { // Extra-robust suspend on pointer start inside any entity picker (captures from Shadow DOM) const capHandler = (e)=>{ try { const path = typeof e.composedPath === 'function' ? e.composedPath() : []; const hit = path && path.some(n => { const t = (n && n.tagName ? n.tagName.toLowerCase() : ''); return t === 'ha-entity-picker' || t === 'tt-entity-picker'; }); if (hit) this._suspendRender = true; } catch {} }; this.shadowRoot.addEventListener('pointerdown', capHandler, { capture: true }); this.shadowRoot.addEventListener('touchstart', capHandler, { capture: true, passive: true }); // Suspend render while interacting with any entity picker to avoid closing dropdowns this.shadowRoot.addEventListener('focusin', (e)=>{ try { if (e.target?.closest && (e.target.closest('ha-entity-picker') || e.target.closest('tt-entity-picker'))) this._suspendRender = true; } catch {} try { // Also suspend while editing inputs in Colors tab (prevents HA hass updates from re-rendering and killing focus) const t = e.target; if (t && t.closest && t.closest('.tab-colors')) { if (t.tagName === 'INPUT' || t.classList?.contains('remove-btn')) { this._suspendRender = true; } } } catch {} }); this.shadowRoot.addEventListener('focusout', ()=>{ // Give a tiny delay so click into next field doesn't flicker setTimeout(()=>{ this._suspendRender = false; }, 150); }); // React to explicit picker open/close signals (from tt-entity-picker) this.shadowRoot.addEventListener('picker-opened', ()=>{ try { this._suspendRender = true; } catch {} }); this.shadowRoot.addEventListener('picker-closed', ()=>{ try { this._suspendRender = false; } catch {} }); this.shadowRoot.addEventListener('value-changed', (e)=>{ try { const tag = e.target?.tagName?.toLowerCase(); if (tag === 'ha-entity-picker' || tag === 'tt-entity-picker') { this._suspendRender = false; } } catch {} }); // After hard refresh, wait for HA to define pickers and hydrate them try { customElements.whenDefined('ha-entity-picker').then(()=>{ try { this._forcePickerRefresh(); } catch {} }); } catch {} // Extra retry shortly after mount try { setTimeout(()=>{ try { this._forcePickerRefresh(); } catch {} }, 300); } catch {} } catch {} } // ---------- Storage helpers (editor context) ---------- // Note: File-based storage is always used now - no sensor entities needed // Stub function for backwards compatibility - always returns empty string _storageEntity(kind = 'schedules') { return ''; } // Debounced push of settings (labels/merges/colors/min/max/away/unit/format) to shared storage _pushSettingsToStoreDebounced(){ clearTimeout(this._storePushTimer); this._storePushTimer = setTimeout(()=>this._pushSettingsToStore(), 500); } async _pushSettingsToStore(){ try { if (!this._hass || !this._config?.storage_enabled) { return; } // IMPORTANT: The backend replaces the entire settings object when provided. // Base our write on the current stored settings to avoid wiping popup-managed keys. const resp = await this._apiFetchState(); const base = resp?.settings; if (!base || typeof base !== 'object') return; let settings = { ...base, // Editor-managed settings only (popup-managed keys are preserved from base) away: this._config.away, away_bypass: !!this._config.away_bypass, open_window: (this._config.open_window && typeof this._config.open_window === 'object') ? this._config.open_window : (base.open_window || { enabled:false, sensors:{}, open_delay_min:2, close_delay_min:5 }), entities: Array.isArray(this._config.entities) ? this._config.entities.filter(Boolean).map(String) : [], room_use_input_number: Array.isArray(this._config.room_use_input_number) ? this._config.room_use_input_number.slice() : [], room_use_temp_sensor: Array.isArray(this._config.room_use_temp_sensor) ? this._config.room_use_temp_sensor.slice() : [], merges: this._config.merges, labels: this._config.labels, temp_sensors: this._config.temp_sensors, turn_on: this._config.turn_on, backup_auto_enabled: !!this._config.backup_auto_enabled, backup_interval_min: Number(this._config.backup_interval_min||1440), holidays_enabled: !!this._config.holidays_enabled, holidays_source: this._config.holidays_source || base.holidays_source || 'calendar', holidays_entity: this._config.holidays_entity || '', holidays_dates: Array.isArray(this._config.holidays_dates) ? this._config.holidays_dates : (Array.isArray(base.holidays_dates) ? base.holidays_dates : []), holidays_groups: Array.isArray(this._config.holidays_groups) ? this._config.holidays_groups : (Array.isArray(base.holidays_groups) ? base.holidays_groups : []), sync_mode: (this._config.storage_sync_mode||base.sync_mode||'instant'), sync_delay_min: Number(this._config.storage_sync_min ?? base.sync_delay_min ?? 0), }; // Prevent accidental flip of holidays_enabled=true->false on load when nothing holiday-related changed try { const bHE = typeof base.holidays_enabled === 'boolean' ? base.holidays_enabled : undefined; const iHE = typeof settings.holidays_enabled === 'boolean' ? settings.holidays_enabled : undefined; if (bHE === true && iHE === false) { const normArr = (a)=>{ try { return Array.from(new Set((a||[]).map(String))).sort(); } catch { return []; } }; const eqDates = JSON.stringify(normArr(settings.holidays_dates)) === JSON.stringify(normArr(base.holidays_dates)); const eqSource = String(settings.holidays_source||'') === String(base.holidays_source||''); const eqEntity = String(settings.holidays_entity||'') === String(base.holidays_entity||''); const eqGroups = JSON.stringify(settings.holidays_groups||[]) === JSON.stringify(base.holidays_groups||[]); if (eqDates && eqSource && eqEntity && eqGroups) { settings = { ...settings, holidays_enabled: true }; } } } catch {} // If user hasn't toggled holidays explicitly, keep backend value try { if (!this._holidaysToggleTouched && typeof base.holidays_enabled === 'boolean') { settings = { ...settings, holidays_enabled: base.holidays_enabled }; } } catch {} const colors = { color_ranges: (this._config?.color_ranges ?? base?.color_ranges ?? {}), color_global: (typeof this._config?.color_global === 'boolean') ? this._config.color_global : !!(base?.color_global), }; // When a dedicated colors sensor is configured, avoid duplicating colors inside settings payload if (this._config?.storage_enabled && this._storageEntity('colors')) { delete settings.color_ranges; delete settings.color_global; } else { settings.color_ranges = colors.color_ranges; settings.color_global = colors.color_global; } const p = { settings, colors }; if (this._config?.instance_enabled) { p.instance_id = this._instanceId(); p.activate = true; } await this._hass.callService('thermostat_timeline','set_store', p); } catch (err) {} } // Replace this editor element with a fresh instance (simulates closing/reopening) _hardReloadEditor(){ try { const cfg = JSON.parse(JSON.stringify(this._config || {})); const hass = this._hass; const parent = this.parentNode || (this.getRootNode && this.getRootNode().host && this.getRootNode().host.parentNode) || null; const next = this.nextSibling || null; const fresh = document.createElement('thermostat-timeline-card-editor'); try { fresh.setConfig(cfg); } catch {} try { if (hass) fresh.hass = hass; } catch {} if (parent) { parent.insertBefore(fresh, next); parent.removeChild(this); } else { // Fallback: force re-render + picker refresh this._render(); this._forcePickerRefresh && this._forcePickerRefresh(); } } catch (e) { try { this._render(); this._forcePickerRefresh && this._forcePickerRefresh(); } catch {} } } // Full dashboard reload with cache-busting query param _hardReloadPage(){ try { const url = new URL(window.location.href); url.searchParams.set('ttcb', String(Date.now())); window.location.replace(url.toString()); } catch { try { window.location.reload(); } catch {} } } _normalizeImportedStoreFile(obj){ // Accept either raw data object ({schedules,...}) or full HA .storage wrapper ({key,data:{...}}). const isObj = (v)=>!!v && typeof v === 'object' && !Array.isArray(v); const root = isObj(obj) ? obj : null; const data = (root && isObj(root.data)) ? root.data : root; if (!isObj(data)) return null; const schedules = data.schedules; if (!isObj(schedules)) return null; const out = { schedules }; if (isObj(data.settings)) out.settings = data.settings; if (isObj(data.weekdays)) out.weekdays = data.weekdays; if (isObj(data.profiles)) out.profiles = data.profiles; if (isObj(data.colors)) out.colors = data.colors; return out; } async _importThermostatTimelineJsonFile(file){ const safeMsg = (e)=>{ try { return String(e?.message || e || ''); } catch { return 'Error'; } }; this._importStoreError = ''; if (!file) return; try { if (!this._hass) throw new Error((this._t && (this._t('backup.import.err_no_hass') || 'Home Assistant not ready')) || 'Home Assistant not ready'); if (!this._config?.storage_enabled) throw new Error((this._t && (this._t('backup.import.err_storage_off') || 'Shared storage is OFF. Turn it on first.')) || 'Shared storage is OFF. Turn it on first.'); const hasService = !!(this._hass?.services && this._hass.services['thermostat_timeline']); if (!hasService) throw new Error((this._t && (this._t('backup.import.err_no_integration') || 'Integration thermostat_timeline not available')) || 'Integration thermostat_timeline not available'); this._importStoreBusy = true; try { this._render(); } catch {} let parsed; try { const text = await file.text(); parsed = JSON.parse(text); } catch { throw new Error((this._t && (this._t('backup.import.err_json') || 'Invalid JSON')) || 'Invalid JSON'); } const store = this._normalizeImportedStoreFile(parsed); if (!store) throw new Error(this._t ? (this._t('backup.import.err_invalid') || 'Invalid file') : 'Invalid file'); // 1) Create a new backup slot of the current store await this._hass.callService('thermostat_timeline', 'backup_now', {}); // Give HA a moment to persist the backup await new Promise((res)=>setTimeout(res, 600)); // 2) Overwrite store with imported data const payload = { force: true, schedules: store.schedules }; if (store.settings) payload.settings = store.settings; if (store.weekdays) payload.weekdays = store.weekdays; if (store.profiles) payload.profiles = store.profiles; if (store.colors) payload.colors = store.colors; if (this._config?.instance_enabled) { payload.instance_id = this._instanceId(); payload.activate = true; } await this._hass.callService('thermostat_timeline', 'set_store', payload); // 3) Sync editor UI with imported settings (best-effort) try { const s = store.settings; if (s && Array.isArray(s.entities)) { const ents = s.entities.filter(Boolean).map(String); this._config.entities = ents; const rawModes = Array.isArray(s.room_use_input_number) ? s.room_use_input_number.map(v=>!!v) : []; while (rawModes.length < ents.length) rawModes.push(false); this._config.room_use_input_number = ents.map((eid, idx)=>{ if (String(eid).startsWith('input_number.')) return true; if (String(eid).startsWith('climate.')) return false; return !!rawModes[idx]; }); this._emit(true); } } catch {} // 4) Update local browser cache and refresh open cards try { const st = await this._apiFetchState(); if (st && typeof st === 'object') { localStorage.setItem(this._localStoreKey(), JSON.stringify({ schedules: st.schedules || {}, weekdays: st.weekdays || {}, profiles: st.profiles || {}, settings: st.settings || {}, colors: st.colors || {}, })); } } catch {} try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} // Re-render backup tab so the new slot shows up await new Promise((res)=>setTimeout(res, 600)); try { this._render(); } catch {} } catch (e) { this._importStoreError = safeMsg(e); try { this._render(); } catch {} } finally { this._importStoreBusy = false; try { this._render(); } catch {} } } _render(){ if(!this._hass || !this.shadowRoot || !this._config) return; if(this._openCount > 0) return; // Boiler tab was migrated into the in-card settings popup if (this._activeTab === 'boiler') this._activeTab = 'settings'; // Open Window Detection tab was migrated into the in-card settings popup if (this._activeTab === 'owd') this._activeTab = 'settings'; // Colors tab was migrated into the in-card settings popup if (this._activeTab === 'colors') this._activeTab = 'settings'; // Holidays tab was migrated into the in-card settings popup if (this._activeTab === 'holidays') this._activeTab = 'settings'; // Away tab was migrated into the in-card settings popup if (this._activeTab === 'away') this._activeTab = 'settings'; // Rooms tab was migrated into the in-card settings popup if (this._activeTab === 'rooms') this._activeTab = 'settings'; // If HA hasn't registered core editor elements yet (after hard refresh), // re-render once they are defined so pickers/textfields upgrade properly. try { if (!customElements.get('ha-entity-picker')) { customElements.whenDefined('ha-entity-picker').then(()=>{ try { this._render(); } catch {} }); } if (!customElements.get('ha-textfield')) { customElements.whenDefined('ha-textfield').then(()=>{ try { this._render(); } catch {} }); } } catch {} // Robust tab delegation (ensure any tab can be opened reliably) if (!this._tabHandlersBound) { this._tabHandlersBound = true; try { this.shadowRoot.addEventListener('click', (ev)=>{ // Shadow DOM can yield a Text node as event.target; use composedPath() // to find the actual button element. const path = typeof ev.composedPath === 'function' ? ev.composedPath() : []; let el = null; for (const n of path) { if (n && n.classList && n.classList.contains('tab-btn')) { el = n; break; } } if (!el) { const t = (ev.target && ev.target.closest) ? ev.target : null; el = t && t.closest ? t.closest('.tab-btn') : null; } if (!el) return; if (el.classList.contains('tab-settings-btn')) { this._activeTab = 'settings'; this._render(); } if (el.classList.contains('tab-backup-btn')) { this._activeTab = 'backup'; this._render(); } }); } catch {} } const t = this.shadowRoot.querySelector(".title"); // Note: storage entity pickers removed - file-based storage is always used const seEn = this.shadowRoot.querySelector('.store-enable'); const hol = this.shadowRoot.querySelector('.holidays-enable'); if (t) t.value = this._config.title ?? ""; if (seEn) { const enabled = !!this._config.storage_enabled; seEn.checked = enabled; // Note: storage entity pickers removed - file-based storage is always used // Ensure controls visibility matches toggle try { const ctrls = this.shadowRoot.querySelector('.store-controls'); if (ctrls) ctrls.style.display = enabled ? 'flex' : 'none'; } catch {} // Sync controls removed (instant-only via Save button) // Reflect state for bottom "Clear local only" button try { const clrLocal = this.shadowRoot.querySelector('.clear-local-only'); if (clrLocal) { clrLocal.disabled = enabled; clrLocal.setAttribute('aria-disabled', enabled ? 'true' : 'false'); clrLocal.title = enabled ? 'Disabled while shared storage is ON' : ''; // Hide when shared storage is enabled clrLocal.style.display = enabled ? 'none' : ''; } } catch {} } this._applyEditorI18n(); if (hol){ hol.checked = !!this._config.holidays_enabled; } // Note: storage entity pickers removed - file-based storage is always used // Backup tab controls try { const bAuto = this.shadowRoot.querySelector('.backup-auto'); const bMin = this.shadowRoot.querySelector('.backup-days'); const bRow = this.shadowRoot.querySelector('.backup-interval-row'); const bBtn = this.shadowRoot.querySelector('.btn-backup-now'); const rBtn = this.shadowRoot.querySelector('.btn-restore-now'); const dBtn = this.shadowRoot.querySelector('.btn-delete-backup'); const impInput = this.shadowRoot.querySelector('.backup-import-file'); const impBtn = this.shadowRoot.querySelector('.btn-import-store'); const impErr = this.shadowRoot.querySelector('.backup-import-error'); const slotSel = this.shadowRoot.querySelector('.backup-slot-select'); const lastDesc = this.shadowRoot.querySelector('.last-backup-desc'); if (bAuto) bAuto.checked = !!this._config.backup_auto_enabled; if (bRow) bRow.style.display = (!!this._config.backup_auto_enabled) ? '' : 'none'; if (bMin && bMin !== this.shadowRoot.activeElement) bMin.value = String(this._config.backup_interval_days || 1); // Import UI state try { const msg = String(this._importStoreError || ''); if (impErr) { impErr.textContent = msg; impErr.style.display = msg ? '' : 'none'; } if (impBtn) impBtn.disabled = !!this._importStoreBusy; if (impInput) impInput.disabled = !!this._importStoreBusy; } catch {} // No master toggle; all individual checkboxes remain enabled const fmt = (iso)=>{ try { if(!iso) return ''; const d=new Date(iso); const lang = ttGetLangFromHass(this._hass || this._lang || 'en'); const opts={ dateStyle:'medium', timeStyle:'short', hour12: !!this._config?.time_12h }; if (this._config?.time_source==='ha' && this._hass?.config?.time_zone) opts.timeZone=this._hass.config.time_zone; return new Intl.DateTimeFormat(lang, opts).format(d); } catch { return String(iso||''); } }; // Fetch backup info from API const fetchBackupInfo = async ()=>{ try { if (!this._apiSupported?.()) return null; return await this._hass.callApi('GET', 'thermostat_timeline/state'); } catch { return null; } }; (async ()=>{ const api = await fetchBackupInfo(); if (lastDesc && api){ const ts = api?.backup?.last_backup_ts; lastDesc.textContent = ts ? fmt(ts) : ''; } if (slotSel && api){ const prevSel = slotSel.value; const slots = Array.isArray(api?.backup?.slots) ? api.backup.slots : []; const opts=[]; slots.forEach((entry, i)=>{ if(!entry) return; const ts = entry.ts || entry.created_at || entry.created || null; const labelTs = ts ? fmt(ts) : ''; let label = labelTs || 'Backup'; try { if (entry.imported) { const suf = (this._t && this._t('backup.imported_suffix')) || ' - imported'; label += suf; } } catch {} opts.push({value:String(i+1), label}); }); slotSel.innerHTML=''; const newOpt = document.createElement('option'); newOpt.value = 'new'; newOpt.textContent = (this._t && this._t('backup.new')) ? this._t('backup.new') : 'New backup'; slotSel.append(newOpt); opts.forEach((o)=>{ const opt=document.createElement('option'); opt.value=o.value; opt.textContent=o.label; slotSel.append(opt); }); // Restore previous selection if still valid, else default to first backup (if any) otherwise new const validValues = new Set(['new', ...opts.map(o=>o.value)]); let targetVal = 'new'; if (prevSel && validValues.has(prevSel)) { targetVal = prevSel; } else if (opts.length > 0) { targetVal = opts[0].value; } slotSel.value = targetVal; const applyStates = ()=>{ const val = slotSel.value; const isNew = (val === 'new' || !val); const setState = (btn, disabled)=>{ if (!btn) return; btn.disabled = !!disabled; btn.style.opacity = disabled ? '0.55' : ''; btn.style.pointerEvents = disabled ? 'none' : ''; btn.style.filter = disabled ? 'grayscale(0.35)' : ''; }; setState(rBtn, isNew); setState(dBtn, isNew); setState(bBtn, !isNew); }; applyStates(); slotSel.onchange = applyStates; } })(); bBtn && (bBtn.onclick = async ()=>{ try { // Always full backup await this._hass.callService('thermostat_timeline','backup_now', {}); // Note: sensor update removed - file-based storage is used // give HA a moment to persist await new Promise(res=>setTimeout(res, 800)); setTimeout(()=>{ try { this._render(); } catch{} }, 1200); } catch {} }); rBtn && (rBtn.onclick = async ()=>{ try { const slot = Number(this.shadowRoot.querySelector('.backup-slot-select')?.value || '0') || undefined; if (confirm((this._t && this._t('backup.restore.confirm_overwrite')) || 'Restore from backup? This will overwrite current schedules and settings.')) { const payload = { mode: 'replace' }; if (slot) payload.slot = slot; await this._hass.callService('thermostat_timeline','restore_now', payload); setTimeout(()=>{ try { this._render(); } catch{} }, 800); } } catch {} }); dBtn && (dBtn.onclick = async ()=>{ try { const slot = Number(this.shadowRoot.querySelector('.backup-slot-select')?.value || '0') || undefined; if (!slot) return; const msg = this._t ? this._t('backup.delete.confirm') : null; if (!confirm(msg || 'Delete selected backup?')) return; await this._hass.callService('thermostat_timeline','delete_backup', { slot }); setTimeout(()=>{ try { this._render(); } catch{} }, 800); } catch {} }); // Import from thermostat_timeline.json file const runImport = async ()=>{ try { const file = impInput?.files?.[0]; await this._importThermostatTimelineJsonFile(file); } finally { try { if (impInput) impInput.value = ''; } catch {} } }; if (impBtn) impBtn.onclick = runImport; if (impInput) impInput.onchange = runImport; // Import full backup from file (thermostat_timeline_backup.json) const impBkBtn = this.shadowRoot.querySelector('.btn-import-backup'); const impBkInput = this.shadowRoot.querySelector('.backup-importbk-file'); const impBkErr = this.shadowRoot.querySelector('.backup-importbk-error'); const impBkRow = this.shadowRoot.querySelector('.backup-file-select-row'); const impBkSel = this.shadowRoot.querySelector('.backup-file-slot-select'); const impBkAddSel = this.shadowRoot.querySelector('.btn-import-slot'); const impBkAddAll = this.shadowRoot.querySelector('.btn-import-all'); const impBkStatus = this.shadowRoot.querySelector('.backup-importbk-status'); const runImportBackup = async ()=>{ try { if (impBkErr) { impBkErr.style.display='none'; impBkErr.textContent=''; } const file = impBkInput?.files?.[0]; if (!file) return; const text = await file.text(); let obj = null; try { obj = JSON.parse(text); } catch { obj = null; } if (!obj) throw new Error('json'); let data = obj?.data || obj; let slots = Array.isArray(data?.slots) ? data.slots : null; if (!slots) { const hasSections = (data && (data.schedules || data.settings || data.weekdays || data.profiles)); if (hasSections) { slots = [{ ts: new Date().toISOString(), sections: { schedules: data.schedules||{}, settings: data.settings||{}, weekdays: data.weekdays||{}, profiles: data.profiles||{}, colors: data.colors||{} }, partial_flags: data.partial_flags||null, imported: true }]; } } if (!Array.isArray(slots) || !slots.length) throw new Error('invalid'); // Keep in memory for user selection this._pendingImportSlots = slots.map((s)=>{ try { return { ...s, imported: true }; } catch { return s; } }); if (impBkSel){ impBkSel.innerHTML = ''; const labels = []; for (let i=0;i{ try { const idx = Number(impBkSel?.value||'0')-1; if (!this._pendingImportSlots || idx<0 || idx>=this._pendingImportSlots.length) return; await this._hass.callService('thermostat_timeline','import_backup', { slots: [ this._pendingImportSlots[idx] ] }); if (impBkStatus){ try { const t = (this._t && this._t('backup.file.imported_ok')) || 'Imported ({n})'; impBkStatus.textContent = t.replace('{n}', '1'); impBkStatus.style.display = ''; } catch {} } setTimeout(()=>{ try { this._render(); } catch{} }, 800); } catch {} }; if (impBkAddAll) impBkAddAll.onclick = async ()=>{ try { if (!Array.isArray(this._pendingImportSlots) || this._pendingImportSlots.length===0) return; await this._hass.callService('thermostat_timeline','import_backup', { slots: this._pendingImportSlots }); if (impBkStatus){ try { const n = this._pendingImportSlots.length; const t = (this._t && this._t('backup.file.imported_ok')) || 'Imported ({n})'; impBkStatus.textContent = t.replace('{n}', String(n)); impBkStatus.style.display = ''; } catch {} } setTimeout(()=>{ try { this._render(); } catch{} }, 800); } catch {} }; // Export full backup (thermostat_timeline_backup.json) const expBtn = this.shadowRoot.querySelector('.btn-export-backup'); if (expBtn) expBtn.onclick = async ()=>{ try { const resp = await this._apiFetchState(); const bk = (resp && resp.backup) ? resp.backup : {}; const data = { schedules: bk.schedules || {}, settings: bk.settings || {}, weekdays: bk.weekdays || {}, profiles: bk.profiles || {}, version: bk.version || 1, last_backup_ts: bk.last_backup_ts || null, partial_flags: bk.partial_flags || null, slots: Array.isArray(bk.slots) ? bk.slots : [], slot_index: Number(bk.slot_index || 0), }; const wrapper = { version: 1, minor_version: 1, key: 'thermostat_timeline_backup.json', data, }; const nameTs = (new Date()).toISOString().replace(/[:T]/g,'-').slice(0,19); const fname = `thermostat_timeline_backup_${nameTs}.json`; const blob = new Blob([JSON.stringify(wrapper, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fname; document.body.appendChild(a); a.click(); setTimeout(()=>{ try{ URL.revokeObjectURL(url); a.remove(); }catch{} }, 1500); } catch (e) { // best-effort; no UI error noise } }; bAuto && bAuto.addEventListener('change', (e)=>{ const on=!!e.target.checked; this._upd('backup_auto_enabled', on); if (bRow) bRow.style.display = on? '' : 'none'; try { this._pushSettingsToStoreDebounced(); } catch {} }); bMin && bMin.addEventListener('change', (e)=>{ let v=Math.round(Number(e.target.value||1)); if(!Number.isFinite(v)) v=1; v=Math.max(1, Math.min(365, v)); e.target.value=String(v); this._upd('backup_interval_days', v); try { this._pushSettingsToStoreDebounced(); } catch {} }); } catch {} // No mode dropdown in editor const wrap = this.shadowRoot.querySelector(".entities"); const want = (this._config.entities || []).length; if (wrap) { // Rebuild rows; preserve which are open using a stable key per row (entity id or placeholder) wrap.innerHTML = ''; for (let i=0;i { try { const id = st?.entity_id || st?.entityId || st?.entityID || ""; if (!id) return false; const chosenPrim = (this._config.entities || []).filter(Boolean); const allLinked = Object.values(this._config.merges || {}).flat(); const blocked = new Set([...chosenPrim, ...allLinked]); const current = this._config.entities[i]; return !blocked.has(id) || id === current; } catch { return true; } }; } // Update header title and label input to reflect current selection/override try { const eid = this._config.entities[i] || ""; const labels = this._config.labels || {}; const titleEl = row.querySelector('.summary-title'); const subEl = row.querySelector('.summary-sub'); const nameInp = row.querySelector('.label-input'); const friendly = eid ? (this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1] || eid)) : ''; const shown = labels[eid] || friendly || this._t('editor.entity_placeholder'); if (titleEl) titleEl.textContent = shown; if (nameInp && nameInp !== this.shadowRoot.activeElement) nameInp.value = labels[eid] || ''; // Build merged subtitle text if (subEl) { const linked = (this._config.merges?.[eid] || []).map(id => ( this._hass?.states?.[id]?.attributes?.friendly_name || (id.split('.')[1] || id) )); const parts = []; if (eid) parts.push(eid); if (linked.length) parts.push(`${this._t('editor.merged_with')}: ${linked.join(', ')}`); subEl.textContent = parts.join(' • '); } } catch {} // Rebuild merge chips every render try { const eid = this._config.entities[i] || ''; const chips = row.querySelector('.linked-chips'); const mergePicker = row.querySelector('.merge-picker'); if (mergePicker) mergePicker.hass = this._hass; if (chips && eid){ chips.innerHTML = ''; const links = (this._config.merges?.[eid] || []); for (const l of links){ const chip = document.createElement('span'); chip.className = 'pill-chip'; const txt = document.createElement('span'); txt.textContent = this._hass?.states?.[l]?.attributes?.friendly_name || (l.split('.')[1]||l); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'rm'; rm.textContent = '×'; rm.setAttribute('aria-label', this._t('editor.remove')); rm.addEventListener('click', ()=>{ const merges = { ...(this._config.merges || {}) }; merges[eid] = (merges[eid]||[]).filter(x=>x!==l); if (!merges[eid]?.length) delete merges[eid]; this._config.merges = merges; this._emit(true); }); chip.append(txt, rm); chips.append(chip); } } // (colors tab content and visibility now rendered once outside the entity loop) } catch {} } } // Build colors tab content (always render once, even with zero rooms) // Build colors tab content (always render once, even with zero rooms) try { const root = this.shadowRoot.querySelector('.tab-colors'); if (root) { root.innerHTML = ''; // Colors mode toggle (Per room | All rooms) const modeWrap = document.createElement('div'); modeWrap.style.display = 'flex'; modeWrap.style.alignItems = 'center'; modeWrap.style.justifyContent = 'space-between'; modeWrap.style.gap = '10px'; modeWrap.style.marginBottom = '6px'; const modeText = document.createElement('div'); modeText.style.display = 'grid'; modeText.style.gap = '2px'; const modeTitle = document.createElement('div'); modeTitle.className = 'colors-mode-title'; modeTitle.style.fontWeight = '600'; modeTitle.textContent = this._t('editor.colors.mode') || 'Color mode'; const modeDesc = document.createElement('div'); modeDesc.className = 'colors-mode-desc'; modeDesc.style.fontSize = '.85rem'; modeDesc.style.color = 'var(--secondary-text-color)'; modeDesc.textContent = this._t('editor.colors.mode.desc') || 'Choose per-room colors or one set for all rooms.'; modeText.append(modeTitle, modeDesc); const seg = document.createElement('div'); seg.className = 'seg colors-mode'; const bRoom = document.createElement('button'); bRoom.type = 'button'; bRoom.className = 'mode-room'; bRoom.textContent = this._t('editor.colors.mode_per_room') || 'Per room'; const bGlob = document.createElement('button'); bGlob.type = 'button'; bGlob.className = 'mode-global'; bGlob.textContent = this._t('editor.colors.mode_global') || 'All rooms'; const applySegUi = ()=>{ const on = !!this._config.color_global; bGlob.classList.toggle('active', on); bRoom.classList.toggle('active', !on); }; bRoom.onclick = ()=>{ this._config.color_global = false; this._emit(true); this._pushSettingsToStoreDebounced(); }; bGlob.onclick = ()=>{ this._config.color_global = true; this._emit(true); this._pushSettingsToStoreDebounced(); }; seg.append(bRoom, bGlob); applySegUi(); modeWrap.append(modeText, seg); root.append(modeWrap); const isGlobal = !!this._config.color_global; // If per-room mode and there are no rooms yet, show helper box if (!isGlobal && want === 0) { const box = document.createElement('div'); box.className = 'empty-box'; const p = document.createElement('div'); p.textContent = this._t('editor.colors.empty') || 'No rooms added yet.'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'add-entity-btn'; btn.innerHTML = '' + (this._t('editor.colors.go_add') || 'Add room') + ''; btn.onclick = () => { this._activeTab = 'rooms'; this._render(); const add = this.shadowRoot.querySelector('.add-room-btn'); if (add) { add.classList.add('pulse-attn','pulse'); setTimeout(() => add.classList.remove('pulse'), 3500); add.scrollIntoView({ behavior:'smooth', block:'center' }); } }; box.append(p, btn); root.append(box); } // --- FIXED: GLOBAL EDITOR --- if (isGlobal) { const eid = '*'; const line = document.createElement('div'); line.className = 'inline'; const summary = document.createElement('div'); summary.className = 'summary'; const sumLeft = document.createElement('div'); sumLeft.className = 'summary-left'; const exp = document.createElement('button'); exp.className = 'expander'; exp.innerHTML = ''; const txtWrap = document.createElement('div'); txtWrap.className = 'summary-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'summary-title'; titleSpan.textContent = this._t('editor.colors.global_title') || 'All rooms'; txtWrap.append(titleSpan); sumLeft.append(exp, txtWrap); summary.append(sumLeft); line.append(summary); const details = document.createElement('div'); details.className = 'details'; line.append(details); const colorsWrap = document.createElement('div'); colorsWrap.style.display = 'grid'; colorsWrap.style.gap = '0px'; details.append(colorsWrap); const head = document.createElement('div'); head.className = 'colors-head'; head.innerHTML = `${this._t('editor.colors.col_from')}${this._t('editor.colors.col_to')}${this._t('editor.colors.col_color')}`; try { if (this._openColorRows.has(eid)) line.classList.add('open'); } catch {} const renderRanges = () => { colorsWrap.innerHTML = ''; // Get current list (treat as read-only) const ranges = (this._config.color_ranges?.[eid]) || []; if (ranges.length > 0) colorsWrap.append(head); ranges.forEach((r, ri) => { const outer = document.createElement('div'); outer.className = 'color-item'; const row = document.createElement('div'); row.className = 'colors-row'; const f = document.createElement('input'); f.type = 'number'; f.step = '0.5'; f.value = String(this._toDisplayTemp(r.from ?? 0)); f.setAttribute('aria-label', this._t('editor.colors.col_from')); f.title = this._t('editor.colors.col_from'); const t = document.createElement('input'); t.type = 'number'; t.step = '0.5'; t.value = String(this._toDisplayTemp(r.to ?? 0)); t.setAttribute('aria-label', this._t('editor.colors.col_to')); t.title = this._t('editor.colors.col_to'); const c = document.createElement('input'); c.type = 'color'; c.value = r.color || '#ffb347'; c.setAttribute('aria-label', this._t('editor.colors.col_color')); c.title = this._t('editor.colors.col_color'); const maxDisp = this._toDisplayTemp(this._config.max_temp ?? 25); const minDisp = this._toDisplayTemp(this._config.min_temp ?? 5); f.min = String(minDisp); t.min = String(minDisp); f.max = String(maxDisp); t.max = String(maxDisp); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'remove-btn'; rm.innerHTML = '' + (this._t('editor.remove') || 'Remove') + ''; const repaint = ()=>{ this._emit(true); try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} try { document.querySelectorAll('thermostat-timeline-card').forEach(el=>el?.devRefreshFromEditor?.()); } catch {} }; // --- SAFE IMMUTABLE UPDATES --- f.onchange = () => { let v=parseFloat(f.value); if (isNaN(v)) v=minDisp; v=Math.max(minDisp, Math.min(maxDisp, v)); f.value=String(v); // Copy array const copied_config = [...(this._config.color_ranges?.[eid] || [])]; // Copy object and update if(copied_config[ri]) copied_config[ri] = { ...copied_config[ri], from: this._fromDisplayTemp(v) }; this._config.color_ranges = { ...(this._config.color_ranges||{}), [eid]: copied_config }; repaint(); }; t.onchange = () => { let v=parseFloat(t.value); if (isNaN(v)) v=minDisp; v=Math.max(minDisp, Math.min(maxDisp, v)); t.value=String(v); const copied_config = [...(this._config.color_ranges?.[eid] || [])]; if(copied_config[ri]) copied_config[ri] = { ...copied_config[ri], to: this._fromDisplayTemp(v) }; this._config.color_ranges = { ...(this._config.color_ranges||{}), [eid]: copied_config }; repaint(); }; c.onchange = () => { const copied_config = [...(this._config.color_ranges?.[eid] || [])]; if(copied_config[ri]) copied_config[ri] = { ...copied_config[ri], color: c.value }; this._config.color_ranges = { ...(this._config.color_ranges||{}), [eid]: copied_config }; repaint(); }; rm.onclick = () => { const copied_config = [...(this._config.color_ranges?.[eid] || [])]; copied_config.splice(ri, 1); this._config.color_ranges = { ...(this._config.color_ranges||{}), [eid]: copied_config }; this._emit(true); renderRanges(); }; // ----------------------------- row.append(f, t, c, rm); outer.append(row); colorsWrap.append(outer); }); const add = document.createElement('button'); add.className = 'add-entity-btn'; add.type = 'button'; add.innerHTML = '' + (this._t('editor.heat_colors.add') || 'Add color range') + ''; add.onclick = () => { const arr = [...(this._config.color_ranges?.[eid] || [])]; arr.push({ from: this._config.default_temp || 20, to: this._config.default_temp || 20, color: '#ffb347' }); this._config.color_ranges = { ...(this._config.color_ranges || {}), [eid]: arr }; this._emit(true); renderRanges(); }; colorsWrap.append(add); }; renderRanges(); exp.onclick = () => { line.classList.toggle('open'); const key = eid; if (line.classList.contains('open')) this._openColorRows.add(key); else this._openColorRows.delete(key); }; root.append(line); } // --- FIXED: PER-ROOM EDITOR --- for (let i = 0; !isGlobal && i < want; i++) { const eid = this._config.entities[i]; if (!eid) continue; const line = document.createElement('div'); line.className = 'inline'; const summary = document.createElement('div'); summary.className = 'summary'; const sumLeft = document.createElement('div'); sumLeft.className = 'summary-left'; const exp = document.createElement('button'); exp.className = 'expander'; exp.innerHTML = ''; const txtWrap = document.createElement('div'); txtWrap.className = 'summary-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'summary-title'; try { const labels = this._config.labels || {}; const display = labels[eid] || this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1] || eid); titleSpan.textContent = display; } catch { titleSpan.textContent = this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1] || eid); } txtWrap.append(titleSpan); sumLeft.append(exp, txtWrap); summary.append(sumLeft); line.append(summary); const details = document.createElement('div'); details.className = 'details'; line.append(details); const colorsWrap = document.createElement('div'); colorsWrap.style.display = 'grid'; colorsWrap.style.gap = '0px'; details.append(colorsWrap); const head = document.createElement('div'); head.className = 'colors-head'; head.innerHTML = `${this._t('editor.colors.col_from')}${this._t('editor.colors.col_to')}${this._t('editor.colors.col_color')}`; try { if (this._openColorRows.has(eid)) line.classList.add('open'); } catch {} const renderRanges = () => { colorsWrap.innerHTML = ''; // Get current list (treat as read-only) const ranges = (this._config.color_ranges?.[eid]) || []; if (ranges.length > 0) colorsWrap.append(head); ranges.forEach((r, ri) => { const outer = document.createElement('div'); outer.className = 'color-item'; const row = document.createElement('div'); row.className = 'colors-row'; const f = document.createElement('input'); f.type = 'number'; f.step = '0.5'; f.value = String(this._toDisplayTemp(r.from ?? 0)); f.setAttribute('aria-label', this._t('editor.colors.col_from')); f.title = this._t('editor.colors.col_from'); const t = document.createElement('input'); t.type = 'number'; t.step = '0.5'; t.value = String(this._toDisplayTemp(r.to ?? 0)); t.setAttribute('aria-label', this._t('editor.colors.col_to')); t.title = this._t('editor.colors.col_to'); const c = document.createElement('input'); c.type = 'color'; c.value = r.color || '#ffb347'; c.setAttribute('aria-label', this._t('editor.colors.col_color')); c.title = this._t('editor.colors.col_color'); const maxDisp = this._toDisplayTemp(this._config.max_temp ?? 25); const minDisp = this._toDisplayTemp(this._config.min_temp ?? 5); f.min = String(minDisp); t.min = String(minDisp); f.max = String(maxDisp); t.max = String(maxDisp); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'remove-btn'; rm.innerHTML = '' + (this._t('editor.remove') || 'Remove') + ''; const repaint = ()=>{ this._emit(true); try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} try { document.querySelectorAll('thermostat-timeline-card').forEach(el=>el?.devRefreshFromEditor?.()); } catch {} }; // --- SAFE IMMUTABLE UPDATES --- f.onchange = () => { let v=parseFloat(f.value); if (isNaN(v)) v=minDisp; v=Math.max(minDisp, Math.min(maxDisp, v)); f.value=String(v); const copied_config = [...(this._config.color_ranges?.[eid] || [])]; if(copied_config[ri]) copied_config[ri] = { ...copied_config[ri], from: this._fromDisplayTemp(v) }; this._config.color_ranges = { ...(this._config.color_ranges||{}), [eid]: copied_config }; repaint(); }; t.onchange = () => { let v=parseFloat(t.value); if (isNaN(v)) v=minDisp; v=Math.max(minDisp, Math.min(maxDisp, v)); t.value=String(v); const copied_config = [...(this._config.color_ranges?.[eid] || [])]; if(copied_config[ri]) copied_config[ri] = { ...copied_config[ri], to: this._fromDisplayTemp(v) }; this._config.color_ranges = { ...(this._config.color_ranges||{}), [eid]: copied_config }; repaint(); }; c.onchange = () => { const copied_config = [...(this._config.color_ranges?.[eid] || [])]; if(copied_config[ri]) copied_config[ri] = { ...copied_config[ri], color: c.value }; this._config.color_ranges = { ...(this._config.color_ranges||{}), [eid]: copied_config }; repaint(); }; rm.onclick = () => { const copied_config = [...(this._config.color_ranges?.[eid] || [])]; copied_config.splice(ri, 1); this._config.color_ranges = { ...(this._config.color_ranges||{}), [eid]: copied_config }; this._emit(true); renderRanges(); }; // ----------------------------- row.append(f, t, c, rm); outer.append(row); colorsWrap.append(outer); }); const add = document.createElement('button'); add.className = 'add-entity-btn'; add.type = 'button'; add.innerHTML = '' + (this._t('editor.heat_colors.add') || 'Add color range') + ''; add.onclick = () => { const arr = [...(this._config.color_ranges?.[eid] || [])]; arr.push({ from: this._config.default_temp || 20, to: this._config.default_temp || 20, color: '#ffb347' }); this._config.color_ranges = { ...(this._config.color_ranges || {}), [eid]: arr }; this._emit(true); renderRanges(); }; colorsWrap.append(add); }; renderRanges(); exp.onclick = () => { line.classList.toggle('open'); const key = eid; if (line.classList.contains('open')) this._openColorRows.add(key); else this._openColorRows.delete(key); }; root.append(line); } // Add explicit refresh/apply button for colors const refresh = document.createElement('button'); refresh.className = 'add-entity-btn'; refresh.type = 'button'; refresh.innerHTML = '' + (this._t('editor.colors.refresh') || 'Update schedule') + ''; refresh.onclick = async () => { // Persist color settings to local/shared storage so live card instances pick them up try { let settings = { time_12h: this._config.time_12h, temp_unit: this._config.temp_unit, time_source: this._config.time_source, row_height: Number(this._config.row_height ?? 64), default_temp: Number(this._config.default_temp || 20), min_temp: Number(this._config.min_temp ?? 5), max_temp: Number(this._config.max_temp ?? 25), per_room_defaults: !!(this._config.per_room_defaults ?? false), apply_on_edit: !!(this._config.apply_on_edit ?? true), apply_on_default_change: !!(this._config.apply_on_default_change ?? true), color_ranges: this._config.color_ranges, color_global: !!this._config.color_global, away: this._config.away, merges: this._config.merges, labels: this._config.labels, temp_sensors: this._config.temp_sensors, profiles_enabled: !!this._config.profiles_enabled, holidays_enabled: !!this._config.holidays_enabled, holidays_source: this._config.holidays_source || 'calendar', holidays_entity: this._config.holidays_entity || '', holidays_dates: Array.isArray(this._config.holidays_dates) ? this._config.holidays_dates : [], sync_mode: (this._config.storage_sync_mode||'instant'), sync_delay_min: Number(this._config.storage_sync_min||0) }; try { if (!this._holidaysToggleTouched) { const st = await this._apiFetchState?.(); const base = st?.settings; if (typeof base?.holidays_enabled === 'boolean') settings.holidays_enabled = base.holidays_enabled; else delete settings.holidays_enabled; } } catch {} const colors = { color_ranges: this._config.color_ranges, color_global: !!this._config.color_global }; let schedules = {}; // Prefer current file storage schedules when enabled - use API try { if (this._config?.storage_enabled && this._apiSupported?.()) { const resp = await this._apiFetchState?.(); if (resp && typeof resp === 'object' && resp.schedules) { schedules = JSON.parse(JSON.stringify(resp.schedules)); } } } catch {} // Fallback to localStorage copy if (!schedules || Object.keys(schedules).length===0){ try { const raw = localStorage.getItem(this._localStoreKey()) || ''; const parsed = JSON.parse(raw||'{}'); schedules = parsed.schedules || {}; // Unwrap if accidentally saved nested if (schedules && schedules.schedules && typeof schedules.schedules === 'object') schedules = schedules.schedules; } catch {} } // Write local copy try { localStorage.setItem(this._localStoreKey(), JSON.stringify({ schedules, settings: { ...settings, show_pause_button: !!(this._config.show_pause_button ?? true), show_room_temp: !!(this._config.show_room_temp ?? true), pause_sensor_enabled: !!(this._config.pause_sensor_enabled ?? false), pause_sensor_entity: String(this._config.pause_sensor_entity || ''), auto_apply_enabled: !!(this._config.auto_apply ?? true), pause_indef: !!this._pauseIndef, pause_until_ms: Number(this._pauseUntilMs||0) }, colors })); } catch {} // And update shared file storage if available try { if (this._config.storage_enabled) { const setPayload = { ...settings, show_pause_button: !!(this._config.show_pause_button ?? true), show_room_temp: !!(this._config.show_room_temp ?? true), pause_sensor_enabled: !!(this._config.pause_sensor_enabled ?? false), pause_sensor_entity: String(this._config.pause_sensor_entity || ''), auto_apply_enabled: !!(this._config.auto_apply ?? true), pause_indef: !!this._pauseIndef, pause_until_ms: Number(this._pauseUntilMs||0) }; const p = { schedules, settings: setPayload, colors }; if (this._config?.instance_enabled) { p.instance_id = this._instanceId(); p.activate = true; } await this._hass.callService('thermostat_timeline','set_store', p); } } catch {} } catch {} // Emit config change and force preview card re-render this._emit(true); try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} try { document.querySelectorAll('thermostat-timeline-card').forEach(el=>el?.devRefreshFromEditor?.()); } catch {} }; // Append buttons stacked (each on its own line) and aligned left // Update schedule button root.append(refresh); // Single button: clear all color data (local + shared if available) const clrAll = document.createElement('button'); clrAll.className = 'remove-btn clr-colors-all-btn'; clrAll.type = 'button'; try { clrAll.style.width = 'max-content'; clrAll.style.justifySelf = 'start'; } catch {} clrAll.innerHTML = '' + (this._t('editor.colors.clear_all') || 'Clear all color data') + ''; clrAll.onclick = async () => { const ok = confirm(this._t('editor.colors.clear_all_confirm') || 'This will delete all color ranges from both local and shared storage. Continue?'); if (!ok) return; try { // 1) Clear in-memory config this._config.color_ranges = {}; // 2) Clear localStorage copy try { const raw = localStorage.getItem(this._localStoreKey()) || ''; let schedules = {}; let settings = { time_12h: this._config.time_12h, temp_unit: this._config.temp_unit, time_source: this._config.time_source, row_height: Number(this._config.row_height ?? 64), default_temp: Number(this._config.default_temp || 20), color_ranges: {}, color_global: !!this._config.color_global, min_temp: Number(this._config.min_temp ?? 5), max_temp: Number(this._config.max_temp ?? 25), per_room_defaults: !!(this._config.per_room_defaults ?? false), apply_on_edit: !!(this._config.apply_on_edit ?? true), apply_on_default_change: !!(this._config.apply_on_default_change ?? true), away: this._config.away, merges: this._config.merges, labels: this._config.labels, temp_sensors: this._config.temp_sensors, sync_mode: (this._config.storage_sync_mode||'instant'), sync_delay_min: Number(this._config.storage_sync_min||0) }; let colors = { color_ranges: {}, color_global: !!this._config.color_global }; if (raw) { try { const parsed = JSON.parse(raw || '{}'); schedules = parsed.schedules || {}; const prev = parsed.settings || {}; settings = { ...prev, color_ranges: {} }; colors = parsed.colors || colors; } catch {} } localStorage.setItem(this._localStoreKey(), JSON.stringify({ schedules, settings, colors })); } catch {} // 3) Clear shared file storage if enabled try { if (this._hass && this._config?.storage_enabled) { let schedules = {}; let settings = { time_12h: this._config.time_12h, temp_unit: this._config.temp_unit, time_source: this._config.time_source, row_height: Number(this._config.row_height ?? 64), default_temp: Number(this._config.default_temp || 20), color_ranges: {}, color_global: !!this._config.color_global, min_temp: Number(this._config.min_temp ?? 5), max_temp: Number(this._config.max_temp ?? 25), per_room_defaults: !!(this._config.per_room_defaults ?? false), apply_on_edit: !!(this._config.apply_on_edit ?? true), apply_on_default_change: !!(this._config.apply_on_default_change ?? true), away: this._config.away, merges: this._config.merges, labels: this._config.labels, temp_sensors: this._config.temp_sensors, sync_mode: (this._config.storage_sync_mode||'instant'), sync_delay_min: Number(this._config.storage_sync_min||0) }; let colors = { color_ranges: {}, color_global: !!this._config.color_global }; // Fetch current schedules from API to preserve them while clearing colors try { const resp = await this._apiFetchState?.(); if (resp && typeof resp === 'object') { if (resp.schedules) schedules = JSON.parse(JSON.stringify(resp.schedules)); if (resp.settings) settings = { ...resp.settings, color_ranges: {} }; if (resp.colors) colors = { ...resp.colors, color_ranges: {} }; } } catch {} const p = { schedules, settings, colors }; if (this._config?.instance_enabled) { p.instance_id = this._instanceId(); p.activate = true; } await this._hass.callService('thermostat_timeline', 'set_store', p); } } catch (e) {} } catch {} // Repaint editor + live cards this._emit(true); try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} try { document.querySelectorAll('thermostat-timeline-card').forEach(el=>el?.devRefreshFromEditor?.()); } catch {} }; root.append(clrAll); } } catch {} // Build Holidays tab content try { const root = this.shadowRoot.querySelector('.tab-holidays'); if (root) { root.innerHTML = ''; // Toggle enable inside Holidays tab const enableCard = document.createElement('div'); enableCard.className = 'settings-card'; const enRow = document.createElement('div'); enRow.className='setting'; const enText = document.createElement('div'); enText.className='text'; const enTitle = document.createElement('div'); enTitle.className='title'; enTitle.textContent = this._t('holidays.enable'); const enDesc = document.createElement('div'); enDesc.className='desc'; enDesc.textContent = this._t('holidays.desc'); enText.append(enTitle, enDesc); const enSwitch = document.createElement('ha-switch'); enSwitch.className = 'holidays-enable'; enSwitch.checked = !!this._config.holidays_enabled; enSwitch.addEventListener('change', (e)=>{ const on = !!e.target.checked; this._upd('holidays_enabled', on); this._holidaysToggleTouched = true; try { contentWrap.style.display = on ? '' : 'none'; } catch {} try { this._pushSettingsToStoreDebounced(); } catch {} }); enRow.append(enText, enSwitch); enableCard.append(enRow); root.append(enableCard); // Container for remaining content (shown only when enabled) const contentWrap = document.createElement('div'); contentWrap.className='holidays-content'; contentWrap.style.display = this._config.holidays_enabled ? '' : 'none'; root.append(contentWrap); // Enable hint if no rooms if ((this._config.entities||[]).length === 0) { const box = document.createElement('div'); box.className='empty-box'; const p = document.createElement('div'); p.textContent = this._t('editor.select_entities'); const btn = document.createElement('button'); btn.type='button'; btn.className='add-entity-btn'; btn.innerHTML = ''+(this._t('editor.add_entity')||'Add room')+''; btn.onclick = ()=>{ this._activeTab='rooms'; this._render(); }; box.append(p, btn); contentWrap.append(box); } // Source selector const sourceWrap = document.createElement('div'); sourceWrap.className='settings-card'; const sRow = document.createElement('div'); sRow.className='setting'; const sText = document.createElement('div'); sText.className='text'; const sTitle = document.createElement('div'); sTitle.className='title'; sTitle.textContent = this._t('holidays.source'); const sDesc = document.createElement('div'); sDesc.className='desc'; sDesc.textContent = this._t('holidays.desc'); sText.append(sTitle, sDesc); const sSeg = document.createElement('div'); sSeg.className='seg'; const bCal = document.createElement('button'); bCal.type='button'; bCal.className='h-src-cal'; bCal.textContent = this._t('holidays.source.calendar'); const bMan = document.createElement('button'); bMan.type='button'; bMan.className='h-src-man'; bMan.textContent = this._t('holidays.source.manual'); const applySegUi = ()=>{ const src = this._config.holidays_source || 'calendar'; bCal.classList.toggle('active', src==='calendar'); bMan.classList.toggle('active', src==='manual'); }; bCal.onclick = ()=>{ this._config.holidays_source='calendar'; applySegUi(); this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} }; bMan.onclick = ()=>{ this._config.holidays_source='manual'; applySegUi(); this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} }; sSeg.append(bCal, bMan); sRow.append(sText, sSeg); sourceWrap.append(sRow); applySegUi(); // Calendar entity picker const rowCal = document.createElement('div'); rowCal.className='setting'; rowCal.style.display = (this._config.holidays_source||'calendar')==='calendar' ? 'grid' : 'none'; // Override layout to single-column so description can sit under the dropdown try { rowCal.style.gridTemplateColumns = '1fr'; } catch {} const calTxt = document.createElement('div'); calTxt.className='text'; const calTitle = document.createElement('div'); calTitle.className='title'; calTitle.textContent = this._t('holidays.entity'); calTxt.append(calTitle); const picker = document.createElement('tt-entity-picker'); picker.className='holiday-entity'; picker.setAttribute('include-domains','["calendar"]'); picker.setAttribute('label', this._t('holidays.entity')); try { picker.hass = this._hass; picker.value = this._config.holidays_entity || ''; } catch {} picker.addEventListener('value-changed', (e)=>{ this._upd('holidays_entity', e.detail.value||''); try { this._pushSettingsToStoreDebounced(); } catch {} }); // Description moved below the dropdown to avoid cramped layout const calDesc = document.createElement('div'); calDesc.className='desc'; calDesc.textContent = this._t('holidays.entity_desc'); rowCal.append(calTxt, picker, calDesc); sourceWrap.append(rowCal); // Manual dates list const rowMan = document.createElement('div'); rowMan.className='setting'; rowMan.style.display = (this._config.holidays_source||'calendar')==='manual' ? 'grid' : 'none'; const manTxt = document.createElement('div'); manTxt.className='text'; const manTitle = document.createElement('div'); manTitle.className='title'; manTitle.textContent = this._t('holidays.dates'); const manDesc = document.createElement('div'); manDesc.className='desc'; manDesc.textContent=''; manTxt.append(manTitle, manDesc); const manWrap = document.createElement('div'); manWrap.style.display='grid'; manWrap.style.gap='8px'; const addRow = document.createElement('div'); addRow.style.display='flex'; addRow.style.gap='8px'; addRow.style.alignItems='center'; addRow.style.flexWrap='wrap'; const inp = document.createElement('input'); inp.type='date'; inp.className='tt-input'; const addBtn = document.createElement('button'); addBtn.type='button'; addBtn.className='add-entity-btn'; addBtn.innerHTML = ''+(this._t('holidays.add_date')||'Add date')+''; const dateMsg = document.createElement('span'); dateMsg.style.color='var(--error-color)'; dateMsg.style.fontSize='.85rem'; dateMsg.style.marginLeft='6px'; addBtn.onclick = ()=>{ const v=inp.value; if (!v) return; // If date is already covered by a range, show hint and do nothing try { const groups = Array.isArray(this._config.holidays_groups) ? this._config.holidays_groups : []; const covered = groups.some(g => Array.isArray(g?.dates) && g.dates.map(String).includes(String(v))); if (covered) { dateMsg.textContent = this._t('holidays.date_in_range') || 'Date already covered by a range'; return; } } catch {} const arr = Array.isArray(this._config.holidays_dates)?[...this._config.holidays_dates]:[]; if (!arr.includes(v)) arr.push(v); this._config.holidays_dates = arr.sort(); inp.value=''; dateMsg.textContent=''; this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} }; addRow.append(inp, addBtn, dateMsg); manWrap.append(addRow); // Range add: from date .. to date const rangeRow = document.createElement('div'); rangeRow.style.display='flex'; rangeRow.style.gap='8px'; rangeRow.style.alignItems='center'; rangeRow.style.flexWrap='wrap'; const inpFrom = document.createElement('input'); inpFrom.type='date'; inpFrom.className='tt-input'; const inpTo = document.createElement('input'); inpTo.type='date'; inpTo.className='tt-input'; const addRangeBtn = document.createElement('button'); addRangeBtn.type='button'; addRangeBtn.className='add-entity-btn'; addRangeBtn.innerHTML = ''+(this._t('holidays.add_range')||'Add range')+''; addRangeBtn.onclick = ()=>{ try { const f = String(inpFrom.value||''); const t = String(inpTo.value||''); if (!f || !t) return; let d1 = new Date(f+'T00:00:00'); let d2 = new Date(t+'T00:00:00'); if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return; if (d1.getTime() > d2.getTime()) { const tmp=d1; d1=d2; d2=tmp; } const fmt = (d)=>{ const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${dd}`; }; const cur = Array.isArray(this._config.holidays_dates)? new Set(this._config.holidays_dates.map(String)) : new Set(); const grpDates = []; for (let d=new Date(d1); d.getTime()<=d2.getTime(); d.setDate(d.getDate()+1)) { const s=fmt(d); cur.add(s); grpDates.push(s); } const next = Array.from(cur).sort(); this._config.holidays_dates = next; // Create UI range group tag for easier removal later try { const fromS = fmt(d1), toS = fmt(d2); const gid = 'hg_'+Date.now()+'_'+Math.floor(Math.random()*1e6); const groups = Array.isArray(this._config.holidays_groups) ? [...this._config.holidays_groups] : []; groups.push({ id: gid, from: fromS, to: toS, dates: grpDates }); this._config.holidays_groups = groups; } catch {} try { inpFrom.value=''; inpTo.value=''; } catch {} this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} } catch {} }; rangeRow.append(inpFrom, inpTo, addRangeBtn); manWrap.append(rangeRow); const singlesLbl = document.createElement('div'); singlesLbl.className='title'; singlesLbl.style.fontSize='.9rem'; singlesLbl.textContent = this._t('holidays.single_dates')||'Single dates'; const chips = document.createElement('div'); chips.className='linked-chips'; const arr = Array.isArray(this._config.holidays_dates)?this._config.holidays_dates:[]; const groupDatesSet = (()=>{ try { const gs = Array.isArray(this._config.holidays_groups)?this._config.holidays_groups:[]; return new Set([].concat(...gs.map(g=>g.dates||[])).map(String)); } catch { return new Set(); } })(); const singles = arr.filter(d=>!groupDatesSet.has(String(d))); chips.innerHTML=''; for (const d of singles){ const chip=document.createElement('span'); chip.className='pill-chip'; const txt=document.createElement('span'); txt.textContent=d; const rm=document.createElement('button'); rm.type='button'; rm.className='rm'; rm.textContent='×'; rm.addEventListener('click', ()=>{ const v=(this._config.holidays_dates||[]).filter(x=>x!==d); this._config.holidays_dates=v; this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} }); chip.append(txt, rm); chips.append(chip); } manWrap.append(singlesLbl, chips); // Range group chips const grpLbl = document.createElement('div'); grpLbl.className='title'; grpLbl.style.fontSize='.9rem'; grpLbl.textContent = this._t('holidays.ranges')||'Holiday ranges'; const grpChips = document.createElement('div'); grpChips.className='linked-chips'; const renderGrp = ()=>{ try { grpChips.innerHTML=''; const groups = Array.isArray(this._config.holidays_groups) ? this._config.holidays_groups : []; for (const g of groups){ const chip=document.createElement('span'); chip.className='pill-chip'; const txt=document.createElement('span'); txt.textContent = `${g.from} – ${g.to}`; const rm=document.createElement('button'); rm.type='button'; rm.className='rm'; rm.textContent='×'; rm.addEventListener('click', ()=>{ try { const all = Array.isArray(this._config.holidays_groups) ? this._config.holidays_groups : []; const others = all.filter(x=>x!==g); const othersDates = new Set([].concat(...others.map(o=>o.dates||[])).map(String)); const curSet = new Set((this._config.holidays_dates||[]).map(String)); for (const d of (g.dates||[])) { if (!othersDates.has(String(d))) curSet.delete(String(d)); } this._config.holidays_dates = Array.from(curSet).sort(); this._config.holidays_groups = others; this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} // rerender both lists chips.innerHTML=''; for (const d of this._config.holidays_dates){ const c=document.createElement('span'); c.className='pill-chip'; const t=document.createElement('span'); t.textContent=d; const r=document.createElement('button'); r.type='button'; r.className='rm'; r.textContent='×'; r.addEventListener('click', ()=>{ const v=(this._config.holidays_dates||[]).filter(x=>x!==d); this._config.holidays_dates=v; this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} }); c.append(t,r); chips.append(c); } renderGrp(); } catch {} }); chip.append(txt, rm); grpChips.append(chip); } } catch {} }; renderGrp(); manWrap.append(grpLbl, grpChips); rowMan.append(manTxt, manWrap); sourceWrap.append(rowMan); contentWrap.append(sourceWrap); // Inline Holidays editor (preview + edit) inside editor const edWrap = document.createElement('div'); edWrap.className = 'holiday-ed'; edWrap.style.position = 'relative'; // Room tabs const tabs = document.createElement('div'); tabs.className='hol-tabs'; tabs.style.display='flex'; tabs.style.gap='8px'; tabs.style.flexWrap='wrap'; edWrap.append(tabs); const note = document.createElement('div'); note.style.fontSize='.85rem'; note.style.color='var(--secondary-text-color)'; note.textContent = this._t('week.editor.preview_note'); edWrap.append(note); // Scale const scaleOuter = document.createElement('div'); scaleOuter.className='week-scale'; const scale = document.createElement('div'); scale.className='week-scale-inner'; scaleOuter.append(scale); edWrap.append(scaleOuter); // Track const track = document.createElement('div'); track.className='week-track'; edWrap.append(track); // Toolbar const tools = document.createElement('div'); tools.className='hol-toolbar'; const addBlkBtn = document.createElement('button'); addBlkBtn.className='add-entity-btn'; addBlkBtn.innerHTML = ''+ (this._t('ui.add_block') || 'Add block') +''; const saveBtn = document.createElement('button'); saveBtn.className='add-entity-btn'; saveBtn.innerHTML = ''+ (this._t('ui.save')||'Save') +''; tools.append(addBlkBtn, saveBtn); edWrap.append(tools); // Inline mini block editor panel const mini = document.createElement('div'); mini.className='mini-ed'; mini.style.display='none'; mini.innerHTML = `
`; edWrap.append(mini); contentWrap.append(edWrap); // Helpers const toTime = (min)=>{ min=(Number(min)||0)%1440; const hh=String(Math.floor(min/60)).padStart(2,'0'); const mm=String(min%60).padStart(2,'0'); return `${hh}:${mm}`; }; const fromTime = (s)=>{ const m=(/^(\d{1,2}):(\d{2})$/).exec(String(s||'')); if(!m) return 0; let h=Number(m[1]); let mi=Number(m[2]); h=Math.min(23,Math.max(0,h)); mi=Math.min(59,Math.max(0,mi)); return h*60+mi; }; const toDisp = (c)=> this._toDisplayTemp(Number(c)); const fromDisp = (v)=> this._fromDisplayTemp(Number(v)); // Color helpers (mirror of card logic) const colorFor = (eid, tempC)=>{ try { const cr = this._config?.color_ranges || {}; const list = this._config?.color_global ? (cr['*'] || []) : (cr[eid] || cr['*'] || []); for (const r of (list||[])){ const f = Number(r.from); const t = Number(r.to); if (Number.isFinite(f) && Number.isFinite(t) && tempC >= f && tempC <= t) return r.color || null; } } catch {} return null; }; const contrastText = (hex)=>{ try { const h = String(hex||'').replace('#',''); if (h.length!==6) return '#000000'; const r=parseInt(h.slice(0,2),16), g=parseInt(h.slice(2,4),16), b=parseInt(h.slice(4,6),16); const yiq=(r*299+g*587+b*114)/1000; return yiq>=140 ? '#000000' : '#ffffff'; } catch { return '#000000'; } }; const ensureHolDraft = async ()=>{ if (this._holDraft) return; let schedules = {}; let settings={}; // Load from file storage via API try { if (this._config?.storage_enabled && this._apiSupported?.()) { const resp = await this._apiFetchState?.(); if (resp && typeof resp === 'object') { schedules = resp.schedules || {}; if (schedules.schedules) schedules = schedules.schedules; settings = resp.settings || {}; } } } catch {} if (!Object.keys(schedules||{}).length){ try { const raw = localStorage.getItem(this._localStoreKey()) || ''; const parsed = JSON.parse(raw||'{}'); schedules = parsed.schedules||{}; if (schedules.schedules) schedules = schedules.schedules; settings = parsed.settings||{}; } catch {} } const roomsMap={}; for (const eid of (this._config?.entities||[])){ const row=schedules[eid]||{}; const arr=(row.holiday && Array.isArray(row.holiday.blocks))?row.holiday.blocks:[]; roomsMap[eid]=JSON.parse(JSON.stringify(arr)); } this._holDraft={ rooms: roomsMap }; this._holRoom = this._holRoom || (this._config?.entities||[])[0] || null; }; const paintScale = ()=>{ scale.innerHTML=''; for(let i=0;i<=24;i+=2){ const t=document.createElement('div'); t.style.position='absolute'; t.style.left=(i/24*100)+'%'; t.style.top='4px'; t.style.bottom='4px'; t.style.borderLeft='1px solid var(--divider-color)'; const lab=document.createElement('div'); lab.style.position='absolute'; lab.style.top='-2px'; lab.style.transform='translate(-50%,0)'; lab.style.left='0'; lab.style.fontSize='.75rem'; lab.style.color='var(--secondary-text-color)'; lab.textContent=(i===24?'00:00':toTime(i*60)); t.append(lab); scale.append(t);} }; const paintTabs = ()=>{ tabs.innerHTML=''; const ents=this._config?.entities||[]; if (!this._holRoom || !ents.includes(this._holRoom)) this._holRoom=ents[0]||null; for (const eid of ents){ const b=document.createElement('button'); b.type='button'; b.className='wk-tab'+(eid===this._holRoom?' active':''); let lbl=this._config?.labels?.[eid] || (this._hass?.states?.[eid]?.attributes?.friendly_name || (eid.split('.')[1]||eid)); b.textContent=lbl; b.onclick=()=>{ this._holRoom=eid; paintTrack(); }; tabs.append(b);} }; const openMini = (blk)=>{ mini.style.display=''; const f=mini.querySelector('.mi-from'); const t=mini.querySelector('.mi-to'); const tmp=mini.querySelector('.mi-temp'); f.value=toTime(blk.startMin); t.value=toTime(blk.endMin%1440); tmp.value=String(toDisp(blk.temp)); this._holMini = { room: this._holRoom, blk: { id: blk.id, startMin: blk.startMin, endMin: blk.endMin, temp: blk.temp } }; // Focus first field so global editor render guard keeps panel open try { f.focus(); f.select && f.select(); } catch {} mini.querySelector('.mi-save').onclick=()=>{ const s=fromTime(f.value); let e=fromTime(t.value); if (e===0 && t.value==='00:00') e=1440; const arr=(this._holDraft.rooms||{})[this._holRoom]||[]; let b=arr.find(x=>x.id===blk.id); if (!b) { b = { id: blk.id, startMin:s, endMin:e, temp: fromDisp(tmp.value) }; arr.push(b); } else { b.startMin=Math.max(0,Math.min(1439,s)); b.endMin=Math.max(1,Math.min(1440,e)); b.temp=fromDisp(tmp.value); } arr.sort((a,bx)=>a.startMin-bx.startMin||a.endMin-bx.endMin); (this._holDraft.rooms||{})[this._holRoom]=arr; this._holMini = null; mini.style.display='none'; paintTrack(); }; mini.querySelector('.mi-cancel').onclick=()=>{ this._holMini = null; mini.style.display='none'; }; mini.querySelector('.mi-del').onclick=()=>{ const arr=(this._holDraft.rooms||{})[this._holRoom]||[]; const i=arr.findIndex(x=>x.id===blk.id); if (i>=0) { arr.splice(i,1); (this._holDraft.rooms||{})[this._holRoom]=arr; paintTrack(); } this._holMini = null; mini.style.display='none'; }; }; const paintTrack = async ()=>{ await ensureHolDraft(); paintTabs(); paintScale(); track.innerHTML=''; // Ensure tooltip exists once let tip = edWrap.querySelector('.ed-hol-tooltip'); if (!tip) { tip = document.createElement('div'); tip.className='wk-tooltip ed-hol-tooltip'; tip.style.display='none'; edWrap.append(tip); } const arr=(this._holDraft.rooms||{})[this._holRoom]||[]; for (const b of arr){ const div=document.createElement('div'); div.className='block'; div.style.left=(b.startMin/1440*100)+'%'; div.style.width=((b.endMin-b.startMin)/1440*100)+'%'; // Apply heat color if configured try { const clr = colorFor(this._holRoom, Number(b.temp)); if (clr) { div.style.background = clr; div.style.borderColor = clr; const txt=contrastText(clr); if (txt) div.style.color = txt; } } catch {} const showTip = ()=>{ const txt = `${toTime(b.startMin)} - ${toTime(b.endMin)} • ${toDisp(b.temp)} ${this._isF()? '°F':'°C'}`; tip.textContent = txt; tip.style.display = ''; const box = div.getBoundingClientRect(); const cont = edWrap.getBoundingClientRect(); const tbox = track.getBoundingClientRect(); // Base center: visual center of the hovered block element let center = box.left + box.width/2 - cont.left; const tipW = tip.offsetWidth || 0; const tipH = tip.offsetHeight || 0; const pad = 8; const minC = (tbox.left - cont.left) + pad + tipW/2; const maxC = (tbox.right - cont.left) - pad - tipW/2; center = Math.max(minC, Math.min(maxC, center)); // Prefer above the block; if not enough space, flip below const yAbove = box.top - cont.top - 8; // anchor a bit above block const yBelow = box.bottom - cont.top + 8; // a bit below block if (yAbove - tipH < pad) { tip.style.top = Math.min(cont.height - pad, yBelow) + 'px'; tip.style.transform = 'translate(-50%,0)'; } else { // Place right above the block top edge tip.style.top = Math.max(pad, box.top - cont.top + 6) + 'px'; tip.style.transform = 'translate(-50%,-100%)'; } tip.style.left = center + 'px'; }; const hideTip = ()=>{ const delay = (window.matchMedia && window.matchMedia('(pointer:coarse)').matches) ? 3000 : 120; if (tip._hideTimer) try{ clearTimeout(tip._hideTimer);}catch{}; tip._hideTimer = setTimeout(()=>{ try { tip.style.display='none'; tip._hideTimer = null; } catch{} }, delay); }; div.addEventListener('mouseenter', showTip); div.addEventListener('mouseleave', hideTip); div.ondblclick=()=>openMini(b); track.append(div); } // Re-open mini editor if it was open before a re-render if (this._holMini && this._holMini.room === this._holRoom) { openMini(this._holMini.blk); } }; addBlkBtn.onclick = async ()=>{ await ensureHolDraft(); const now=new Date(); const m=now.getHours()*60+now.getMinutes(); const start=Math.max(0,Math.min(1380, Math.round(m))); const end=Math.min(1440,start+60); const blk={ id: Math.random().toString(36).slice(2,9), startMin:start, endMin:end, temp: fromDisp(this._config.default_temp||20) }; openMini(blk); }; saveBtn.onclick = async ()=>{ await ensureHolDraft(); let schedules={}; let settings={}; // Load current schedules from file storage via API try { if (this._config?.storage_enabled && this._apiSupported?.()) { const resp = await this._apiFetchState?.(); if (resp && typeof resp === 'object') { schedules = resp.schedules || {}; if (schedules.schedules) schedules = schedules.schedules; settings = resp.settings || {}; } } } catch {} if (!Object.keys(schedules||{}).length){ try { const raw=localStorage.getItem(this._localStoreKey())||''; const parsed=JSON.parse(raw||'{}'); schedules=parsed.schedules||{}; if (schedules.schedules) schedules=schedules.schedules; settings=parsed.settings||{}; } catch {} } for (const eid of (this._config?.entities||[])){ const row=schedules[eid] || { defaultTemp: this._config.default_temp, blocks: [] }; const arr=(this._holDraft.rooms||{})[eid]||[]; row.holiday={ blocks: JSON.parse(JSON.stringify(arr)) }; schedules[eid]=row; } const wantF = this._isF(); const schOut = wantF ? this._convertSchedulesTemps(schedules,'F') : schedules; let settingsOut = { ...settings, holidays_enabled: !!this._config.holidays_enabled, holidays_source: this._config.holidays_source || 'calendar', holidays_entity: this._config.holidays_entity || '', holidays_dates: Array.isArray(this._config.holidays_dates)?this._config.holidays_dates:[], holidays_groups: Array.isArray(this._config.holidays_groups)?this._config.holidays_groups:[] }; // Preserve backend holidays_enabled unless explicitly toggled in this session try { if (!this._holidaysToggleTouched) { const st = await this._apiFetchState?.(); const base = st?.settings; if (typeof base?.holidays_enabled === 'boolean') settingsOut.holidays_enabled = base.holidays_enabled; else delete settingsOut.holidays_enabled; } } catch {} try { if (wantF) { const toStore=(n)=>{ const v=Number(n); return Number.isFinite(v)? this._cToF(v): n; }; if (settingsOut.default_temp!==undefined) settingsOut.default_temp = toStore(settingsOut.default_temp); if (settingsOut.min_temp!==undefined) settingsOut.min_temp = toStore(settingsOut.min_temp); if (settingsOut.max_temp!==undefined) settingsOut.max_temp = toStore(settingsOut.max_temp); if (settingsOut.away && typeof settingsOut.away==='object' && settingsOut.away.target_c!==undefined) { settingsOut.away = { ...settingsOut.away, target_c: toStore(settingsOut.away.target_c) }; } } } catch {} try { localStorage.setItem(this._localStoreKey(), JSON.stringify({ schedules: schOut, settings: settingsOut })); } catch {} // Save to file storage via service try { if (this._config.storage_enabled) { const p = { schedules: schOut, settings: settingsOut }; if (this._config?.instance_enabled) { p.instance_id = this._instanceId(); p.activate = true; } await this._hass.callService('thermostat_timeline','set_store', p); } } catch {} try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} }; // Initial render (async()=>{ await ensureHolDraft(); await paintTrack(); })(); } } catch {} // Apply tab visibility/class based on current state try { const sBtn = this.shadowRoot.querySelector('.tab-settings-btn'); const bBtn = this.shadowRoot.querySelector('.tab-backup-btn'); const sSec = this.shadowRoot.querySelector('.tab-settings'); const bSec = this.shadowRoot.querySelector('.tab-backup'); const setActive = (btn, on) => { if (!btn) return; try { btn.classList.toggle('active', !!on); } catch {} try { btn.setAttribute('aria-selected', on ? 'true' : 'false'); } catch {} }; const setShow = (sec, on) => { if (!sec) return; try { sec.style.display = on ? '' : 'none'; } catch {} }; const showBackup = !!this._config.storage_enabled; if (!showBackup && this._activeTab === 'backup') this._activeTab = 'settings'; if (this._activeTab === 'sync') this._activeTab = 'settings'; if (!['settings', 'backup'].includes(this._activeTab)) this._activeTab = 'settings'; const isSettings = this._activeTab === 'settings'; const isBackup = this._activeTab === 'backup'; setActive(sBtn, isSettings); if (bBtn) { setActive(bBtn, isBackup); try { bBtn.style.display = showBackup ? '' : 'none'; } catch {} } setShow(sSec, isSettings); setShow(bSec, showBackup && isBackup); } catch {} } // Pointer-driven Drag & Drop (works reliably inside HA editors) _startPointerDnd(line, idx){ const wrap = line.parentElement; if (!wrap) return; if (this._pointerDndActive) return; this._pointerDndActive = true; // Avoid any re-render while dragging which could disrupt DOM try { this._suspendRender = true; } catch {} const rect = line.getBoundingClientRect(); const ghost = line.cloneNode(true); ghost.classList.add('drag-ghost'); ghost.style.width = rect.width + 'px'; ghost.style.height = rect.height + 'px'; ghost.style.top = rect.top + 'px'; ghost.style.left = rect.left + 'px'; const placeholder = document.createElement('div'); placeholder.className = 'placeholder'; placeholder.style.height = rect.height + 'px'; // place placeholder where line was, and hide line wrap.insertBefore(placeholder, line); line.style.display = 'none'; document.body.appendChild(ghost); const readClient = (e)=>{ if (e.touches && e.touches[0]) return { x: e.touches[0].clientX, y: e.touches[0].clientY }; return { x: e.clientX, y: e.clientY }; }; const onMove = (ev)=>{ try { ev.preventDefault(); } catch {} const { y, x } = readClient(ev); ghost.style.top = (y - rect.height/2) + 'px'; // find element under pointer within this shadowRoot let el = this.shadowRoot && this.shadowRoot.elementFromPoint(x, y); // climb to row in the same wrap while (el && el !== wrap && !el.classList?.contains('inline')) el = el.parentElement; if (!el || el===placeholder || el===wrap) return; const targetRect = el.getBoundingClientRect(); const before = (y - targetRect.top) < targetRect.height/2; if (before) wrap.insertBefore(placeholder, el); else wrap.insertBefore(placeholder, el.nextSibling); }; const endDrag = ()=>{ window.removeEventListener('mousemove', onMove, true); window.removeEventListener('mouseup', onUp, true); window.removeEventListener('touchmove', onMove, true); window.removeEventListener('touchend', onUp, true); ghost.remove(); // compute new order const from = idx; const to = Array.prototype.indexOf.call(wrap.children, placeholder); const arr = [...(this._config.entities||[])]; const modes = Array.isArray(this._config.room_use_input_number) ? [...this._config.room_use_input_number] : []; while (modes.length < arr.length) modes.push(false); const [item] = arr.splice(from,1); const [mItem] = modes.splice(from,1); const correctedTo = Math.max(0, Math.min(to, arr.length)); arr.splice(correctedTo, 0, item); modes.splice(correctedTo, 0, mItem); placeholder.remove(); line.style.display = ''; this._pointerDndActive = false; this._config.entities = arr; this._config.room_use_input_number = modes; try { this._suspendRender = false; } catch {} this._emit(true); }; const onUp = (ev)=>{ try { ev.preventDefault?.(); } catch {} endDrag(); }; window.addEventListener('mousemove', onMove, true); window.addEventListener('mouseup', onUp, true); window.addEventListener('touchmove', onMove, true); window.addEventListener('touchend', onUp, true); } // Async creator used with lit's `until` to delay row insertion until HA elements are ready async _createRoomLine(ent, idx){ try { await customElements.whenDefined('ha-entity-picker'); } catch {} try { await customElements.whenDefined('ha-textfield'); } catch {} const line = this._makeEntityRow(idx); // Restore open state for this row try { const key = ent || `#idx:${idx}`; if (this._openRows.has(key)) line.classList.add('open'); } catch {} return line; } _makeEntityRow(idx){ const line = document.createElement("div"); line.className = "inline"; // Summary bar (collapsed row header) const summary = document.createElement('div'); summary.className = 'summary'; const sumLeft = document.createElement('div'); sumLeft.className = 'summary-left'; const expander = document.createElement('button'); expander.className = 'expander'; expander.innerHTML = ``; sumLeft.append(expander); // Title and subtitle container const textWrap = document.createElement('div'); textWrap.className = 'summary-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'summary-title'; titleSpan.textContent = this._t('editor.entity_placeholder'); const subSpan = document.createElement('span'); subSpan.className = 'summary-sub'; subSpan.textContent = ''; textWrap.append(titleSpan, subSpan); sumLeft.append(textWrap); summary.append(sumLeft); const actions = document.createElement("div"); actions.className = "actions"; const handle = document.createElement("button"); handle.className = "drag-handle"; handle.setAttribute("draggable","true"); handle.setAttribute("title", this._t('editor.drag_reorder')); handle.innerHTML = ``; actions.append(handle); // Start pointer-driven drag on mousedown (more reliable inside HA editors than native DnD) handle.addEventListener('mousedown', (ev)=>{ ev.preventDefault(); try { const wrap = line.parentElement; const curIdx = wrap ? Array.prototype.indexOf.call(wrap.children, line) : idx; this._startPointerDnd(line, curIdx); } catch { this._startPointerDnd(line, idx); } }); // Touch support handle.addEventListener('touchstart', (ev)=>{ try { ev.preventDefault(); } catch {} try { const wrap = line.parentElement; const curIdx = wrap ? Array.prototype.indexOf.call(wrap.children, line) : idx; this._startPointerDnd(line, curIdx); } catch { this._startPointerDnd(line, idx); } }, { passive: false }); const del = document.createElement("button"); del.className = "remove-btn"; del.innerHTML = `${this._t('editor.remove')}`; actions.append(del); summary.append(actions); // Details (expanded content) const details = document.createElement('div'); details.className = 'details'; // Left column: entity picker + optional label + merge UI const left = document.createElement("div"); left.style.display = "grid"; left.style.gap = "6px"; const useInput = this._roomUsesInputNumber(idx); // Per-room mode toggle const modeRow = document.createElement('div'); modeRow.style.display = 'flex'; modeRow.style.alignItems = 'center'; modeRow.style.justifyContent = 'space-between'; modeRow.style.gap = '10px'; const modeText = document.createElement('div'); modeText.style.display = 'grid'; modeText.style.gap = '2px'; const modeTitle = document.createElement('div'); modeTitle.style.fontWeight = '600'; modeTitle.textContent = this._t('editor.room_mode.title') || 'Use input_number instead of climate'; const modeDesc = document.createElement('div'); modeDesc.style.fontSize = '.85rem'; modeDesc.style.color = 'var(--secondary-text-color)'; modeDesc.textContent = this._t('editor.room_mode.desc') || ''; modeText.append(modeTitle, modeDesc); const modeSwitch = document.createElement('ha-switch'); modeSwitch.className = 'room-mode-switch'; modeSwitch.checked = !!useInput; modeSwitch.addEventListener('change', (e)=>{ this._setRoomUseInputNumber(idx, !!e.target.checked); }); modeRow.append(modeText, modeSwitch); const pick = document.createElement("tt-entity-picker"); try { pick.style.display = 'block'; pick.style.minHeight = '48px'; } catch {} try { pick.hass = this._hass; } catch {} try { pick.value = this._config.entities[idx] || ""; } catch {} pick.setAttribute("include-domains", useInput ? '["input_number"]' : '["climate"]'); pick.setAttribute("label", this._t("editor.entity_placeholder")); // Filter out already-selected entities in other pickers (but allow the current one) pick.entityFilter = (st) => { try { const id = st?.entity_id || st?.entityId || st?.entityID || ""; if (!id) return false; const chosenPrim = (this._config.entities || []).filter(Boolean); const allLinked = Object.values(this._config.merges || {}).flat(); const blocked = new Set([...chosenPrim, ...allLinked]); const current = this._config.entities[idx]; return !blocked.has(id) || id === current; } catch { return true; } }; const nameInp = document.createElement("input"); nameInp.type = 'text'; nameInp.className = "label-input tt-input"; nameInp.setAttribute("placeholder", this._t('editor.display_name_optional')); try { const curEid = this._config.entities[idx]; const curLbl = (this._config.labels || {})[curEid] || ''; nameInp.value = curLbl; } catch {} nameInp.addEventListener("change", (e)=>{ const eid = this._config.entities[idx]; if (!eid) return; const labels = { ...(this._config.labels || {}) }; const val = (e.target.value || "").trim(); if (val) labels[eid] = val; else delete labels[eid]; this._config.labels = labels; // Repaint so the summary header updates immediately with the custom name this._emit(true); // Persist label to local browser storage so live preview doesn’t get // overwritten by periodic hass() reloads when shared storage is OFF try { const raw = localStorage.getItem(this._localStoreKey()) || ''; let parsed = {}; try { parsed = JSON.parse(raw || '{}'); } catch {} const schedules = parsed.schedules || {}; const prevSettings = parsed.settings || {}; const settings = { ...prevSettings, labels: { ...(prevSettings.labels||{}), ...labels } }; localStorage.setItem(this._localStoreKey(), JSON.stringify({ schedules, settings })); } catch {} // Nudge any live preview card instances to refresh immediately try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} try { document.querySelectorAll('thermostat-timeline-card').forEach(el=>el?.devRefreshFromEditor?.()); } catch {} try { this._pushSettingsToStoreDebounced(); } catch {} }); // Per-room: optional turn_on behavior const turnOnWrap = document.createElement('div'); turnOnWrap.style.display = 'grid'; turnOnWrap.style.gap = '6px'; const turnOnRow = document.createElement('div'); turnOnRow.style.display = 'flex'; turnOnRow.style.alignItems = 'center'; turnOnRow.style.justifyContent = 'space-between'; turnOnRow.style.gap = '10px'; const turnOnText = document.createElement('div'); turnOnText.style.display = 'grid'; turnOnText.style.gap = '2px'; const turnOnTitle = document.createElement('div'); turnOnTitle.style.fontWeight = '600'; turnOnTitle.textContent = this._t('editor.turn_on.title') || 'Send turn_on command'; const turnOnDesc = document.createElement('div'); turnOnDesc.style.fontSize = '.85rem'; turnOnDesc.style.color = 'var(--secondary-text-color)'; turnOnDesc.textContent = this._t('editor.turn_on.desc') || ''; turnOnText.append(turnOnTitle, turnOnDesc); const turnOnSwitch = document.createElement('ha-switch'); turnOnSwitch.className = 'turn-on-switch'; // Dropdown (only visible when enabled) const turnOnOrderRow = document.createElement('div'); turnOnOrderRow.style.display = 'grid'; turnOnOrderRow.style.gap = '2px'; const orderLbl = document.createElement('div'); orderLbl.className = 'slabel'; orderLbl.textContent = this._t('editor.turn_on.order') || 'Order'; const orderSel = document.createElement('select'); orderSel.className = 'turn-on-order'; try { orderSel.style.height = '32px'; orderSel.style.border = '1px solid var(--divider-color)'; orderSel.style.borderRadius = '8px'; orderSel.style.background = 'var(--card-background-color)'; orderSel.style.color = 'var(--primary-text-color)'; orderSel.style.padding = '0 10px'; orderSel.style.width = '100%'; orderSel.style.maxWidth = '440px'; } catch {} const optBefore = document.createElement('option'); optBefore.value = 'before'; optBefore.textContent = this._t('editor.turn_on.order.before') || 'Before set_temperature'; const optAfter = document.createElement('option'); optAfter.value = 'after'; optAfter.textContent = this._t('editor.turn_on.order.after') || 'After set_temperature'; orderSel.append(optBefore, optAfter); const readTurnOnCfg = ()=>{ try { const eid = this._config.entities[idx]; const map = (this._config.turn_on && typeof this._config.turn_on === 'object') ? this._config.turn_on : {}; const cfg = (eid && map[eid] && typeof map[eid] === 'object') ? map[eid] : {}; const enabled = !!cfg.enabled; const order = String(cfg.order || 'before'); return { eid, enabled, order: (order === 'after' ? 'after' : 'before') }; } catch { return { eid: this._config.entities[idx], enabled: false, order: 'before' }; } }; const writeTurnOnCfg = (eid, patch)=>{ try { if (!eid) return; const prev = (this._config.turn_on && typeof this._config.turn_on === 'object') ? this._config.turn_on : {}; const cur = (prev[eid] && typeof prev[eid] === 'object') ? prev[eid] : {}; const next = { ...prev, [eid]: { ...cur, ...patch } }; // If disabling, keep order but store enabled=false (so it can be re-enabled without losing selection) this._config.turn_on = next; } catch {} // Persist to local browser storage for immediate live preview when Sync is OFF try { const raw = localStorage.getItem(this._localStoreKey()) || ''; let parsed = {}; try { parsed = JSON.parse(raw || '{}'); } catch { parsed = {}; } const schedules = parsed.schedules || {}; const prevSettings = parsed.settings || {}; const settings = { ...prevSettings, turn_on: { ...(prevSettings.turn_on||{}), ...(this._config.turn_on||{}) } }; localStorage.setItem(this._localStoreKey(), JSON.stringify({ schedules, settings })); } catch {} try { this._pushSettingsToStoreDebounced(); } catch {} try { window.dispatchEvent(new CustomEvent('thermostat-timeline-refresh')); } catch {} try { document.querySelectorAll('thermostat-timeline-card').forEach(el=>el?.devRefreshFromEditor?.()); } catch {} }; const applyTurnOnUi = ()=>{ const { enabled, order } = readTurnOnCfg(); try { turnOnSwitch.checked = !!enabled; } catch {} try { orderSel.value = (order === 'after' ? 'after' : 'before'); } catch {} try { turnOnOrderRow.style.display = enabled ? '' : 'none'; } catch {} }; turnOnSwitch.addEventListener('change', (e)=>{ try { const { eid, order } = readTurnOnCfg(); const enabled = !!e.target.checked; writeTurnOnCfg(eid, { enabled, order }); applyTurnOnUi(); this._emit(true); } catch {} }); orderSel.addEventListener('change', ()=>{ try { const { eid, enabled } = readTurnOnCfg(); const order = String(orderSel.value || 'before'); writeTurnOnCfg(eid, { enabled: !!enabled, order: (order === 'after' ? 'after' : 'before') }); this._emit(true); } catch {} }); // Initial values applyTurnOnUi(); turnOnRow.append(turnOnText, turnOnSwitch); turnOnOrderRow.append(orderLbl, orderSel); turnOnWrap.append(turnOnRow, turnOnOrderRow); // Merge/Link section const linkWrap = document.createElement('div'); linkWrap.style.display = 'grid'; linkWrap.style.gap = '6px'; // Visible label for merge/link section const linkLabel = document.createElement('div'); linkLabel.className = 'label'; linkLabel.textContent = this._t('editor.merge_label') || 'Flet med (tilføj ekstra termostat)'; const linkPicker = document.createElement('tt-entity-picker'); try { linkPicker.style.display = 'block'; linkPicker.style.minHeight = '48px'; } catch {} linkPicker.className = 'merge-picker'; linkPicker.setAttribute('include-domains', '["climate"]'); linkPicker.setAttribute('label', this._t('editor.merge_label') || 'Flet med (tilføj ekstra termostat)'); linkPicker.hass = this._hass; const linkFilter = (st) => { try { const id = st?.entity_id || st?.entityId || st?.entityID || ''; if (!id) return false; const prims = (this._config.entities || []).filter(Boolean); const allLinked = Object.values(this._config.merges || {}).flat(); const used = new Set([...prims, ...allLinked]); // Allow selecting if not already used anywhere and not equal to current primary const cur = this._config.entities[idx]; return !used.has(id) && id !== cur; } catch { return true; } }; linkPicker.entityFilter = linkFilter; linkPicker.addEventListener('value-changed', (e)=>{ const toAdd = e.detail.value; linkPicker.value = ''; const primary = this._config.entities[idx]; if (!primary || !toAdd) return; const merges = { ...(this._config.merges || {}) }; const arr = Array.from(new Set([...(merges[primary] || []), toAdd])); merges[primary] = arr; this._config.merges = merges; this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} }); const chips = document.createElement('div'); chips.className = 'linked-chips'; left.append(pick, nameInp, turnOnWrap, linkWrap, chips); // Insert mode toggle at the top try { left.insertBefore(modeRow, left.firstChild); } catch { left.prepend(modeRow); } // Hide merge UI when controlling input_number try { if (useInput) { linkWrap.style.display = 'none'; chips.style.display = 'none'; turnOnWrap.style.display = 'none'; } } catch {} details.append(left); // Expand/collapse const toggle = ()=>{ line.classList.toggle('open'); try { const eid = this._config.entities[idx] || `#idx:${idx}`; if (line.classList.contains('open')) this._openRows.add(eid); else this._openRows.delete(eid); } catch {} // When a row is opened, force-hydrate the pickers so they render immediately try { if (line.classList.contains('open')) { // Primary picker try { pick.hass = this._hass; pick.style.display = 'block'; pick.style.minHeight = '48px'; if (typeof pick.requestUpdate === 'function') pick.requestUpdate(); // Nudge again after layout for stubborn cases setTimeout(()=>{ try { if (typeof pick.requestUpdate === 'function') pick.requestUpdate(); } catch {} }, 50); } catch {} // Merge picker try { linkPicker.hass = this._hass; linkPicker.style.display = 'block'; linkPicker.style.minHeight = '48px'; if (typeof linkPicker.requestUpdate === 'function') linkPicker.requestUpdate(); setTimeout(()=>{ try { if (typeof linkPicker.requestUpdate === 'function') linkPicker.requestUpdate(); } catch {} }, 50); } catch {} } } catch {} }; expander.addEventListener('click', (e)=>{ e.stopPropagation(); toggle(); }); sumLeft.addEventListener('click', (e)=>{ if (e.target === expander) return; toggle(); }); line.append(summary, details); // populate chips on render const refreshChips = () => { try { linkPicker.hass = this._hass; const primary = this._config.entities[idx]; const links = (this._config.merges?.[primary] || []); chips.innerHTML = ''; for (const l of links){ const chip = document.createElement('span'); chip.className = 'pill-chip'; const txt = document.createElement('span'); txt.textContent = this._hass?.states?.[l]?.attributes?.friendly_name || (l.split('.')[1]||l); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'rm'; rm.textContent = '×'; rm.setAttribute('aria-label', this._t('editor.remove')); rm.addEventListener('click', ()=>{ const merges = { ...(this._config.merges || {}) }; merges[primary] = (merges[primary]||[]).filter(x=>x!==l); if (!merges[primary]?.length) delete merges[primary]; this._config.merges = merges; this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} }); chip.append(txt, rm); chips.append(chip); } // Update subtitle after chip changes try { const subEl = line.querySelector('.summary-sub'); const linkedNames = links.map(id => this._hass?.states?.[id]?.attributes?.friendly_name || (id.split('.')[1]||id)); const parts = []; const eid = primary; if (eid) parts.push(eid); if (linkedNames.length) parts.push(`${this._t('editor.merged_with')}: ${linkedNames.join(', ')}`); if (subEl) subEl.textContent = parts.join(' • '); } catch {} linkWrap.innerHTML = ''; linkWrap.append(linkLabel, linkPicker); } catch {} }; // Initial render of merge section if (!useInput) refreshChips(); // Remove this room/entity del.addEventListener('click', () => { try { const arr = [...(this._config.entities || [])]; const modes = Array.isArray(this._config.room_use_input_number) ? [...this._config.room_use_input_number] : []; const primary = arr[idx]; // Remove the primary from entities arr.splice(idx, 1); try { if (modes.length) modes.splice(idx, 1); } catch {} // Clean up labels const labels = { ...(this._config.labels || {}) }; if (primary && labels[primary]) delete labels[primary]; // Clean up turn_on const turnOn = { ...(this._config.turn_on || {}) }; if (primary && turnOn[primary]) delete turnOn[primary]; // Clean up merges: remove mapping for this primary and remove it from other arrays const merges = { ...(this._config.merges || {}) }; if (primary && merges[primary]) delete merges[primary]; for (const k of Object.keys(merges)) { merges[k] = (merges[k] || []).filter((e) => e !== primary); if (!merges[k].length) delete merges[k]; } this._config.entities = arr; this._config.room_use_input_number = modes; this._config.labels = labels; this._config.merges = merges; this._config.turn_on = turnOn; this._emit(true); } catch (e) {} }); // When picker changes primary value, migrate merges pick.addEventListener("value-changed", (e)=>{ const oldPrimary = this._config.entities[idx]; const newPrimary = e.detail.value; // Auto-sync mode based on chosen domain (defensive) try { const modes = Array.isArray(this._config.room_use_input_number) ? [...this._config.room_use_input_number] : []; const ents = [...(this._config.entities || [])]; while (modes.length < ents.length) modes.push(false); if (typeof newPrimary === 'string') { if (newPrimary.startsWith('input_number.')) modes[idx] = true; else if (newPrimary.startsWith('climate.')) modes[idx] = false; } this._config.room_use_input_number = modes; } catch {} if (oldPrimary && newPrimary && oldPrimary !== newPrimary){ const merges = { ...(this._config.merges || {}) }; if (merges[oldPrimary] && !merges[newPrimary]) { merges[newPrimary] = merges[oldPrimary]; delete merges[oldPrimary]; } else if (merges[oldPrimary] && merges[newPrimary]) { merges[newPrimary] = Array.from(new Set([...(merges[newPrimary]||[]), ...(merges[oldPrimary]||[])])); delete merges[oldPrimary]; } this._config.merges = merges; // migrate temp sensor mapping to new primary key try { const sensors = { ...(this._config.temp_sensors || {}) }; if (sensors[oldPrimary] && !sensors[newPrimary]) sensors[newPrimary] = sensors[oldPrimary]; if (sensors[oldPrimary]) delete sensors[oldPrimary]; this._config.temp_sensors = sensors; } catch {} // migrate per-room turn_on mapping to new primary key try { const to = { ...(this._config.turn_on || {}) }; if (to[oldPrimary] && !to[newPrimary]) to[newPrimary] = to[oldPrimary]; if (to[oldPrimary]) delete to[oldPrimary]; this._config.turn_on = to; } catch {} } // Avoid mutating a potentially frozen array (some browsers/HA paths freeze config) try { const ents = [...(this._config.entities || [])]; ents[idx] = newPrimary; this._config.entities = ents; } catch { // Fallback (should not happen, but keep previous behavior if cloning fails) try { this._config.entities[idx] = newPrimary; } catch {} } // Move open-state key from old -> new entity id try { const oldKey = oldPrimary || `#idx:${idx}`; const newKey = newPrimary || `#idx:${idx}`; if (this._openRows.has(oldKey)) { this._openRows.delete(oldKey); this._openRows.add(newKey); } } catch {} try { nameInp.value = (this._config.labels || {})[newPrimary] || ""; if (nameInp && typeof nameInp.requestUpdate === 'function') nameInp.requestUpdate(); } catch {} if (this._openCount > 0) this._pendingEmit = true; else this._emit(true); try { this._pushSettingsToStoreDebounced(); } catch {} if (!this._roomUsesInputNumber(idx)) refreshChips(); // refresh per-room turn_on UI after primary change try { applyTurnOnUi(); } catch {} // Also update subtitle on primary change try { const subEl = line.querySelector('.summary-sub'); const eid = this._config.entities[idx]; const linkedNames = (this._config.merges?.[eid] || []).map(id => this._hass?.states?.[id]?.attributes?.friendly_name || (id.split('.')[1]||id)); const parts = []; if (eid) parts.push(eid); if (linkedNames.length) parts.push(`${this._t('editor.merged_with')}: ${linkedNames.join(', ')}`); if (subEl) subEl.textContent = parts.join(' • '); } catch {} }); return line; } // (Removed unused pointer-driven drag & drop prototype) _addEntity(){ this._config.entities = [...(this._config.entities||[]), ""]; try { const modes = Array.isArray(this._config.room_use_input_number) ? [...this._config.room_use_input_number] : []; modes.push(false); this._config.room_use_input_number = modes; } catch {} this._emit(true); } _upd(key,val){ this._config[key] = val; this._emit(); } _emit(repaint=false){ if (this._openCount > 0) { this._pendingEmit = true; return; } // Emit a fresh cloned config to ensure HA editor detects changes immediately try { this._config.respect_storage_weekdays = false; } catch {} let cfg; try { cfg = JSON.parse(JSON.stringify(this._config)); } catch { cfg = { ...this._config }; } // These settings are now edited in the in-card popup + stored in shared/local storage, // so they should not be emitted into YAML config. try { for (const k of ['row_height','default_temp','min_temp','max_temp','auto_apply','apply_on_edit','apply_on_default_change','per_room_defaults','show_pause_button','show_room_temp','pause_sensor_enabled','pause_sensor_entity','presence_sensor_enabled','presence_sensors','presence_sensor_temps','presence_sensor_delays','presence_sensor_delay_units','time_12h','time_source','temp_unit','profiles_enabled','weekdays_enabled','weekdays_view','weekdays_selected_room','boiler_enabled','boiler_switch','boiler_switch_domain','boiler_temp_sensor','boiler_min_temp','boiler_max_temp','boiler_multi_enabled','boiler_room_settings','open_window','color_ranges','color_global','respect_storage_weekdays']) { if (k in cfg) delete cfg[k]; } } catch {} // Always keep instance config in YAML (per-card) try { cfg.instance_enabled = !!this._config?.instance_enabled; cfg.instance_id = String(this._config?.instance_id || ''); cfg.instance_uid = String(this._config?.instance_uid || ''); } catch {} this.dispatchEvent(new CustomEvent("config-changed", { detail:{ config: cfg }, bubbles:true, composed:true })); if (repaint) this._render(); } _t(k){ return ttLocalize(k, this._hass || this._lang || 'en'); } _applyEditorI18n(){ try { const t = (k)=> this._t(k); const root = this.shadowRoot; if (!root) return; // Title row const titleRow = root.querySelector('.row .title')?.closest('.row'); const titleLabel = titleRow?.querySelector('.label') || root.querySelector('.row .label'); if (titleLabel) titleLabel.textContent = t('editor.title_label'); const titleField = root.querySelector('ha-textfield.title'); if (titleField) { titleField.setAttribute('label', t('editor.title_label')); // Show localized preset when empty, and keep it updated with language try { titleField.setAttribute('placeholder', this._t('card.title_default')); } catch {} } // Storage entity (use the .storage picker to find its label robustly) const storagePicker = root.querySelector('.storage'); if (storagePicker) { const storageLabel = storagePicker.closest('.row')?.querySelector('.label'); // Hide the label text for the storage section if (storageLabel) storageLabel.textContent = ''; // toggle texts const stTitle = root.querySelector('.store-title'); const stDesc = root.querySelector('.store-desc'); // Title + info icon (click to open popover) const stTitleText = root.querySelector('.store-title .store-title-text'); if (stTitleText) stTitleText.textContent = t('editor.store_enable.title'); if (stDesc) { const shortTxt = this._t('editor.store_enable.short'); const icon = root.querySelector('.info-icon'); const textSpan = root.querySelector('.store-desc-text'); if (textSpan) textSpan.textContent = shortTxt !== 'editor.store_enable.short' ? shortTxt : (this._t('editor.store_enable.title') || ''); // Build popover once let pop = root.querySelector('.info-pop'); if (!pop) { pop = document.createElement('div'); pop.className = 'info-pop hidden'; pop.setAttribute('role','dialog'); root.append(pop); } const openPop = () => { const desc = this._t('editor.store_enable.desc'); pop.textContent = desc; const r = icon.getBoundingClientRect(); const maxW = Math.min(520, Math.max(240, Math.floor(window.innerWidth*0.9))); pop.style.maxWidth = maxW + 'px'; let left = Math.max(8, Math.min(window.innerWidth - maxW - 8, r.left)); let top = r.bottom + 8; pop.style.left = left + 'px'; pop.style.top = top + 'px'; pop.classList.remove('hidden'); }; const closePop = () => { if (pop) pop.classList.add('hidden'); }; // Bind click/tap once if (icon && !icon.dataset.bound) { icon.dataset.bound = '1'; icon.addEventListener('click', (e) => { e.stopPropagation(); if (pop.classList.contains('hidden')) openPop(); else closePop(); }); icon.addEventListener('touchend', (e) => { try { e.preventDefault(); } catch{}; e.stopPropagation(); if (pop.classList.contains('hidden')) openPop(); else closePop(); }, { passive:false }); // Close on outside click or ESC const onOutside = (ev)=>{ const path = ev.composedPath ? ev.composedPath() : []; if (path.includes(icon) || path.includes(pop)) return; closePop(); }; window.addEventListener('click', onOutside); window.addEventListener('keydown', (ev)=>{ if (ev.key==='Escape') closePop(); }); } } // Check that the thermostat_timeline integration/service exists in Home Assistant try { const hasService = !!(this._hass && this._hass.services && this._hass.services['thermostat_timeline']); let warn = root.querySelector('.store-missing'); const warnText = this._t ? this._t('editor.store_missing') : null; const msg = warnText && warnText !== 'editor.store_missing' ? warnText : 'Integration "Thermostat Pro Timeline Sync" is not installed. Shared storage is unavailable.'; var githubLink = ' [Åbn i HACS]'; if (!warn) { warn = document.createElement('div'); warn.className = 'store-missing'; warn.style.color = 'var(--error-color)'; warn.style.fontSize = '.9rem'; warn.style.marginTop = '6px'; warn.style.display = 'none'; const controls = root.querySelector('.store-controls'); if (controls && controls.parentNode) controls.parentNode.insertBefore(warn, controls); } // Always update warning content // Sæt tekst og link som separate child nodes // Fjern alle eksisterende child nodes while (warn.firstChild) warn.removeChild(warn.firstChild); // Tilføj tekst og link igen warn.appendChild(document.createTextNode(msg.replace(/\.$/, '') + '. ')); let link = document.createElement('a'); link.href = 'https://my.home-assistant.io/redirect/hacs_repository/?owner=qlerup&repository=thermostat-pro-timeline-sync'; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.style.color = 'var(--error-color)'; link.style.textDecoration = 'underline'; link.style.fontWeight = '500'; link.textContent = this._t('editor.open_in_hacs'); warn.appendChild(link); if (!hasService) { warn.style.display = 'block'; const sw = root.querySelector('.store-enable'); if (sw) sw.disabled = true; const picker = root.querySelector('.storage'); if (picker) { try { picker.disabled = true; picker.setAttribute('disabled',''); } catch {} } } else { warn.style.display = 'none'; const sw = root.querySelector('.store-enable'); if (sw) sw.disabled = false; const picker = root.querySelector('.storage'); if (picker) { try { picker.disabled = false; picker.removeAttribute('disabled'); } catch {} } } // Update small status chip in tabs try { const chip = root.querySelector('.int-status'); const icon = chip?.querySelector('ha-icon'); const txt = chip?.querySelector('.text'); if (chip && icon && txt) { if (hasService) { chip.classList.remove('bad'); chip.classList.add('ok'); icon.setAttribute('icon', 'mdi:cloud-check-outline'); txt.textContent = t('editor.integration.connected') || 'Connected to integration'; } else { chip.classList.remove('ok'); chip.classList.add('bad'); icon.setAttribute('icon', 'mdi:cloud-off-outline'); txt.textContent = t('editor.integration.disconnected') || 'Not connected to integration'; } // Make the chip clickable to jump to Settings (Sync is inside Settings) chip.style.userSelect = 'none'; chip.addEventListener('click', ()=>{ try { this._activeTab = 'settings'; this._render(); } catch {} }); } } catch {} } catch {} // (clear storage only button removed) const clrAllBtn = root.querySelector('.clear-all span'); if (clrAllBtn) clrAllBtn.textContent = t('editor.clear_all'); const migBtn = root.querySelector('.migrate-to-store span'); if (migBtn) migBtn.textContent = t('editor.migrate_to_store'); } const clrLocalBtn = root.querySelector('.clear-local-only span'); if (clrLocalBtn) clrLocalBtn.textContent = t('editor.clear_local_only'); const resetOnbBtn = root.querySelector('.reset-onboard span'); if (resetOnbBtn) resetOnbBtn.textContent = t('onboard.reset'); // Entities section label (use the .entities container to find its label robustly) const entitiesWrap = root.querySelector('.entities'); if (entitiesWrap) { const entitiesLabel = entitiesWrap.closest('.row')?.querySelector('.label'); if (entitiesLabel) entitiesLabel.textContent = t('editor.select_entities'); } // Add room button text (target only the actual add-room control) const addBtn = root.querySelector('.add-room-btn span'); if (addBtn) addBtn.textContent = t('editor.add_entity'); const bMinLbl = root.querySelector('.boiler-min-temp-label'); if (bMinLbl) bMinLbl.textContent = (t('boiler.min_temp') || 'Min boiler temp (°C)').replace('°C', this._unitSymbol()); const bMaxLbl = root.querySelector('.boiler-max-temp-label'); if (bMaxLbl) bMaxLbl.textContent = (t('boiler.max_temp') || 'Max boiler temp (°C)').replace('°C', this._unitSymbol()); const bEnTitle = root.querySelector('.boiler-enable-title'); if (bEnTitle) bEnTitle.textContent = t('boiler.enable') || 'Enable boiler controls'; const bEnDesc = root.querySelector('.boiler-enable-desc'); if (bEnDesc) bEnDesc.textContent = t('boiler.enable.desc') || ''; const bSensTitle = root.querySelector('.boiler-sensor-title'); if (bSensTitle) bSensTitle.textContent = t('boiler.temp_sensor') || 'Boiler temperature sensor'; const bSensDesc = root.querySelector('.boiler-sensor-desc'); if (bSensDesc) bSensDesc.textContent = t('boiler.temp_sensor.desc') || ''; // Tabs labels try { const sBtn = root.querySelector('.tab-settings-btn'); if (sBtn) sBtn.textContent = t('editor.tabs.settings'); const rBtn = root.querySelector('.tab-rooms-btn'); if (rBtn) rBtn.textContent = t('editor.tabs.rooms'); const oBtn = root.querySelector('.tab-boiler-btn'); if (oBtn) oBtn.textContent = t('editor.tabs.boiler') || 'Boiler'; const cBtn = root.querySelector('.tab-colors-btn'); if (cBtn) cBtn.textContent = t('editor.tabs.colors'); const owBtn = root.querySelector('.tab-owd-btn'); if (owBtn) owBtn.textContent = t('editor.tabs.owd') || 'Open Window Detection'; const bBtn = root.querySelector('.tab-backup-btn'); if (bBtn) bBtn.textContent = t('editor.tabs.backup') || 'Backup'; const hBtn = root.querySelector('.tab-holidays-btn'); if (hBtn) hBtn.textContent = t('editor.tabs.holidays'); const boTitle = root.querySelector('.boiler-switch-title'); if (boTitle) boTitle.textContent = t('boiler.switch') || 'Boiler switch'; // Holidays tab inner labels try { const srcTitle = root.querySelector('.tab-holidays .settings-card .setting .title'); if (srcTitle) srcTitle.textContent = t('holidays.source'); const srcDesc = root.querySelector('.tab-holidays .settings-card .setting .desc'); if (srcDesc) srcDesc.textContent = t('holidays.desc'); const calTitle = root.querySelector('.tab-holidays .settings-card .setting:nth-child(2) .title'); if (calTitle) calTitle.textContent = t('holidays.entity'); const manTitle = root.querySelector('.tab-holidays .settings-card .setting:nth-child(3) .title'); if (manTitle) manTitle.textContent = t('holidays.dates'); const editBtns = root.querySelectorAll('.tab-holidays .inline .actions .btn'); editBtns.forEach(b=>{ try { b.textContent = t('holidays.edit'); } catch{} }); const segCal = root.querySelector('.tab-holidays .h-src-cal'); if (segCal) segCal.textContent = t('holidays.source.calendar'); const segMan = root.querySelector('.tab-holidays .h-src-man'); if (segMan) segMan.textContent = t('holidays.source.manual'); const addDate = root.querySelector('.tab-holidays .add-entity-btn span'); if (addDate) addDate.textContent = t('holidays.add_date'); } catch {} // (reload buttons removed) // Colors mode labels const cmTitle = root.querySelector('.colors-mode-title'); if (cmTitle) cmTitle.textContent = t('editor.colors.mode'); const cmDesc = root.querySelector('.colors-mode-desc'); if (cmDesc) cmDesc.textContent = t('editor.colors.mode.desc'); const cmRoom = root.querySelector('.colors-mode .mode-room'); if (cmRoom) cmRoom.textContent = t('editor.colors.mode_per_room'); const cmGlob = root.querySelector('.colors-mode .mode-global'); if (cmGlob) cmGlob.textContent = t('editor.colors.mode_global'); // Colors tab action label (single clear-all button) const clrAllBtn = root.querySelector('.clr-colors-all-btn span'); if (clrAllBtn) clrAllBtn.textContent = t('editor.colors.clear_all'); // Away tab labels const awayTitle = root.querySelector('.away-enable-title'); if (awayTitle) awayTitle.textContent = t('away.enable'); const awayDesc = root.querySelector('.away-desc'); if (awayDesc) awayDesc.textContent = t('away.desc'); const awayTemp = root.querySelector('.away-temp-title'); if (awayTemp) awayTemp.textContent = t('away.temp'); const awayPersons = root.querySelector('.away-persons-title'); if (awayPersons) awayPersons.textContent = t('away.persons'); // Presence labels in Away tab const advTitle = root.querySelector('.away-adv-title'); if (advTitle) advTitle.textContent = t('presence.enable_advanced'); const advDesc = root.querySelector('.away-adv-desc'); if (advDesc) advDesc.textContent = t('presence.enable_advanced.desc'); const pcTitle = root.querySelector('.presence-combos-title'); if (pcTitle) pcTitle.textContent = t('presence.combos'); const plTitle = root.querySelector('.presence-live-title'); if (plTitle) plTitle.textContent = t('presence.live_header'); const plDesc = root.querySelector('.presence-live-desc'); if (plDesc) plDesc.textContent = t('presence.live_header.desc'); const pcDesc = root.querySelector('.presence-combos-desc'); if (pcDesc) pcDesc.textContent = ''; } catch {} // Sync delay labels removed (controls removed) // Backup tab labels try { const bt = root.querySelector('.backup-title'); if (bt) bt.textContent = t('backup.title') || 'Backup'; const bd = root.querySelector('.backup-desc'); if (bd) bd.textContent = t('backup.desc') || 'Copy schedules/settings to backup and restore when needed.'; // Selection UI removed; no per-section labels to set const bn = root.querySelector('.backup-now-label'); if (bn) bn.textContent = t('backup.now') || 'Backup now'; const brl = root.querySelector('.backup-restore-label'); if (brl) brl.textContent = t('backup.restore') || 'Restore'; const bdl = root.querySelector('.backup-delete-label'); if (bdl) bdl.textContent = t('backup.delete') || 'Delete'; const bitl = root.querySelector('.backup-import-title'); if (bitl) bitl.textContent = t('backup.import.title') || 'Import thermostat_timeline.json'; const bidl = root.querySelector('.backup-import-desc'); if (bidl) bidl.textContent = t('backup.import.desc') || ''; const bibl = root.querySelector('.backup-import-btn-label'); if (bibl) bibl.textContent = t('backup.import.btn') || 'Import file'; const betl = root.querySelector('.backup-export-title'); if (betl) betl.textContent = t('backup.export.title') || 'Export backup file'; const bedl = root.querySelector('.backup-export-desc'); if (bedl) bedl.textContent = t('backup.export.desc') || 'Download a full thermostat_timeline_backup.json with all slots and data.'; const bebl = root.querySelector('.backup-export-btn-label'); if (bebl) bebl.textContent = t('backup.export.btn') || 'Download file'; const ibt = root.querySelector('.backup-importbk-title'); if (ibt) ibt.textContent = t('backup.importbk.title') || 'Import backup file'; const ibd = root.querySelector('.backup-importbk-desc'); if (ibd) ibd.textContent = t('backup.importbk.desc') || 'Upload a thermostat_timeline_backup.json file to append its backups to the list.'; const ibl = root.querySelector('.backup-importbk-btn-label'); if (ibl) ibl.textContent = t('backup.importbk.btn') || 'Import backup'; const ifc = root.querySelector('.backup-file-choose'); if (ifc) ifc.textContent = t('backup.file.choose') || 'Choose imported backup'; const ifas = root.querySelector('.backup-file-add-selected'); if (ifas) ifas.textContent = t('backup.file.add_selected') || 'Add selected'; const ifaa = root.querySelector('.backup-file-add-all'); if (ifaa) ifaa.textContent = t('backup.file.add_all') || 'Add all'; const bat = root.querySelector('.backup-auto-title'); if (bat) bat.textContent = t('backup.auto'); const bad = root.querySelector('.backup-auto-desc'); if (bad) bad.textContent = t('backup.auto.desc'); const bit = root.querySelector('.backup-int-title'); if (bit) bit.textContent = t('backup.interval'); const bid = root.querySelector('.backup-int-desc'); if (bid) bid.textContent = t('backup.interval.desc'); const bst = root.querySelector('.backup-sensor-title'); if (bst) bst.textContent = t('backup.sensor'); const blt = root.querySelector('.backup-last-title'); if (blt) blt.textContent = t('backup.last'); } catch {} } catch(e){ /* ignore */ } } // Detect whether a given title equals any built-in localized default _isDefaultTitle(txt){ try { const s = String(txt || '').trim(); for (const k of Object.keys(TT_I18N || {})){ const v = TT_I18N[k] && TT_I18N[k]['card.title_default']; if (v && String(v).trim() === s) return true; } } catch {} return false; } } class UiHelper { // BinarydustFF - fix // Block's pills (showing temperature) were rendered differently between modes // Creating a unique helper to unify the rendering method /** * Generate a pill element inside a parent block * @param {ThermostatTimelineCard} ttcInst Current `ThermostatTimelineCard` instance * @param {HTMLDivElement} parentBlock * @param {string} content The string content * @returns {HTMLSpanElement} The generated pill */ static genBlockPill(ttcInst, parentBlock, content) { // Pill creation const pill = document.createElement('span'); pill.className = 'pill'; pill.textContent = content; parentBlock.append(pill); // Pill styling try { const blcBgClr = parentBlock.style.backgroundColor || ''; if (!blcBgClr) { // possible? // console.warn("ThermostatTimelineCard: Block without background color", parentBlock) // Skip the styling part return pill } const pillTxtClr = parentBlock.style.color || ttcInst._contrastTextColor(blcBgClr) || ''; pill.style.backgroundColor = pillTxtClr === '#ffffff' ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.06)'; pill.style.backgroundColor = pillTxtClr === '#ffffff' ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.12)'; } catch {} return pill; } } // Avoid hard failures if the editor gets registered elsewhere/earlier. try { if (!customElements.get("thermostat-timeline-card-editor")) { customElements.define("thermostat-timeline-card-editor", ThermostatTimelineCardEditor); } } catch {} // Registrér i “Custom cards” window.customCards = window.customCards || []; window.customCards.push({ type: "thermostat-timeline-card", name: "Thermostat Pro Timeline Card", description: "24h tidslinje – transition-baseret set_temperature + smart replan & apply-on-change", version: TT_CARD_VERSION }); function loadCard() {} loadCard();