Files
HomeAssistantVS/www/community/lovelace-thermostat-pro-timeline/thermostat-pro-timeline.js
T
2026-07-08 10:43:39 -04:00

19796 lines
1.1 MiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.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.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.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': 'Dont 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.',
'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 climateentiteter 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 °Cfelt 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 climateentitet.',
'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.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 IDer.',
'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.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 kedelswitchen.',
'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 syncsensor til backupsensoren 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.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æktemperatur',
'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': 'Pauseknap',
'editor.pausebtn.desc': 'Vis en global pauseknap i kortets header. Når pause er aktiv, undertrykkes alle set_temperaturekommandoer.',
'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.',
'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': 'Helligdagsentitet',
'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': 'Helligdagsskema',
'holidays.edit': 'Redigér helligdagsskema',
'holidays.button': 'Helligdage',
// Holiday copy-from popup
'holidays.copy_from': 'Kopiér fra…',
'holidays.copy.title': 'Kopiér til helligdagsskema',
'holidays.copy.select': 'Vælg kilde',
'holidays.copy.apply': 'Indsæt',
'holidays.copy.replace_confirm': 'Erstat eksisterende helligdagsblokke?',
'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 tilstedeskemaer',
'presence.enable_advanced.desc': 'Brug kombinationer og skemaer baseret på hvem der er hjemme/ude.',
// Away delay
'away.delay_enable': 'Forsink Awayhandlinger',
'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 Termostattidslinje',
'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 syncsensor till backupsensorn 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': 'Backupsensor',
'backup.last': 'Senaste backup',
'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 autoapply',
'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_temperaturekommandon.',
'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': 'Helgdagsentitet'
, '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': 'Helgdagsschema'
, 'holidays.edit': 'Redigera helgdagsschema'
, 'holidays.button': 'Helgdagar'
, 'editor.perroom.title': 'Standard °C per rum'
, 'editor.perroom.desc': 'Visa ett Standard °Cfä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': 'Helgdagsscheman'
, '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.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 panntemp (°C)'
, 'boiler.max_temp': 'Max panntemp (°C)'
, 'boiler.offsets': 'Offset'
, 'boiler.offsets.desc': 'ONoffset: hur långt under schemamålet innan den slår PÅ. OFFoffset: 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 livenä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.'
, '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.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 autoapply',
'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_temperaturekommandoer.',
'editor.tabs.sync': 'Synkmotor',
'editor.tabs.backup': 'Backup',
'backup.title': 'Backup',
'backup.desc': 'Kopier innholdet i synksensoren til backupsensoren 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': 'Backupsensor',
'backup.last': 'Siste backup',
'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': 'Helligdagsentitet'
, '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': 'Helligdagsskjema'
, 'holidays.edit': 'Rediger helligdagsskjema'
, 'holidays.button': 'Helligdager'
, 'editor.perroom.title': 'Standard °C per rom'
, 'editor.perroom.desc': 'Vis et Standard °Cfelt på hver rad. Når av, brukes global Standard °C for alle rom.'
, 'away.resume_schedule': 'Gjenoppta skjema'
, 'away.resume_away': 'Gjenoppta bortetemperatur'
, 'presence.button': 'Tilstedeskjemaer'
, 'presence.editor.title': 'Tilstedeskjemaer'
, 'presence.enable_advanced': 'Aktiver avanserte tilstedeskjemaer'
, 'presence.enable_advanced.desc': 'Bruk kombinasjoner og skjemaer basert på hvem som er hjemme/borte.'
, 'away.delay_enable': 'Forsink bortehandlinger'
, 'away.delay_desc': 'Vent før borteendringer 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 bortemodus'
, 'away.desc': 'Når ingen av de valgte personene er hjemme, senkes termostatene automatisk til den angitte temperaturen.'
, 'away.temp': 'Bortetemperatur'
, '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': 'Tilstedeskjemaer'
, 'backup.opt.settings': 'Redigeringsinnstillinger'
, 'backup.opt.holiday': 'Helligdagsskjemaer'
, '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ærelsesentitet'
, '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.enable': 'Aktiver kjelstyring'
, 'boiler.enable.desc': 'Vis kjelinnstillinger i denne korteditoren.'
, '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.'
, '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.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 BrowserZeit oder die HomeAssistantZeitzone 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 ThermostatZeitlinie',
'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': 'SyncEngine',
'sync.running': 'Synchronisierung läuft…',
'sync.completed': 'Synchronisierung abgeschlossen',
'editor.tabs.backup': 'Backup',
'backup.title': 'Backup',
'backup.desc': 'Kopiere den Inhalt deines SyncSensors in den BackupSensor 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': 'BackupSensor',
'backup.last': 'Letztes Backup',
'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': 'AutoAnwendung 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': 'PauseSchaltfläche',
'editor.pausebtn.desc': 'Zeigt eine globale PauseSchaltfläche in der Kopfzeile. Während der Pause werden alle set_temperatureBefehle 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': 'FeiertagsEntitä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': 'FeiertagsZeitplan'
, 'holidays.edit': 'FeiertagsZeitplan bearbeiten'
, 'holidays.button': 'Feiertage'
, 'editor.perroom.title': 'Standard‑°C pro Raum'
, 'editor.perroom.desc': 'Zeigt ein Standard‑°CFeld in jeder Zeile. Wenn aus, wird die globale Standard‑°C für alle Räume verwendet.'
, 'away.resume_schedule': 'Zeitplan fortsetzen'
, 'away.resume_away': 'AbwesenheitsTemperatur 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': 'AbwesenheitsAktionen 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': 'AbwesenheitsTemperatur'
, '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': 'WochentagsZeitpläne'
, 'backup.opt.presence': 'Anwesenheitspläne'
, 'backup.opt.settings': 'EditorEinstellungen'
, 'backup.opt.holiday': 'FeiertagsZeitplä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 AnwesenheitssensorKonfiguration 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.enable': 'Kesselsteuerung aktivieren'
, 'boiler.enable.desc': 'Kesseleinstellungen in diesem KartenEditor anzeigen.'
, 'boiler.temp_sensor': 'KesselTemperatursensor'
, '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': 'ONOffset: wie weit unter dem ZeitplanSollwert bevor EIN geschaltet wird. OFFOffset: wie weit über dem ZeitplanSollwert bevor AUS geschaltet wird.'
, 'boiler.on_offset': 'ONOffset (°C)'
, 'boiler.off_offset': 'OFFOffset (°C)'
, 'editor.temp_sensor': 'Temperatursensor'
, 'presence.live_header': 'LiveAnwesenheit 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.'
, '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.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.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 autoaplicació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.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.'
, '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.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 dentité 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 cest 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': 'Lorsquil est activé, la carte essaie de régler la température des entités climate sélectionnées pour quelle 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 lheure actuelle, la nouvelle température est appliquée immédiatement.',
'editor.apply_default.title': 'Lors dun 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 len-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èreplan',
'editor.store_enable.short': 'Enregistre les plannings dans le capteur sélectionné et active le contrôle en arrièreplan.',
'editor.store_enable.desc': 'Enregistre les plannings dans le capteur sélectionné et active set_temperature en arrièreplan. 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': 'Lintégration « Thermostat Pro Timeline Sync » nest pas installée. Le stockage partagé nest 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 dabord 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 danciens 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 sappliquent lors de lenregistrement.',
'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 lheure',
'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 laffichage dans la carte et les éditeurs.',
'editor.timesrc.title': 'Source de lheure',
'editor.timesrc.desc': 'Choisir si la carte utilise lheure 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 doublecliquez sur la chronologie.',
'onboard.step_edit': 'Modifiez/Supprimez un bloc par doubleclic. Utilisez les jours de semaine pour des jours différents.',
'onboard.reset': 'Afficher le guide à nouveau',
'onboard.reset_ok': 'La présentation saffichera 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.slot.title': 'Sélectionner une sauvegarde',
'backup.slot.desc': 'Sélectionnez lemplacement de sauvegarde à restaurer.',
'backup.slot.empty': 'Aucune sauvegarde pour linstant',
'pause.menu_title': 'Suspendre lapplication 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 lentê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 dabsence'
, '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 dabsence'
, 'away.delay_desc': 'Attendre avant dappliquer les changements dabsence.'
, '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 nest pas à la maison'
, 'away.enable': 'Activer le mode absence'
, 'away.desc': 'Lorsque aucune des personnes sélectionnées nest à la maison, les thermostats sont automatiquement abaissés à la température définie.'
, 'away.temp': 'Température dabsence'
, '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 dappliquer la présence'
, 'editor.presence_sensor.on_delay.desc': 'Durée pendant laquelle le capteur doit rester activé avant dutiliser la température de présence. La valeur utilise lunité 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 lunité 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 dentité"
, 'boiler.switch_type.switch': 'Interrupteur'
, 'boiler.switch_type.input_boolean': "Booléen dentrée"
, 'boiler.rooms': 'Pièces incluses'
, 'boiler.rooms.desc': 'Sélectionnez les pièces à inclure 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 linterrupteur 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 dallumer. Décalage OFF : à combien audessus 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 lentê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.'
, '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 len-tête de la frise pour basculer entre les deux modes de vue des jours de semaine.'
, '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 dabord.'
, '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 nellintestazione 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 leditor 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 nellintestazione della timeline per passare tra le due modalità di visualizzazione dei giorni feriali.'
, '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 lunità 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 lunità 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.enable': 'Attiva controllo caldaia'
, 'boiler.enable.desc': 'Mostra le impostazioni caldaia nelleditor di questa scheda.'
, 'boiler.temp_sensor': 'Sensore temperatura caldaia'
, 'boiler.temp_sensor.desc': 'Sensore di temperatura usato per controllare linterruttore 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 lobiettivo programmato prima di accendere. Offset OFF: quanto sopra lobiettivo 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.'
, '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.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 nellangolo 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_temperatureohjauksen. 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': 'Synkronointimoottori',
'sync.running': 'Synkronointi käynnissä…',
'sync.completed': 'Synkronointi valmis',
'editor.tabs.backup': 'Varmuuskopio',
'backup.title': 'Varmuuskopio',
'backup.desc': 'Kopioi syncanturin 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.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_temperaturekomennot 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ä poissaolotoimia'
, '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.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ötilaanturi'
, 'boiler.temp_sensor.desc': 'Lämpötilaanturi, joka ohjaa kattilakytkintä.'
, 'boiler.min_temp': 'Kattilan minimi (°C)'
, 'boiler.max_temp': 'Kattilan maksimi (°C)'
, 'boiler.offsets': 'Offsetit'
, 'boiler.offsets.desc': 'ONoffset: kuinka paljon alle aikataulun tavoitteen ennen kuin kytkeytyy päälle. OFFoffset: 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ötilaanturi'
, 'presence.live_header': 'Näytä livelä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.'
, '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.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.',
'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.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 autoapply',
'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.',
'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.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.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.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.'
, '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.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'
},
};
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.01.18-boiler-domain";
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)
weekdays_enabled: false,
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_enabled: Object.prototype.hasOwnProperty.call(config, 'boiler_enabled'),
boiler_switch: Object.prototype.hasOwnProperty.call(config, 'boiler_switch'),
boiler_switch_domain: Object.prototype.hasOwnProperty.call(config, 'boiler_switch_domain'),
boiler_rooms: Object.prototype.hasOwnProperty.call(config, 'boiler_rooms'),
boiler_on_offset: Object.prototype.hasOwnProperty.call(config, 'boiler_on_offset'),
boiler_off_offset: Object.prototype.hasOwnProperty.call(config, 'boiler_off_offset'),
boiler_temp_sensor: Object.prototype.hasOwnProperty.call(config, 'boiler_temp_sensor'),
boiler_min_temp: Object.prototype.hasOwnProperty.call(config, 'boiler_min_temp'),
boiler_max_temp: Object.prototype.hasOwnProperty.call(config, 'boiler_max_temp'),
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'),
holidays_enabled: Object.prototype.hasOwnProperty.call(config, 'holidays_enabled'),
holidays_source: Object.prototype.hasOwnProperty.call(config, 'holidays_source'),
holidays_entity: Object.prototype.hasOwnProperty.call(config, 'holidays_entity'),
holidays_dates: Object.prototype.hasOwnProperty.call(config, 'holidays_dates'),
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,
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),
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.
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.enabled = false;
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();
// 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._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
// 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;
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 }
// 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/<repo>) 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;
// 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;
}
}
}
} 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 {}
// 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 (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];
});
}
}
}
} 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' && !this._yamlProvided?.boiler_switch) this._config.boiler_switch = s.boiler_switch || '';
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;
// 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.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.
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.enabled = false; 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 {}
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' && !this._yamlProvided?.boiler_switch) this._config.boiler_switch = s.boiler_switch || '';
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.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.
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.enabled = false; 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 || {};
}
}
catch { this._schedules = {}; }
}
_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 {}
// 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_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),
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;
}
} 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 {}
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._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) {
// 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 {}
}
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 {}
}
} catch {}
return Number.isFinite(maxV) ? maxV : -Infinity;
}
// Build payload for shared storage respecting selected unit for numbers
_makeStoragePayload(includeSchedules=true){
try {
// 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.
try {
awayOut.persons = Array.isArray(awayOut.persons) ? awayOut.persons.filter(Boolean).map(String) : [];
if (!awayOut.persons.length) { awayOut.enabled = false; 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,
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_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_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),
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 {}
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';
}
}
_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']; }
_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<<n); mask++){
const home=[], away=[];
for (let i=0;i<n;i++){ ((mask>>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 (allhome 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 allday 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 sw = String(this._config.boiler_switch || '');
if (!sw) return;
const dom = String(sw).split('.')[0];
if (!(dom === 'switch' || dom === 'input_boolean')) 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 sw = String(this._config.boiler_switch || '');
if (!sw) 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 nowMin = this._getNowMin();
const allRooms = (Array.isArray(this._config.entities) ? this._config.entities : [])
.map(e=>String(e||''))
.filter(e=>e.startsWith('climate.'));
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 };
// When bypassing or using advanced presence, disable simple Away in backend apply
if (this._awayBypass || _awayOut.advanced_enabled) _awayOut.enabled = false;
const payload = this._makeStoragePayload(false);
payload.settings = { ...payload.settings, away: _awayOut };
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 = `
<meta charset="UTF-8">
<style>
:host { display:block; }
.card{ --pad-x:16px; background: var(--card-background-color); color: var(--primary-text-color); border-radius: var(--ha-card-border-radius, 12px); box-shadow: var(--ha-card-box-shadow, none); overflow:hidden }
.header{display:grid;align-items:center;grid-template-columns: minmax(0,1fr) auto minmax(0,1fr);gap:.75rem;padding:12px var(--pad-x);border-bottom:1px solid var(--divider-color); position:relative}
/* Explicit grid placement so right tools don't drift when center is hidden */
.title{ grid-column:1; display:flex; align-items:center; gap:8px; font-weight:600;font-size:1rem;color:var(--primary-text-color); min-width:0; overflow:hidden}
.title .title-text{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* Right header area aligns content to far right */
.header .spacer{ grid-column:3; justify-self:end; display:flex; justify-content:flex-end; min-width:0; overflow:hidden;}
/* Right-side header toolbar (lives inside .spacer) */
.header .pause-wrap{ display:flex; align-items:center; gap:8px; }
.header .pause-wrap .pause-eta{ font-size:.82rem; color: var(--primary-text-color); min-width:58px; text-align:center; padding:2px 8px; border:1px solid var(--divider-color); border-radius:999px; background: var(--secondary-background-color, rgba(0,0,0,.05)); }
/* Presence live chips (away persons status) */
.header .pause-wrap .presence-live{ display:flex; align-items:center; gap:6px; flex-wrap:nowrap; max-width:50vw; overflow:hidden; }
.header .pause-wrap .presence-live .presence-chip{ display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border:1px solid var(--divider-color); border-radius:999px; background: var(--secondary-background-color, rgba(0,0,0,.05)); font-size:.82rem; max-width:160px; white-space:nowrap; text-overflow:ellipsis; overflow:hidden; }
.header .pause-wrap .presence-live .presence-chip .presence-dot{ width:8px; height:8px; border-radius:50%; background: var(--divider-color); flex:0 0 8px; }
.header .pause-wrap .presence-live .presence-chip.home .presence-dot{ background: var(--success-color, #2e7d32); }
.header .pause-wrap .presence-live .presence-chip.away .presence-dot{ background: color-mix(in oklab, var(--error-color, #b00020) 60%, #fff); }
@supports not (color-mix(in oklab, #000 0%, #000 0%)){
.header .pause-wrap .presence-live .presence-chip.away .presence-dot{ background: var(--error-color, #b00020); }
}
.weekday-full{
grid-column:2;
display:grid;
grid-template-columns: auto 1fr auto;
align-items:center;
gap:8px;
text-align:center;
font-weight:700;
font-size:1.15rem;
color:var(--primary-text-color);
min-width:0;
}
.weekday-full .wd-left{ display:flex; align-items:center; justify-content:flex-end; gap:6px; }
.weekday-full .wd-center{ display:inline-flex; align-items:center; justify-content:center; gap:6px; min-width:0; }
.weekday-full .wd-right{ display:flex; align-items:center; justify-content:flex-start; gap:6px; }
/* Weekday view switch (inline, next to weekday controls) */
.header .sp-week-view-inline{ display:inline-flex; align-items:center; height:26px; border-radius:999px; overflow:hidden; border:1px solid var(--divider-color); background: var(--card-background-color); pointer-events:auto; }
.header .pause-wrap .sp-week-view-inline{ margin-right:8px; }
.header .sp-week-view-inline button{ height:26px; display:inline-flex; align-items:center; padding:0 10px; font-size:.78rem; font-weight:700; line-height:1.1; border:0; cursor:pointer; background:transparent; color: var(--secondary-text-color); pointer-events:auto; }
.header .sp-week-view-inline button.active{ background: var(--primary-color); color: var(--text-primary-color, #fff); }
.header .sp-week-view-inline button:focus{ outline:none; }
.header .sp-week-view-inline button:focus-visible{ outline:2px solid var(--primary-color); outline-offset:2px; border-radius:8px; }
/* Default: show switch in top row (desktop), hide on weekday row */
.weekday-full .sp-week-view-mobile{ display:none; }
.header .pause-wrap .sp-week-view-desktop{ display:inline-flex; }
/* Mobile: split header into 2 rows (title/tools on top, weekday nav below) */
@media (max-width: 520px){
.header{ grid-template-columns: 1fr auto; grid-template-rows: auto auto; row-gap: 8px; }
.title{ grid-column: 1; grid-row: 1; min-width: 0; }
.header .spacer{ grid-column: 2; grid-row: 1; }
.weekday-full{
grid-column: 1 / 3;
grid-row: 2;
width: 100%;
display:flex;
align-items:center;
justify-content:space-between;
}
.weekday-full .wd-left{ justify-content:flex-start; }
.weekday-full .wd-center{ margin-left:auto; justify-content:flex-end; }
/* right container is unused on mobile (arrows are in center) */
.weekday-full .wd-right{ display:none; }
/* Mobile: show switch on row 2 by arrows, hide in top row */
.weekday-full .sp-week-view-mobile{ display:inline-flex; }
.header .pause-wrap .sp-week-view-desktop{ display:none; }
}
.profile-pill{ font-weight:600; font-size:.85rem; padding:2px 8px; border-radius:999px; border:1px solid var(--divider-color); background: var(--secondary-background-color, rgba(0,0,0,.05)); color: var(--primary-text-color); }
.profile-pill.success{ background: var(--success-color, #2e7d32); color:#fff; border-color: var(--success-color, #2e7d32); }
.scale{position:relative;padding:8px var(--pad-x);z-index:0;margin-bottom:6px;border-bottom:1px solid var(--divider-color)}
.scale-inner{position:relative;height:48px;display:block}
.rows{padding:0 var(--pad-x);position:relative}
.tick{position:absolute;width:0}
.tick.full{top:6px;bottom:8px;border-left:1px solid var(--divider-color)}
.tick.short{top:22px;bottom:8px;border-left:1px solid var(--divider-color);opacity:1}
.tick label{position:absolute;bottom:14px;left:50%;transform:translate(-50%,0);user-select:none;font-weight:500;font-size:.75rem;color:var(--secondary-text-color)}
.tick label .t-main{display:block; line-height:1;}
.tick label .mer{display:block; line-height:1; font-size:.65rem; text-align:center;}
.row{border-bottom:1px solid var(--divider-color)}
.row-head{display:flex;align-items:center;justify-content:space-between;padding:8px 0;background:transparent}
.row-head .meta{display:flex;align-items:center;gap:.5rem;font-size:.8rem;color:var(--secondary-text-color)}
.row-head input[type=number]{width:64px;padding:4px 6px;border:1px solid var(--divider-color);border-radius:8px;background:var(--card-background-color);color:var(--primary-text-color);font-size:.8rem}
/* Normalize button heights inside row header to avoid vertical shift */
.row-head .btn{ display:inline-flex; align-items:center; height:32px; box-sizing:border-box; white-space: nowrap; }
/* Collapsible tools on each row (Add/Copy/Weekdays) */
.row-head .toolbar { display:flex; align-items:center; gap:6px; }
/* Smooth slide container */
.row-head .tools-wrap { display:flex; align-items:center; gap:6px; overflow:hidden; max-width:0; opacity:.0; transform: translateX(6px); white-space: nowrap;
transition: max-width .35s cubic-bezier(.25,.8,.25,1), opacity .28s ease, transform .35s cubic-bezier(.25,.8,.25,1); will-change: max-width, opacity, transform; }
.row-head .toolbar.open .tools-wrap { max-width:1000px; opacity:1; transform: translateX(0); }
/* Per-button slide + fade for a more fluid feel (no scale to avoid vertical jump) */
.row-head .tools-wrap > .btn { opacity:0; transform: translateX(12px); transition: transform .28s ease, opacity .28s ease; will-change: transform, opacity; flex: 0 0 auto; white-space: nowrap; }
.row-head .toolbar.open .tools-wrap > .btn { opacity:1; transform: translateX(0); }
/* Header toolbar (same motion as row toolbars) */
.header .toolbar { display:flex; align-items:center; gap:6px; }
.header .tools-wrap { display:flex; align-items:center; gap:6px; overflow:hidden; max-width:0; opacity:.0; transform: translateX(6px); white-space: nowrap;
transition: max-width .35s cubic-bezier(.25,.8,.25,1), opacity .28s ease, transform .35s cubic-bezier(.25,.8,.25,1); will-change: max-width, opacity, transform; }
.header .toolbar.open .tools-wrap { max-width:1000px; opacity:1; transform: translateX(0); }
.header .tools-wrap > .btn, .header .tools-wrap > .profiles-btn { opacity:0; transform: translateX(12px); transition: transform .28s ease, opacity .28s ease; will-change: transform, opacity; flex: 0 0 auto; white-space: nowrap; }
.header .toolbar.open .tools-wrap > .btn, .header .toolbar.open .tools-wrap > .profiles-btn { opacity:1; transform: translateX(0); }
.header .tools-toggle { width:24px; height:24px; border-radius:999px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; font-size:14px; font-weight:700; line-height:1; transition: background-color .2s ease, border-color .2s ease, color .2s ease; }
.header .toolbar:not(.open) .tools-toggle { background: var(--primary-color); border-color: var(--primary-color); color: var(--text-primary-color, #fff); }
.header .toolbar.open .tools-toggle { background: var(--error-color); border-color: var(--error-color); color: var(--text-primary-color, #fff); }
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.row-head .tools-wrap, .row-head .tools-wrap > .btn { transition: none; }
}
.row-head .tools-toggle {
width:24px; height:24px; border-radius:999px; box-sizing:border-box;
border:1px solid var(--divider-color);
background: var(--card-background-color); color: var(--primary-text-color);
display:inline-flex; align-items:center; justify-content:center; cursor:pointer;
font-size:14px; font-weight:700; line-height:1;
transition: background-color .2s ease, border-color .2s ease, color .2s ease;
}
.row-head .tools-toggle ha-icon{ --mdc-icon-size: 14px; }
/* Closed = primary (same blue as Add block) */
.row-head .toolbar:not(.open) .tools-toggle { background: var(--primary-color); border-color: var(--primary-color); color: var(--text-primary-color, #fff); }
/* Open = danger/red */
.row-head .toolbar.open .tools-toggle { background: var(--error-color); border-color: var(--error-color); color: var(--text-primary-color, #fff); }
/* Remove blue ring on click/focus */
.row-head .tools-toggle:focus, .row-head .tools-toggle:focus-visible, .row-head .tools-toggle:active {
outline:none; box-shadow:none;
}
/* Current temperature bubble */
.sensor-bubble{ display:inline-flex; align-items:center; gap:6px; margin-left:8px; padding:2px 8px; border:1px solid var(--divider-color); border-radius:999px; background: var(--secondary-background-color, rgba(0,0,0,.05)); color: var(--primary-text-color); font-size:.78rem; }
.btn{cursor:pointer;border:1px solid var(--divider-color);border-radius:10px;padding:6px 10px;font-size:.8rem;background:var(--card-background-color);color:var(--primary-text-color)}
/* Make row toolbar buttons a bit lower than default */
.row-head .btn{ padding:4px 10px; border-radius:8px; font-size:.78rem; }
.btn.primary{background:var(--primary-color);color:var(--text-primary-color, #fff);border-color:var(--primary-color)}
.btn.success{background:var(--success-color, #2e7d32);color:#fff;border-color:var(--success-color, #2e7d32)}
.btn.danger{background:var(--error-color);color:var(--text-primary-color, #fff);border-color:var(--error-color)}
.btn.ghost{background:var(--card-background-color);color:var(--primary-text-color)}
.btn[disabled]{ opacity:.5; cursor:not-allowed }
.track{position:relative;height:var(--row-height,64px);user-select:none;background:transparent}
.block{position:absolute;top:6px;bottom:6px;border:1px solid var(--divider-color);border-radius:10px;background:var(--secondary-background-color, rgba(0,0,0,.05));display:flex;align-items:center;gap:10px;padding:0 10px;cursor:pointer;box-sizing:border-box}
.block.active{outline:2px solid var(--primary-color)}
.pill{font-size:.78rem;padding:2px 8px;border-radius:999px;border:1px solid var(--divider-color);background:var(--secondary-background-color, rgba(0,0,0,.05));color:inherit;white-space:nowrap}
.now{position:absolute;top:var(--now-extend-top,-76px);bottom:0;width:2px;background:var(--primary-color);opacity:.9;z-index:1;pointer-events:none}
.now-top{position:absolute;top:6px;bottom:6px;width:3px;background:var(--primary-color);border-radius:2px;z-index:40;pointer-events:none}
.footer{height:28px;position:relative;border-top:1px solid var(--divider-color)}
.label-end{position:absolute;left:var(--pad-x);right:var(--pad-x);top:4px;display:flex;justify-content:space-between;font-size:.7rem;color:var(--secondary-text-color)}
.overlay{position:fixed;inset:0;display:none;place-items:center;background:rgba(0,0,0,.32);z-index:2147483000}
.overlay.open{display:grid}
/* Ensure modal content is always above any card decorations/lines */
.overlay .modal{ position: relative; z-index:2147483001; }
.overlay .modal .week-scale,
.overlay .modal .week-track{ position:relative; z-index:2147483002; }
.modal{width:min(80vw,320px);max-width:320px;border-radius:8px;padding:10px;background:var(--card-background-color);color:var(--primary-text-color);border:1px solid var(--divider-color)}
/* Wider modal when 12h editor controls (AM/PM) are shown */
.modal.modal-12h{ width: min(92vw, 400px); max-width: 400px; }
/* Wider weekday editor */
.modal-week{ width: min(95vw, 640px); max-width: 640px; position: relative; }
.modal-week .week-head{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin:0 0 6px; }
.modal-week .week-room{ font-weight:600; color: var(--primary-text-color); opacity:.9; }
/* Indent scale and track so ticks/blocks are not against the edge */
.modal-week .week-scale{ margin: 0 16px; }
.modal-week .week-track{ margin: 0 16px; }
/* Weekday tabs */
.modal-week .week-tabs{ display:flex; gap:8px; flex-wrap:wrap; border-bottom: 1px solid var(--divider-color); padding-bottom:4px; }
.modal-week .wk-tab{ appearance:none; border:none; background:transparent; color: var(--secondary-text-color); cursor:pointer; padding:6px 10px; border-bottom:2px solid transparent; border-radius:0; font-weight:600; }
.modal-week .wk-tab.active{ color: var(--primary-text-color); border-bottom-color: var(--primary-color); }
.modal-week .wk-actions{ display:flex; gap:8px; align-items:center; }
/* Copy to rooms modal */
.modal-copyrooms{ width:min(92vw,420px); max-width:420px; }
.copyrooms-list{ max-height: 260px; overflow:auto; border:1px solid var(--divider-color); border-radius:8px; padding:6px; display:grid; gap:6px; }
.copyrooms-list label{ display:flex; align-items:center; gap:8px; }
/* Onboarding modal */
.modal-onboard{ width:min(96vw,820px); max-width:820px; }
.ob-hero{ position:relative; width:100%; height:auto; max-height:60vh; border:1px dashed var(--divider-color); border-radius:10px; display:grid; place-items:center; overflow:hidden; background: var(--secondary-background-color, transparent); }
.ob-hero img, .ob-hero video{ display:block; width:100%; height:auto; max-height:100%; object-fit:contain; }
.ob-text{ margin-top:8px; font-size:.95rem; color: var(--primary-text-color); }
.ob-steps{ display:flex; gap:6px; justify-content:center; margin-top:6px; }
.ob-steps .dot{ width:8px; height:8px; border-radius:50%; background: var(--divider-color); }
.ob-steps .dot.active{ background: var(--primary-color); }
/* Inline tooltip for weekly blocks */
.wk-tooltip{ position:absolute; z-index:2147483003; pointer-events:none; padding:6px 8px; border-radius:8px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size:.85rem; box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0,0,0,.2)); }
/* While any overlay is open, hide underlying timeline elements to avoid bleed-through lines from the editor */
.card.overlay-open .scale,
.card.overlay-open .rows,
.card.overlay-open .footer { visibility: hidden; }
.card.overlay-open .now { display: none !important; }
/* Hide temp pill inside weekly preview blocks for cleaner look */
.modal-week .week-track .block .pill{ display:none; }
.modal-week .week-scale-inner .mer{ display:block; font-size:.65rem; color: var(--secondary-text-color); text-align:center; }
.modal h3{margin:0 0 10px;font-size:1rem}
.grid{display:grid;grid-template-columns:max-content max-content;column-gap:15px;row-gap:8px}
/* Add a bit of inner padding so right time input isn't hugging the edge */
.modal .grid{padding:0 4px}
.grid>div{display:grid;gap:6px;align-items:start}
.grid .time-inline{display:flex;flex-direction:row;align-items:center;gap:2px}
.grid .time-inline label{margin:0 4px 0 0; width:auto; flex:0 0 auto; white-space:nowrap}
/* Keep 'To' compact but readable */
.grid .time-right label{margin:0 4px 0 0; width:auto; flex:0 0 auto}
.grid .time-inline input[type=time]{flex:0 0 96px;min-width:96px;max-width:96px;box-sizing:border-box;border:1px solid var(--divider-color);border-radius:8px;background: var(--card-background-color);color: var(--primary-text-color);height:28px;padding:2px 6px}
.grid .time-inline select.mer-select{flex:0 0 60px;min-width:60px;max-width:60px;height:28px;padding:2px 6px;border:1px solid var(--divider-color);border-radius:8px;background:var(--card-background-color);color:var(--primary-text-color)}
.grid>div.time-right{justify-content:flex-start;padding-right:0}
/* Temperature input: compact and rounded */
.grid .rowfull .ed-temp { width: 140px; height:28px; padding:2px 6px; box-sizing:border-box; border:1px solid var(--divider-color); border-radius:8px; background: var(--card-background-color); color: var(--primary-text-color); }
/* remove button */
.remove-btn { flex-shrink: 0; padding:4px 8px; border-radius:8px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--error-color); display:inline-flex; align-items:center; gap:6px; cursor:pointer; user-select:none; }
/* Make disabled state clearly visible on remove-style buttons */
.remove-btn[disabled] { opacity: .55; cursor: not-allowed; filter: grayscale(60%); color: var(--disabled-text-color, var(--secondary-text-color)) !important; border-color: var(--divider-color) !important; pointer-events: none; }
.remove-btn[disabled] ha-icon { filter: grayscale(100%); opacity: .7; }
.remove-btn[disabled] span { color: var(--disabled-text-color, var(--secondary-text-color)); }
.actions { display:flex; gap:8px; justify-content:flex-end; align-items:center; }
/* Give modal action buttons some space from inputs */
.modal .actions { margin-top: 10px; }
.order-btn {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:8px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
cursor:pointer; user-select:none;
}
.order-btn[disabled] { opacity: .5; cursor: not-allowed; }
/* Drag & drop */
.drag-handle {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:8px;
border:1px dashed var(--divider-color);
background: var(--card-background-color);
cursor: grab; user-select:none;
}
.drag-handle:active { cursor: grabbing; }
.inline.drag-over { outline: 2px dashed var(--primary-color); outline-offset: 2px; }
/* Pointer-driven drag */
.drag-ghost {
position: fixed;
pointer-events: none;
opacity: .9;
z-index: 9999;
}
.placeholder { border:2px dashed var(--primary-color); border-radius:10px; margin:6px 0; }
/* Merge chips */
.linked-chips { display:flex; flex-wrap:wrap; gap:6px; }
.pill-chip { display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border:1px solid var(--divider-color); border-radius:999px; background: var(--secondary-background-color, transparent); color: var(--primary-text-color); }
.pill-chip .rm { cursor:pointer; border:none; background:transparent; color: var(--error-color); font-weight:700; line-height:1; padding:0 2px; }
.pill-chip .rm:focus { outline: 2px solid var(--primary-color); outline-offset:2px; }
/* Hide all popups and popup-trigger buttons when card has .hide-popups */
.card.hide-popups .overlay,
.card.hide-popups .overlay-week,
.card.hide-popups .overlay-profiles,
.card.hide-popups .overlay-settings,
.card.hide-popups .overlay-copyrooms,
.card.hide-popups .overlay-onboard { display: none !important; }
.card.hide-popups [data-role="addbtn"],
.card.hide-popups [data-role="weekbtn"],
.card.hide-popups [data-role="profbtn"],
.card.hide-popups [data-role="settingsbtn"] { display: none !important; }
/* Global pause button */
.pause-btn { width:26px; height:26px; border-radius:999px; border:1px solid var(--primary-color); background: var(--primary-color); color: var(--text-primary-color, #fff); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; }
.pause-btn.paused { background: var(--error-color); color: var(--text-primary-color, #fff); border-color: var(--error-color); }
.pause-btn ha-icon{ --mdc-icon-size: 16px; }
/* Settings button (header, top-right) */
.settings-btn { width:26px; height:26px; border-radius:999px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; }
.settings-btn ha-icon{ --mdc-icon-size: 16px; }
.profiles-btn { height:26px; border-radius:999px; border:1px solid var(--primary-color); background: var(--card-background-color); color: var(--primary-text-color); padding:0 10px; display:inline-flex; align-items:center; gap:6px; cursor:pointer; }
.profiles-btn.primary{ background: var(--primary-color); color: var(--text-primary-color, #fff); }
.profiles-btn.success{ background: var(--success-color, #4caf50); color: var(--text-primary-color, #fff); border-color: var(--success-color, #4caf50); }
.prof-toolbar .btn ha-icon{ --mdc-icon-size: 16px; }
.prof-toolbar .btn.primary{ background: var(--primary-color) !important; color: var(--text-primary-color, #fff) !important; border-color: var(--primary-color) !important; }
.modal-pause{ width:min(86vw, 320px); max-width:320px; border-radius:8px; padding:10px; background: var(--card-background-color); color: var(--primary-text-color); border:1px solid var(--divider-color); }
.pause-list{ display:grid; gap:8px; }
/* Profiles modal */
/* Stack controls above preview to give them more space; match Weekly modal width */
/* Make tooltip positions relative to the profiles modal */
.modal-profiles, .modal-holiday{ width:min(95vw, 640px); max-width: 640px; border-radius:8px; padding:10px; background: var(--card-background-color); color: var(--primary-text-color); border:1px solid var(--divider-color); position: relative; }
/* Holiday copy popup */
.modal-hcopy{ width:min(95vw, 640px); max-width: 640px; border-radius:8px; padding:12px; background: var(--card-background-color); color: var(--primary-text-color); border:1px solid var(--divider-color); position: relative; }
.modal-hcopy .hcopy-select{ width:100%; padding:8px; border-radius:8px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); }
.modal-hcopy .week-scale{ margin: 8px 16px; }
.modal-hcopy .week-track{ margin: 0 16px; position:relative; height:64px; border:1px dashed var(--divider-color); border-radius:10px; overflow:hidden; }
.prof-head{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin:0 0 6px; }
.prof-body{ display:grid; grid-template-columns: 1fr; gap:12px; align-items:start; }
.prof-controls{ display:grid; gap:8px; }
.prof-list{ display:grid; gap:6px; border:1px solid var(--divider-color); border-radius:8px; padding:6px; max-height:260px; overflow:auto; background: var(--secondary-background-color, transparent); }
.prof-item{ display:grid; grid-template-columns: 1fr auto; align-items:center; gap:6px; padding:4px 6px; border-radius:8px; }
.prof-item.active{ background: var(--secondary-background-color, rgba(0,0,0,.05)); }
.prof-chip{ display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border:1px solid var(--divider-color); border-radius:999px; background: var(--card-background-color); color: var(--primary-text-color); font-weight:600; }
.prof-chip:focus, .prof-chip:focus-visible{ outline: none; box-shadow: none; }
.prof-actions{ display:flex; gap:6px; align-items:center; }
.prof-actions .btn{ height:28px; padding:2px 8px; border-radius:8px; font-size:.78rem; }
.prof-actions .remove-btn{ height:28px; min-height:28px; padding:2px 8px; border-radius:8px; font-size:.78rem; }
.prof-actions .remove-btn ha-icon{ --mdc-icon-size: 16px; }
.prof-topbar{ display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; margin: 0 16px; }
.prof-topbar .prof-note{ margin:0; }
.prof-topbar .prof-toolbar{ margin-left:auto; }
/* Make profiles and holidays preview identical to weekday preview */
.modal-profiles .week-scale, .modal-holiday .week-scale{ margin: 0 16px; }
/* Hide the "Add profile" button profiles are created on Save */
.modal-profiles .prof-add{ display:none !important; }
.modal-profiles .week-track, .modal-holiday .week-track{ margin: 0 16px; position:relative; height:64px; border:1px dashed var(--divider-color); border-radius:10px; overflow:hidden; }
.modal-profiles .week-track .block .pill{ display:none; }
.modal-profiles .week-scale-inner .mer, .modal-holiday .week-scale-inner .mer{ display:block; font-size:.65rem; color: var(--secondary-text-color); text-align:center; }
/* Tabs styling reused (match weekdays look) */
.modal-profiles .wk-tab,
.modal-holiday .wk-tab{ appearance:none; border:none; background:transparent; color: var(--secondary-text-color); cursor:pointer; padding:6px 10px; border-bottom:2px solid transparent; border-radius:0; font-weight:600; }
.modal-profiles .wk-tab.active,
.modal-holiday .wk-tab.active{ color: var(--primary-text-color); border-bottom-color: var(--primary-color); }
/* Small confirm popup inside overlays */
.mini-confirm{ position:fixed; inset:0; display:grid; place-items:center; background: rgba(0,0,0,.32); z-index:2147484000; }
.mini-confirm .box{ min-width:260px; max-width:420px; border-radius:8px; padding:12px; background: var(--card-background-color); color: var(--primary-text-color); border:1px solid var(--divider-color); box-shadow: var(--ha-card-box-shadow, 0 4px 12px rgba(0,0,0,.2)); }
.mini-confirm .box h4{ margin:0 0 8px; font-size:1rem; }
.mini-confirm .btns{ display:flex; gap:8px; justify-content:flex-end; margin-top:10px; }
/* Presence combo chips (top of modal) */
.presence-combo-chips{ display:flex; gap:6px; flex-wrap:wrap; }
.presence-combo-chips .combo-pill{ max-width:100%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* Presence vertical list */
.presence-rows{ display:grid; gap:6px; }
.presence-row{ text-align:left; padding:6px 10px; border:1px solid var(--divider-color); border-radius:8px; background: var(--card-background-color); color: var(--primary-text-color); cursor:pointer; white-space: normal; line-height:1.2; }
.presence-row.active{ border-color: var(--primary-color); background: var(--secondary-background-color, rgba(0,0,0,.05)); }
/* Sync pill next to title */
.sync-pill { margin-left: 8px; display:inline-flex; align-items:center; gap:6px; }
.sync-pill.ghost { background: var(--secondary-background-color, transparent); color: var(--secondary-text-color); border-color: var(--divider-color); }
.sync-pill.primary { background: var(--primary-color); color: var(--text-primary-color, #fff); border-color: var(--primary-color); }
.sync-pill.success { background: color-mix(in oklab, #2e7d32 20%, var(--card-background-color)); color: var(--primary-text-color); border-color: color-mix(in oklab, #2e7d32 50%, var(--divider-color)); }
.sync-pill ha-icon { --mdc-icon-size: 16px; }
/* Small red test button next to title */
.test-btn { width:22px; height:22px; border-radius:50%; border:1px solid var(--error-color); background: var(--error-color); color: var(--text-primary-color, #fff); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; }
.test-btn ha-icon{ --mdc-icon-size: 14px; }
/* Test popup */
.modal-test{ width:min(92vw, 520px); max-width:520px; }
/* Settings popup */
.modal-settings{
width:min(95vw, 640px);
max-width:640px;
height:min(90vh, 720px);
max-height:min(90vh, 720px);
display:flex;
flex-direction:column;
box-sizing:border-box;
overflow:hidden;
}
.overlay-settings .settings-head{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin:0 0 8px; }
.overlay-settings .settings-head .settings-title{ margin:0; }
.overlay-settings .settings-head .int-status{ cursor: default; }
.overlay-settings .int-status{ display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border:1px solid var(--divider-color); border-radius:999px; background: var(--secondary-background-color, transparent); }
.overlay-settings .int-status.ok{ color: var(--success-color, #2e7d32); border-color: color-mix(in oklab, var(--success-color, #2e7d32) 50%, var(--divider-color)); }
.overlay-settings .int-status.bad{ color: var(--error-color); border-color: color-mix(in oklab, var(--error-color) 50%, var(--divider-color)); }
.overlay-settings .int-status ha-icon{ --mdc-icon-size: 14px; }
.overlay-settings .int-status .text{ font-size:.8rem; white-space:nowrap; }
/* Keep header/tabs/buttons fixed; scroll content instead */
.overlay-settings .modal-settings .settings-body{
flex:1 1 auto;
min-height:0;
overflow:auto;
}
.overlay-settings .modal-settings .actions{ flex:0 0 auto; }
.settings-tabs{ display:flex; flex-wrap:wrap; gap:12px 18px; align-items:flex-end; padding:0 6px; margin:8px 0 4px; border-bottom: 1px solid var(--divider-color); }
.settings-tab-btn{ appearance:none; border:none; background:transparent; color: var(--secondary-text-color); padding:10px 2px; margin:0; cursor:pointer; font-weight:600; position:relative; max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.settings-tab-btn::after{ content:""; position:absolute; left:0; right:0; bottom:-1px; height:2px; background: transparent; border-radius:2px; }
.settings-tab-btn.active{ color: var(--primary-text-color); }
.settings-tab-btn.active::after{ background: var(--primary-color); }
.settings-tab-btn:focus{ outline:none; }
.settings-tab-btn:focus-visible{ outline: 2px solid var(--primary-color); outline-offset:2px; border-radius:4px; }
/* Popup settings content (reuse editor look) */
.settings-card{ border:1px solid var(--divider-color); border-radius:12px; background: var(--card-background-color); padding:12px; display:grid; gap:10px; }
.settings-card .grid4{ display:grid; grid-template-columns: repeat(4, 1fr); gap:10px; margin-bottom: 6px; }
.settings-card .sfield{ display:grid; gap:2px; align-items:start; }
.settings-card .slabel{ font-size:.8rem; color: var(--secondary-text-color); line-height:1; padding-left:4px; }
.settings-card .settings-input{ width:88px; height:28px; padding:4px 6px; border:1px solid var(--divider-color); border-radius:8px; background: var(--card-background-color); color: var(--primary-text-color); box-sizing:border-box; }
.settings-card .settings-input:disabled{ opacity:.55; cursor:not-allowed; filter: grayscale(60%); color: var(--disabled-text-color, var(--secondary-text-color)); background: var(--secondary-background-color, var(--card-background-color)); }
.settings-card .setting{ display:grid; grid-template-columns: 1fr auto; align-items:center; gap:10px; padding:10px 4px; }
.settings-card .setting + .setting{ border-top: 1px solid var(--divider-color); }
/* Group Configuration ID (instance) rows: remove divider between toggle and ID row */
.settings-card .sp-instance-setting + .sp-instance-id-setting{ border-top: none; }
.settings-card .title{ font-weight:600; }
.settings-card .desc{ font-size:.85rem; color: var(--secondary-text-color); margin-top:2px; }
/* Segmented control (used by popup Settings: time format/source, temperature unit) */
.settings-card .seg{ display:inline-flex; border:1px solid var(--divider-color); border-radius:10px; overflow:hidden; }
.settings-card .seg button{ padding:6px 10px; background: var(--card-background-color); color: var(--primary-text-color); border:0; cursor:pointer; }
.settings-card .seg button.active{ background: var(--primary-color); color: var(--text-primary-color, #fff); }
/* Configuration ID: align icon and match the standard button look */
.overlay-settings .sp-instance-regenerate-btn{ display:inline-flex; align-items:center; gap:6px; white-space: nowrap; }
.overlay-settings .sp-instance-regenerate-btn ha-icon{ --mdc-icon-size: 16px; }
/* Popup inputs (used in Rooms, Holidays etc.) */
.overlay-settings input.tt-input{
width:100%;
height:36px;
min-height:36px;
padding:6px 10px;
box-sizing:border-box;
border:1px solid var(--divider-color);
border-radius:8px;
background: var(--card-background-color);
color: var(--primary-text-color);
display:block;
}
.overlay-settings input.tt-input.tt-input-lg{
height:48px;
min-height:48px;
max-height:48px;
padding:0 14px;
border-radius:10px;
background: var(--card-background-color);
}
.overlay-settings input.tt-input::placeholder{ color: var(--secondary-text-color); opacity: 1; }
.overlay-settings input.tt-input:focus{ outline:none; }
.overlay-settings input.tt-input:focus-visible{ outline: 2px solid var(--primary-color); outline-offset:2px; }
/* Popup entity pickers should match the Rooms details width */
.overlay-settings .inline tt-entity-picker,
.overlay-settings .inline ha-entity-picker{
width:100%;
min-width:250px;
max-width:440px;
min-height:48px;
display:block;
}
.settings-body{ display:grid; gap:10px; }
.settings-page{ display:none; }
.settings-page.active{ display:block; }
.test-list{ margin-top:8px; display:grid; gap:8px; max-height:360px; overflow:auto; border:1px solid var(--divider-color); border-radius:8px; padding:8px; }
.test-item{ padding:6px 8px; border:1px solid var(--divider-color); border-radius:8px; background: var(--secondary-background-color, transparent); }
.test-item h4{ margin:0 0 4px; font-size:.95rem; overflow-wrap:anywhere; }
.test-fields{ margin:0; padding-left:18px; color: var(--secondary-text-color); }
.test-empty{ color: var(--secondary-text-color); font-size:.95rem; }
/* Ensure test modal never overflows horizontally */
.modal-test{ box-sizing: border-box; overflow:hidden; }
.modal-test *{ box-sizing: border-box; }
.modal-test .test-list{ width:100%; overflow-x:auto; }
.modal-test .test-select{ width:100% !important; max-width:100% !important; display:block; }
.modal-test .test-picker, .modal-test ha-entity-picker{ width:100% !important; max-width:100% !important; display:block; }
</style>
<ha-card class="card">
<div class="header"><div class="title"></div><div class="weekday-full" style="display:none;"></div><div class="spacer"></div></div>
<div class="scale"><div class="scale-inner"></div></div>
<div class="rows"></div>
<div class="footer"><div class="label-end"><span class="lbl-start">00:00</span><span class="lbl-end">00:00</span></div></div>
<div class="overlay" part="overlay">
<div class="modal" role="dialog" aria-modal="true" aria-label="">
<h3 class="ed-modal-title"></h3>
<div class="grid">
<div class="time-inline"><label class="ed-from-label"></label><input class="ed-from" type="time" step="60" /><select class="mer-select ed-from-mer" style="display:none;"><option>AM</option><option>PM</option></select></div>
<div class="time-inline time-right"><label class="ed-to-label"></label><input class="ed-to" type="time" step="60" /><select class="mer-select ed-to-mer" style="display:none;"><option>AM</option><option>PM</option></select></div>
<div class="rowfull"><label class="ed-temp-label"></label><input class="ed-temp" type="number" step="0.5" min="0" max="120" /></div>
</div>
<div class="ed-error" role="alert" aria-live="assertive" style="color:var(--error-color); display:none; margin-top:8px; font-size:.95rem;"></div>
<div class="actions"><button class="btn ghost danger ed-delete" type="button"></button><button class="btn ghost ed-cancel" type="button"></button><button class="btn primary ed-save" type="button"></button></div>
</div>
</div>
<div class="overlay overlay-week" part="overlay">
<div class="modal modal-week" role="dialog" aria-modal="true" aria-label="">
<div class="week-head"><h3 class="week-title"></h3><div class="week-room"></div></div>
<div style="display:grid; gap:10px;">
<div class="week-roomtabs" role="tablist" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
<div style="display:grid; gap:4px;">
<label style="font-size:.85rem; color: var(--secondary-text-color);" class="week-mode-label"></label>
<select class="week-mode" style="padding:6px; border-radius:8px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color);">
<option value="weekday_weekend" class="opt-weekday-weekend"></option>
<option value="weekday_sat_sun" class="opt-weekday-sat-sun"></option>
<option value="all_7" class="opt-all-7"></option>
</select>
</div>
<div class="week-tabs" role="tablist"></div>
<div class="week-toolbar" style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div class="week-note" style="font-size:.85rem; color: var(--secondary-text-color);"></div>
<div class="wk-actions"></div>
</div>
<div class="week-pastebar" style="display:none; padding:2px 0; gap:6px; flex-wrap:wrap;"></div>
<div class="week-scale" style="position:relative; height:32px;">
<div class="week-scale-inner" style="position:relative; height:100%;"></div>
</div>
<div class="week-track" style="position:relative; height:64px; border:1px dashed var(--divider-color); border-radius:10px; overflow:hidden;"></div>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button class="btn ghost wk-cancel" type="button"></button>
<button class="btn primary wk-save" type="button"></button>
</div>
</div>
</div>
</div>
<!-- Copy entire week to other rooms -->
<!-- Profiles (named daily schedules per room) -->
<div class="overlay overlay-profiles" part="overlay">
<div class="modal modal-profiles" role="dialog" aria-modal="true">
<div class="prof-head">
<h3 class="prof-title"></h3>
<div class="prof-room" style="opacity:.9;"></div>
</div>
<div class="prof-body">
<div class="prof-controls" style="display:grid; gap:8px;">
<div class="prof-list"></div>
<button type="button" class="add-entity-btn prof-add"><ha-icon icon="mdi:plus"></ha-icon><span class="prof-add-text"></span></button>
</div>
<div style="display:grid; gap:8px; align-items:start;">
<div class="prof-roomtabs" role="tablist" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
<div class="prof-note" style="font-size:.85rem; color: var(--secondary-text-color);"></div>
<div class="week-scale" style="position:relative; height:32px;">
<div class="week-scale-inner" style="position:relative; height:100%;"></div>
</div>
<div class="week-track" style="position:relative;"></div>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button class="btn primary prof-save" type="button"></button>
<button class="btn ghost prof-close" type="button"></button>
</div>
</div>
</div>
</div>
</div>
<!-- Holidays (single daily schedule per room) -->
<div class="overlay overlay-holiday" part="overlay">
<div class="modal modal-holiday" role="dialog" aria-modal="true">
<div class="prof-head">
<h3 class="holiday-title"></h3>
<div class="holiday-room" style="opacity:.9;"></div>
</div>
<div style="display:grid; gap:8px; align-items:start;">
<div class="holiday-roomtabs" role="tablist" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
<div class="holiday-note" style="font-size:.85rem; color: var(--secondary-text-color);"></div>
<div class="week-scale" style="position:relative; height:32px;">
<div class="week-scale-inner" style="position:relative; height:100%;"></div>
</div>
<div class="week-track" style="position:relative;"></div>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button class="btn primary holiday-save" type="button"></button>
<button class="btn ghost holiday-close" type="button"></button>
</div>
</div>
</div>
</div>
<!-- Presence (advanced away) -->
<!-- Holiday: copy from other schedule types -->
<div class="overlay overlay-hcopy" part="overlay">
<div class="modal modal-hcopy" role="dialog" aria-modal="true">
<div class="prof-head">
<h3 class="hcopy-title"></h3>
<div class="holiday-room hcopy-room" style="opacity:.9;"></div>
</div>
<div style="display:grid; gap:10px;">
<label style="display:grid; gap:6px;">
<span style="font-size:.9rem; color: var(--secondary-text-color);" class="hcopy-label"></span>
<select class="hcopy-select"></select>
</label>
<div class="week-scale" style="position:relative; height:32px;">
<div class="week-scale-inner" style="position:relative; height:100%;"></div>
</div>
<div class="week-track" style="position:relative;"></div>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button class="btn primary hcopy-apply" type="button"></button>
<button class="btn ghost hcopy-close" type="button"></button>
</div>
</div>
</div>
</div>
<!-- Presence (advanced away) -->
<div class="overlay overlay-presence" part="overlay">
<div class="modal modal-profiles" role="dialog" aria-modal="true">
<div class="prof-head">
<h3 class="presence-title"></h3>
<div class="presence-room" style="opacity:.9;"></div>
</div>
<div class="prof-body">
<div class="prof-controls" style="display:grid; gap:8px;">
<label class="presence-adv-row" style="display:none; align-items:center; gap:8px;">
<input type="checkbox" class="presence-adv-toggle" />
<span class="presence-adv-label"></span>
</label>
<div class="presence-combos"></div>
</div>
<div style="display:grid; gap:8px; align-items:start;">
<div class="presence-combo-active" style="font-size:.9rem; color: var(--secondary-text-color);"></div>
<div class="presence-roomtabs" role="tablist" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
<div class="week-scale" style="position:relative; height:32px;">
<div class="week-scale-inner" style="position:relative; height:100%;"></div>
</div>
<div class="week-track" style="position:relative;"></div>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button class="btn primary presence-save" type="button"></button>
<button class="btn ghost presence-close" type="button"></button>
</div>
</div>
</div>
</div>
</div>
<!-- Copy entire week to other rooms -->
<div class="overlay overlay-copyrooms" part="overlay">
<div class="modal modal-copyrooms" role="dialog" aria-modal="true">
<h3 class="copyrooms-title"></h3>
<div class="copyrooms-desc" style="font-size:.9rem; color: var(--secondary-text-color); margin-bottom:6px;"></div>
<div class="copyrooms-list"></div>
<div class="actions" style="margin-top:10px; display:flex; justify-content:flex-end; gap:8px;">
<button class="btn ghost copyrooms-cancel" type="button"></button>
<button class="btn primary copyrooms-apply" type="button"></button>
</div>
</div>
</div>
<!-- First-time onboarding -->
<div class="overlay overlay-onboard" part="overlay">
<div class="modal modal-onboard" role="dialog" aria-modal="true">
<h3 class="onb-title"></h3>
<div class="ob-hero">
<img class="onb-img" alt="guide" style="display:none;" />
<video class="onb-vid" muted playsinline loop style="display:none;"></video>
</div>
<div class="ob-text onb-text"></div>
<div class="ob-steps onb-dots"></div>
<div style="display:flex; align-items:center; justify-content:space-between; margin-top:10px; gap:8px;">
<label style="display:flex; align-items:center; gap:6px; font-size:.85rem; color: var(--secondary-text-color);"><input type="checkbox" class="onb-hide" /> <span class="onb-hide-lbl"></span></label>
<div class="actions" style="display:flex; gap:8px;">
<button class="btn ghost onb-prev" type="button"></button>
<button class="btn ghost onb-skip" type="button"></button>
<button class="btn primary onb-next" type="button"></button>
<button class="btn primary onb-done" type="button" style="display:none;"></button>
</div>
</div>
</div>
</div>
<!-- Pause auto-apply -->
<div class="overlay overlay-pause" part="overlay">
<div class="modal modal-pause" role="dialog" aria-modal="true">
<h3 class="pause-title"></h3>
<div class="pause-status" style="font-size:.9rem; color: var(--secondary-text-color); margin-bottom:6px;"></div>
<div class="pause-list">
<button type="button" class="btn ghost pause-1h"></button>
<button type="button" class="btn ghost pause-5h"></button>
<button type="button" class="btn ghost pause-custom"></button>
<button type="button" class="btn ghost pause-indef"></button>
<button type="button" class="btn success pause-resume"></button>
<button type="button" class="btn danger pause-close"></button>
</div>
</div>
</div>
<!-- In-card Settings (moved out of HA editor over time) -->
<div class="overlay overlay-settings" part="overlay">
<div class="modal modal-settings" role="dialog" aria-modal="true">
<div class="settings-head">
<h3 class="settings-title"></h3>
<span class="int-status sp-int-status" role="status">
<ha-icon class="icon" icon="mdi:cloud-off-outline"></ha-icon>
<span class="text"></span>
</span>
</div>
<div class="settings-tabs" role="tablist">
<button type="button" class="settings-tab-btn" data-tab="settings" role="tab" aria-selected="true"></button>
<button type="button" class="settings-tab-btn" data-tab="pause" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="weekdays" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="rooms" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="boiler" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="colors" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="presence_sensor" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="owd" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="holidays" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="away" role="tab" aria-selected="false"></button>
<button type="button" class="settings-tab-btn" data-tab="reset" role="tab" aria-selected="false"></button>
</div>
<div class="settings-body" style="min-height:80px;">
<div class="settings-page" data-page="settings">
<div class="settings-card">
<div class="grid4">
<div class="sfield">
<div class="slabel sp-slabel-def"></div>
<input class="settings-input sp-def" type="number" step="0.5" />
</div>
<div class="sfield">
<div class="slabel sp-slabel-rowh"></div>
<input class="settings-input sp-rowh" type="number" step="1" min="40" max="120" />
</div>
<div class="sfield">
<div class="slabel sp-slabel-minc"></div>
<input class="settings-input sp-minc" type="number" step="0.5" />
</div>
<div class="sfield">
<div class="slabel sp-slabel-maxc"></div>
<input class="settings-input sp-maxc" type="number" step="0.5" />
</div>
</div>
<div class="setting">
<div class="text">
<div class="title sp-auto-title"></div>
<div class="desc sp-auto-desc"></div>
</div>
<ha-switch class="sp-auto"></ha-switch>
</div>
<div class="setting">
<div class="text">
<div class="title sp-applyedit-title"></div>
<div class="desc sp-applyedit-desc"></div>
</div>
<ha-switch class="sp-applyedit"></ha-switch>
</div>
<div class="setting">
<div class="text">
<div class="title sp-applydef-title"></div>
<div class="desc sp-applydef-desc"></div>
</div>
<ha-switch class="sp-applydef"></ha-switch>
</div>
<div class="setting">
<div class="text">
<div class="title sp-perroom-title"></div>
<div class="desc sp-perroom-desc"></div>
</div>
<ha-switch class="sp-perroom"></ha-switch>
</div>
<div class="setting">
<div class="text">
<div class="title sp-showroomtemp-title"></div>
<div class="desc sp-showroomtemp-desc"></div>
</div>
<ha-switch class="sp-showroomtemp"></ha-switch>
</div>
<div class="setting">
<div class="text">
<div class="title sp-timefmt-title"></div>
<div class="desc sp-timefmt-desc"></div>
</div>
<div class="seg sp-timefmt">
<button type="button" class="sp-fmt24">24h</button>
<button type="button" class="sp-fmt12">12h</button>
</div>
</div>
<div class="setting">
<div class="text">
<div class="title sp-timesrc-title"></div>
<div class="desc sp-timesrc-desc"></div>
</div>
<div class="seg sp-timesrc">
<button type="button" class="sp-srcBrowser"></button>
<button type="button" class="sp-srcHa"></button>
</div>
</div>
<div class="setting">
<div class="text">
<div class="title sp-tempfmt-title"></div>
<div class="desc sp-tempfmt-desc"></div>
</div>
<div class="seg sp-tempfmt">
<button type="button" class="sp-unitC">°C</button>
<button type="button" class="sp-unitF">°F</button>
</div>
</div>
<div class="setting">
<div class="text">
<div class="title sp-profiles-title"></div>
<div class="desc sp-profiles-desc"></div>
</div>
<ha-switch class="sp-profiles"></ha-switch>
</div>
<div class="setting sp-instance-setting">
<div class="text">
<div class="title sp-instance-title"></div>
<div class="desc sp-instance-desc"></div>
</div>
<ha-switch class="sp-instance-enable"></ha-switch>
</div>
<div class="setting sp-instance-id-setting" style="grid-template-columns: 1fr;">
<div style="display:flex; gap:8px; align-items:center; justify-content:space-between; width:100%;">
<div class="sp-instance-id" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"></div>
<button type="button" class="btn ghost sp-instance-regenerate-btn"><ha-icon icon="mdi:refresh"></ha-icon><span></span></button>
</div>
</div>
</div>
</div>
<div class="settings-page" data-page="pause">
<div class="settings-card">
<div class="setting">
<div class="text">
<div class="title sp-pausebtn-title"></div>
<div class="desc sp-pausebtn-desc"></div>
</div>
<ha-switch class="sp-pausebtn"></ha-switch>
</div>
<div class="setting">
<div class="text">
<div class="title sp-pausesensor-title"></div>
<div class="desc sp-pausesensor-desc"></div>
</div>
<ha-switch class="sp-pausesensor"></ha-switch>
</div>
<div class="setting sp-pausesensor-entity-setting" style="grid-template-columns: 1fr;">
<div class="text">
<div class="title sp-pausesensor-entity-title"></div>
<div class="desc sp-pausesensor-entity-desc"></div>
</div>
<tt-entity-picker class="sp-pausesensor-entity" include-domains='["binary_sensor"]'></tt-entity-picker>
</div>
</div>
</div>
<div class="settings-page" data-page="weekdays"></div>
<div class="settings-page" data-page="rooms"></div>
<div class="settings-page" data-page="boiler"></div>
<div class="settings-page" data-page="colors"></div>
<div class="settings-page" data-page="presence_sensor"></div>
<div class="settings-page" data-page="owd"></div>
<div class="settings-page" data-page="holidays"></div>
<div class="settings-page" data-page="away"></div>
<div class="settings-page" data-page="reset"></div>
</div>
<div class="actions" style="margin-top:10px; display:flex; justify-content:flex-end; gap:8px;">
<button class="btn primary settings-save" type="button"></button>
<button class="btn ghost settings-close" type="button"></button>
</div>
</div>
</div>
<!-- Test popup: entity picker + list of available services for selected entity -->
<div class="overlay overlay-test" part="overlay">
<div class="modal modal-test" role="dialog" aria-modal="true">
<h3 style="margin-bottom:8px;" class="test-title"></h3>
<div style="display:grid; gap:6px;">
<label style="font-size:.9rem; color: var(--secondary-text-color);" class="test-label"></label>
<ha-entity-picker class="test-picker"></ha-entity-picker>
<!-- Simpel fallback hvis ha-entity-picker ikke er tilgængelig -->
<select class="test-select" style="display:none; padding:6px; border-radius:8px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color);"></select>
<div class="test-list">
<div class="test-empty"></div>
</div>
<div class="actions" style="display:flex; justify-content:flex-end; gap:8px;">
<button class="btn primary test-export" type="button" disabled class="test-export-btn"></button>
<button class="btn ghost test-close" type="button"></button>
</div>
</div>
</div>
</div>
</ha-card>`; 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 { 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; 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: if advanced presence is enabled, disable basic away in backend
const _awayCfg = this._config?.away || {};
const _awayOut = { ..._awayCfg };
if (_awayOut.advanced_enabled) _awayOut.enabled = false;
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?.weekdays_enabled) 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){
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;
// 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 = `<span class="t-main">${p.main}</span><span class="mer">${p.mer||''}</span>`;
} 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 = '<ha-icon icon="mdi:cog-outline"></ha-icon>';
sbtn.addEventListener('click', ()=>{ try { this._openSettingsPopup(); } catch {} });
wrap.append(sbtn);
}
} 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 = '<ha-icon icon="mdi:pause"></ha-icon>';
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); autosave back to weekdays
try {
const rowHasWeekly = !!(this._config?.weekdays_enabled && row?.weekly);
if (rowHasWeekly) {
// Prepare an inmemory 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 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');
// Configuration ID (instance) toggle (persists outside of the popup draft)
if (swInstance) {
swInstance.onchange = async (e)=>{
try {
const on = !!e?.target?.checked;
if (!on) {
try { this._config.instance_enabled = false; } 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 {}
}
// --- 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 {}
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 {}
}
_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 {}
const cfg = this._config || {};
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_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),
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 || {}) };
// 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<ents.length;i++) {
const primary = ents[i];
const isInput = !!modes[i] || String(primary).startsWith('input_number.');
if (isInput) continue;
const arr = Array.isArray(src[primary]) ? src[primary].filter(Boolean).map(String) : [];
const cleaned = Array.from(new Set(arr.filter(x => 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<ents.length;i++) {
const primary = ents[i];
const isInput = !!modes[i] || String(primary).startsWith('input_number.');
if (isInput) continue;
const cfg0 = (src && Object.prototype.hasOwnProperty.call(src, primary) && src[primary] && typeof src[primary] === 'object') ? src[primary] : null;
if (!cfg0) continue;
const enabled = !!cfg0.enabled;
const order = (String(cfg0.order||'before') === 'after') ? 'after' : 'before';
out[primary] = { enabled, order };
}
cfg.turn_on = out;
} catch {}
} catch {}
cfg.default_temp = this._ttClampNum(d.default_temp, -50, 80, cfg.default_temp);
cfg.row_height = this._ttClampNum(d.row_height, 30, 200, cfg.row_height);
cfg.min_temp = this._ttClampNum(d.min_temp, -50, 80, cfg.min_temp);
cfg.max_temp = this._ttClampNum(d.max_temp, -50, 80, cfg.max_temp);
// Normalize min/max if user swapped them
if (Number.isFinite(cfg.min_temp) && Number.isFinite(cfg.max_temp) && cfg.min_temp > 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.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 {}
// 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();
cfg.boiler_switch_domain = (raw === 'input_boolean') ? 'input_boolean' : 'switch';
// Keep entity + selected domain consistent
const swDom = String(cfg.boiler_switch || '').split('.')[0];
if (cfg.boiler_switch && (swDom === 'switch' || swDom === 'input_boolean') && swDom !== cfg.boiler_switch_domain) {
cfg.boiler_switch = '';
}
} 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;
}
// 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 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 live presence chips (and advanced)
if (!cur.enabled) {
cur.advanced_enabled = false;
cfg.presence_live_header = false;
} else {
// Presence live header is available whenever Away mode is enabled
const live = !!(d.presence_live_header ?? true);
cfg.presence_live_header = live;
}
cfg.away = cur;
} catch {}
this._config = cfg;
} 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 = `
<div class="settings-card" style="padding:10px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px;">
<div class="text" style="display:grid; gap:2px;">
<div class="title sp-rooms-title"></div>
<div class="desc sp-rooms-desc"></div>
</div>
<button type="button" class="btn ghost sp-rooms-add"></button>
</div>
<div class="sp-rooms-list" style="display:grid; gap:10px;"></div>
</div>
`;
}
// 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 = '<ha-icon icon="mdi:chevron-down"></ha-icon>';
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 = '<ha-icon icon="mdi:drag"></ha-icon>';
const del = document.createElement('button');
del.type = 'button';
del.className = 'remove-btn';
del.innerHTML = '<ha-icon icon="mdi:close"></ha-icon><span>' + (this._t('editor.remove') || 'Remove') + '</span>';
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();
// 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<ents.length;i++) {
list.append(mkFoldout(i));
}
// Bind top-level events once
if (!page.dataset.bound) {
page.dataset.bound = '1';
try {
const btn = q('.overlay-settings .sp-rooms-add');
if (btn) btn.addEventListener('click', ()=>{
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 = `
<div class="settings-card">
<div class="setting">
<div class="text">
<div class="title sp-away-enable-title"></div>
<div class="desc sp-away-enable-desc"></div>
</div>
<ha-switch class="sp-away-enable"></ha-switch>
</div>
<div class="setting sp-away-temp-row">
<div class="text">
<div class="title sp-away-temp-title"></div>
<div class="desc sp-away-temp-desc"></div>
</div>
<input class="settings-input sp-away-temp" type="number" step="0.5" />
</div>
<div class="setting sp-away-persons-row">
<div class="text">
<div class="title sp-away-persons-title"></div>
<div class="desc sp-away-persons-desc"></div>
</div>
<div style="display:grid; gap:8px;">
<tt-entity-picker class="sp-away-person-picker" include-domains='["person","device_tracker"]'></tt-entity-picker>
<div class="linked-chips sp-away-chips"></div>
</div>
</div>
<div class="setting sp-away-delay-row">
<div class="text">
<div class="title sp-away-delay-title"></div>
<div class="desc sp-away-delay-desc"></div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<ha-switch class="sp-away-delay-enable"></ha-switch>
<div class="inline" style="display:flex; gap:8px; align-items:center;">
<label class="sp-away-delay-value-label" style="opacity:0.8;"></label>
<input class="settings-input sp-away-delay-value" type="number" step="1" min="0" style="width:90px;" />
<label class="sp-away-delay-unit-label" style="opacity:0.8;"></label>
<select class="settings-input sp-away-delay-unit">
<option value="seconds"></option>
<option value="minutes"></option>
</select>
</div>
</div>
</div>
<div class="setting sp-presence-live-row">
<div class="text">
<div class="title sp-presence-live-title"></div>
<div class="desc sp-presence-live-desc"></div>
</div>
<ha-switch class="sp-presence-live-enable"></ha-switch>
</div>
<div class="setting sp-away-adv-row">
<div class="text">
<div class="title sp-away-adv-title"></div>
<div class="desc sp-away-adv-desc"></div>
</div>
<ha-switch class="sp-away-adv-enable"></ha-switch>
</div>
<div class="setting sp-away-combos-row" style="grid-template-columns: 1fr;">
<div class="text">
<div class="title sp-presence-combos-title"></div>
<div class="desc sp-presence-combos-desc"></div>
</div>
<div class="sp-presence-combo-list" style="margin-top:6px; border:1px solid var(--divider-color); border-radius:8px; padding:6px; display:grid; gap:6px; max-height:180px; overflow:auto;"></div>
</div>
</div>
`;
}
// 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._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<<n); mask++){
const home=[], away=[];
for (let i=0;i<n;i++){ ((mask>>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;
} 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';
try {
const sw = q('.overlay-settings .sp-away-enable');
if (sw) sw.addEventListener('change', (e)=>{
try {
if (!this._settingsDraft?.away) return;
const enabled = !!e.target.checked;
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._settingsDraft.presence_live_header = false;
}
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;
} 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._renderSettingsPopupAwayTab();
} catch {}
});
} catch {}
try {
const adv = q('.overlay-settings .sp-away-adv-enable');
if (adv) adv.addEventListener('change', (e)=>{
try {
if (!this._settingsDraft?.away) return;
this._settingsDraft.away.advanced_enabled = !!e.target.checked;
// Do not force presence chips off when advanced is disabled;
// availability is controlled by Away mode.
this._renderSettingsPopupAwayTab();
} catch {}
});
} catch {}
try {
const pl = q('.overlay-settings .sp-presence-live-enable');
if (pl) pl.addEventListener('change', (e)=>{
try {
if (!this._settingsDraft?.away?.enabled) {
e.preventDefault?.();
pl.checked = false;
this._settingsDraft.presence_live_header = false;
return;
}
this._settingsDraft.presence_live_header = !!e.target.checked;
} catch {}
});
} catch {}
// Delay bindings
try {
const en = q('.overlay-settings .sp-away-delay-enable');
if (en) en.addEventListener('change', (e)=>{ try { this._settingsDraft.away_delay_enabled = !!e.target.checked; } 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'); } 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; } 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 = `
<div class="settings-card" style="padding:10px; display:grid; gap:10px;">
<div class="text" style="display:grid; gap:2px;">
<div class="title sp-reset-title"></div>
<div class="desc sp-reset-desc" style="white-space:pre-line;"></div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end;">
<button type="button" class="btn danger sp-reset-clear-all"><ha-icon icon="mdi:delete-alert"></ha-icon><span></span></button>
<button type="button" class="btn ghost sp-reset-clear-local"><ha-icon icon="mdi:delete-outline"></ha-icon><span></span></button>
</div>
</div>
`;
}
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 = '<ha-icon icon="mdi:plus"></ha-icon><span>' + (this._t('editor.add_entity') || 'Add room') + '</span>';
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 = '<ha-icon icon="mdi:chevron-down"></ha-icon>';
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 = `
<div class="settings-card">
<div class="setting">
<div class="text">
<div class="title sp-holidays-enable-title"></div>
<div class="desc sp-holidays-enable-desc"></div>
</div>
<ha-switch class="sp-holidays-enable"></ha-switch>
</div>
<div class="setting sp-holidays-source-setting">
<div class="text">
<div class="title sp-holidays-source-title"></div>
</div>
<div class="seg sp-holidays-source-seg">
<button type="button" class="sp-holidays-source-cal"></button>
<button type="button" class="sp-holidays-source-man"></button>
</div>
</div>
<div class="setting sp-holidays-entity-setting" style="grid-template-columns: 1fr;">
<div class="text">
<div class="title sp-holidays-entity-title"></div>
<div class="desc sp-holidays-entity-desc"></div>
</div>
<tt-entity-picker class="sp-holidays-entity-picker" include-domains='["calendar"]'></tt-entity-picker>
</div>
<div class="setting sp-holidays-dates-setting" style="grid-template-columns: 1fr;">
<div class="text">
<div class="title sp-holidays-dates-title"></div>
</div>
<div class="hol-sec hol-single" style="display:grid; gap:8px; padding:10px; border:1px solid var(--divider-color); border-radius:10px; background: color-mix(in srgb, var(--card-background-color) 92%, var(--primary-text-color));">
<div class="title sp-holidays-single-title" style="font-size:.95rem;"></div>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<input class="settings-input sp-holidays-date-input" type="date" />
<button type="button" class="btn ghost sp-holidays-add-date"></button>
<span class="sp-holidays-add-date-msg" style="color:var(--error-color); font-size:.85rem;"></span>
</div>
<div class="linked-chips sp-holidays-date-chips"></div>
</div>
<div class="hol-sec hol-range" style="display:grid; gap:8px; padding:10px; border:1px solid var(--divider-color); border-radius:10px; background: color-mix(in srgb, var(--card-background-color) 92%, var(--primary-text-color));">
<div class="title sp-holidays-ranges-title" style="font-size:.95rem;"></div>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<input class="settings-input sp-holidays-range-from" type="date" />
<input class="settings-input sp-holidays-range-to" type="date" />
<button type="button" class="btn ghost sp-holidays-add-range"></button>
</div>
<div class="linked-chips sp-holidays-range-chips"></div>
</div>
</div>
<div class="setting sp-holidays-edit-setting">
<div class="text">
<div class="title sp-holidays-edit-title"></div>
</div>
<button type="button" class="btn ghost sp-holidays-edit-btn"></button>
</div>
</div>
`;
}
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);
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();
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._renderSettingsPopupHolidaysTab(); } catch {} });
if (bMan) bMan.addEventListener('click', ()=>{ try { if (!this._settingsDraft) return; this._settingsDraft.holidays_source = 'manual'; 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 || ''; } 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;
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;
// 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 = '<ha-icon icon="mdi:plus"></ha-icon><span>' + (this._t('editor.colors.go_add') || 'Add room') + '</span>';
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 = '<ha-icon icon="mdi:chevron-down"></ha-icon>';
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 = '<ha-icon icon="mdi:close"></ha-icon><span>' + (this._t('editor.remove') || 'Remove') + '</span>';
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 = '<ha-icon icon="mdi:plus"></ha-icon><span>' + (this._t('editor.heat_colors.add') || 'Add color range') + '</span>';
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 = '<ha-icon icon="mdi:delete-alert"></ha-icon><span>' + (this._t('editor.colors.clear_all') || 'Clear all color data') + '</span>';
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 = `
<div class="settings-card">
<div class="setting">
<div class="text">
<div class="title sp-boiler-enable-title"></div>
<div class="desc sp-boiler-enable-desc"></div>
</div>
<ha-switch class="sp-boiler-enable"></ha-switch>
</div>
<div class="setting sp-boiler-switch-setting" style="grid-template-columns: 1fr;">
<div class="text">
<div class="title sp-boiler-switch-title"></div>
</div>
<div class="sfield" style="margin-top:6px;">
<div class="slabel sp-boiler-switch-domain-label"></div>
<select class="settings-input sp-boiler-switch-domain">
<option value="switch">Switch</option>
<option value="input_boolean">Input boolean</option>
</select>
</div>
<tt-entity-picker class="sp-boiler-switch-picker" include-domains='["switch"]'></tt-entity-picker>
</div>
<div class="setting sp-boiler-rooms-setting" style="grid-template-columns: 1fr;">
<div class="text">
<div class="title sp-boiler-rooms-title"></div>
<div class="desc sp-boiler-rooms-desc"></div>
</div>
<div class="copyrooms-list sp-boiler-rooms-list"></div>
</div>
<div class="setting sp-boiler-offsets-setting" style="grid-template-columns: 1fr;">
<div class="text">
<div class="title sp-boiler-offsets-title"></div>
<div class="desc sp-boiler-offsets-desc"></div>
</div>
<div class="grid2" style="margin-top:6px;">
<div class="sfield">
<div class="slabel sp-boiler-on-offset-label"></div>
<input class="settings-input sp-boiler-on-offset" type="number" step="0.1" min="-10" max="10" />
</div>
<div class="sfield">
<div class="slabel sp-boiler-off-offset-label"></div>
<input class="settings-input sp-boiler-off-offset" type="number" step="0.1" min="-10" max="10" />
</div>
</div>
</div>
</div>
`;
}
const q = (sel)=> this.shadowRoot.querySelector(sel);
// Visibility of dependent controls
const boilerOn = !!d.boiler_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');
[switchRow, roomsRow, offRow].forEach(r=>{ if (r) r.style.display = boilerOn ? '' : 'none'; });
} catch {}
// Master toggle
try {
const sw = q('.overlay-settings .sp-boiler-enable');
if (sw) sw.checked = !!d.boiler_enabled;
} catch {}
// Pickers
try {
const domSel = q('.overlay-settings .sp-boiler-switch-domain');
if (domSel) {
const raw = String(d.boiler_switch_domain || '').toLowerCase().trim();
domSel.value = (raw === 'input_boolean') ? 'input_boolean' : 'switch';
}
const p = q('.overlay-settings .sp-boiler-switch-picker');
if (p) {
const raw = String(d.boiler_switch_domain || '').toLowerCase().trim();
const dom = (raw === 'input_boolean') ? 'input_boolean' : 'switch';
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 {}
// 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._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 = `
<div class="settings-card">
<div class="setting">
<div class="text">
<div class="title sp-week-enable-title"></div>
<div class="desc sp-week-enable-desc"></div>
</div>
<ha-switch class="sp-week-enable"></ha-switch>
</div>
<div class="setting sp-week-view-setting">
<div class="text">
<div class="title sp-week-view-title"></div>
</div>
<div class="seg sp-week-view">
<button type="button" class="sp-week-view-rooms"></button>
<button type="button" class="sp-week-view-days"></button>
</div>
</div>
<div class="setting sp-week-view-switch-setting">
<div class="text">
<div class="title sp-week-view-switch-title"></div>
<div class="desc sp-week-view-switch-desc"></div>
</div>
<ha-switch class="sp-week-view-switch"></ha-switch>
</div>
<div class="setting sp-week-open-setting">
<div class="text">
<div class="title sp-week-open-title"></div>
<div class="desc sp-week-open-desc"></div>
</div>
<button type="button" class="btn ghost sp-week-open-btn"></button>
</div>
</div>
`;
}
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 on = !!e.target.checked;
this._settingsDraft.weekdays_enabled = on;
// 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 = '<ha-icon icon="mdi:plus"></ha-icon><span>' + (this._t('editor.colors.go_add') || 'Add room') + '</span>';
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 = '<ha-icon icon="mdi:chevron-down"></ha-icon>';
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);
// 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.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;i<dRooms.length;i++){ if (dRooms[i] !== cRooms[i]) return true; }
} catch {}
if (String(d.boiler_temp_sensor||'') !== String(c.boiler_temp_sensor||'')) return true;
const cBoilerMin = this._ttCoerceNum(c.boiler_min_temp, 20);
const cBoilerMax = this._ttCoerceNum(c.boiler_max_temp, 25);
if (!eqNum(d.boiler_min_temp, cBoilerMin)) return true;
if (!eqNum(d.boiler_max_temp, cBoilerMax)) return true;
const cOnOff = this._ttCoerceNum(c.boiler_on_offset, 0);
const cOffOff = this._ttCoerceNum(c.boiler_off_offset, 0);
if (!eqNum(d.boiler_on_offset, cOnOff)) return true;
if (!eqNum(d.boiler_off_offset, cOffOff)) return true;
// 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<a.length;i++) {
const ra = a[i] || {};
const rb = b[i] || {};
if (!eqNum(ra.from, rb.from)) return true;
if (!eqNum(ra.to, rb.to)) return true;
if (String(ra.color||'') !== String(rb.color||'')) return true;
}
}
} catch {}
// Open Window Detection
try {
const dn = (d.open_window && typeof d.open_window === 'object') ? d.open_window : {};
const cn = (c.open_window && typeof c.open_window === 'object') ? c.open_window : {};
if (!!dn.enabled !== !!cn.enabled) return true;
const clampOw = (v, fallback)=>{
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<sa.length;i++){ if (sa[i] !== sb[i]) return true; }
}
} catch {}
// Holidays
try {
if (!!d.holidays_enabled !== !!c.holidays_enabled) return true;
const ds = (d.holidays_source === 'manual') ? 'manual' : 'calendar';
const cs = (c.holidays_source === 'manual') ? 'manual' : 'calendar';
if (ds !== cs) return true;
if (String(d.holidays_entity||'') !== String(c.holidays_entity||'')) return true;
const da = Array.isArray(d.holidays_dates) ? d.holidays_dates.filter(Boolean).map(String) : [];
const ca = Array.isArray(c.holidays_dates) ? c.holidays_dates.filter(Boolean).map(String) : [];
const sd = Array.from(new Set(da.filter(x => /^\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<sd.length;i++){ if (sd[i] !== sc[i]) return true; }
} catch {}
// Away
try {
const da = (d.away && typeof d.away === 'object') ? d.away : {};
const ca = (c.away && typeof c.away === 'object') ? c.away : {};
if (!!da.enabled !== !!ca.enabled) return true;
if (!eqNum(da.target_c, this._ttCoerceNum(ca.target_c, 17))) return true;
if (!!da.advanced_enabled !== !!ca.advanced_enabled) return true;
const dp = Array.isArray(da.persons) ? da.persons.filter(Boolean).map(String) : [];
const cp = Array.isArray(ca.persons) ? ca.persons.filter(Boolean).map(String) : [];
const sdp = Array.from(new Set(dp)).sort();
const scp = Array.from(new Set(cp)).sort();
if (sdp.length !== scp.length) return true;
for (let i=0;i<sdp.length;i++){ if (sdp[i] !== scp[i]) return true; }
const dcmb = (da.combos && typeof da.combos === 'object') ? da.combos : {};
const ccmb = (ca.combos && typeof ca.combos === 'object') ? ca.combos : {};
const dk = Object.keys(dcmb||{}).filter(k => !!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;i<dk.length;i++){ if (dk[i] !== ck[i]) return true; }
const dlive = !!(d.presence_live_header ?? true);
const clive = !!(c.presence_live_header ?? true);
const effD = da.advanced_enabled ? dlive : false;
const effC = ca.advanced_enabled ? clive : false;
if (effD !== effC) return true;
} catch {}
// Rooms
try {
const de = Array.isArray(d.entities) ? d.entities.map(x=>String(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;i<de.length;i++) {
const primary = de[i];
const isInput = dm[i] || String(primary).startsWith('input_number.');
if (isInput) continue;
const arr = Array.isArray(src?.[primary]) ? src[primary].filter(Boolean).map(String) : [];
const cleaned = Array.from(new Set(arr.filter(x=>String(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 {
// 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.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 <select> fallback
// Force simple <select> 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();
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();
};
}
// 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 = `<div class="t-main">${p.main}</div><div class="mer">${p.mer||''}</div>`; } 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 = '<ha-icon icon="mdi:close"></ha-icon><span>'+this._t('profiles.delete')+'</span>';
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 brandnew 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 = `<div class="t-main">${p.main}</div><div class="mer">${p.mer||''}</div>`; } 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 = '<ha-icon icon="mdi:plus"></ha-icon><span>'+this._t('ui.add_block')+'</span>'; 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 = `<div class="t-main">${p.main}</div><div class="mer">${p.mer||''}</div>`; } 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 = '<ha-icon icon="mdi:plus"></ha-icon><span>'+this._t('ui.add_block')+'</span>';
addBtnInline.addEventListener('click', ()=>{ this._openHolidayBlockEditor(null, this._getNowMin()); });
const copyBtn = document.createElement('button'); copyBtn.type='button'; copyBtn.className='btn ghost'; copyBtn.innerHTML = '<ha-icon icon="mdi:content-copy"></ha-icon><span>'+ (this._t('holidays.copy_from') || this._t('ui.copy_plan')) +'</span>';
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, b,
`${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');
// 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;
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=(r.presence && r.presence[it.key]) ? (r.presence[it.key].blocks || []) : []; roomsMap[eid]=JSON.parse(JSON.stringify(arr)); }
this._presenceDraft = { combo: it.key, rooms: roomsMap }; this._renderPresenceModal();
});
grid.append(btn);
}
list.append(grid);
}
// 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 = `<div><strong>${lh}:</strong> ${homeNames}</div><div><strong>${la}:</strong> ${awayNames}</div>`;
} 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=`<div class="t-main">${p.main}</div><div class="mer">${p.mer||''}</div>`; } 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='<ha-icon icon="mdi:plus"></ha-icon><span>'+this._t('ui.add_block')+'</span>';
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: allday 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 = `<div class="t-main">${p.main}</div><div class="mer">${p.mer||''}</div>`; } 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 += `<button class="btn success ed-fix-start" type="button">${fixStartLab}</button>`;
if (showEnd) btns += `<button class="btn success ed-fix-end" type="button">${fixEndLab}</button>`;
// No extra cancel button here; bottom Cancel handles it
errEl.innerHTML = `<div>${msg}</div><div style="margin-top:8px; display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap;">${btns}</div>`;
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 = `<div>${msg}</div>`; 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 = `<div>${msg}</div>`; 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 = `<div>${msg}</div>`; 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 = `<div>${msg}</div>`; 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 = `<div>${msg}</div>`; 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 = `<div>${msg}</div>`; 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 = `<div>${msg}</div>`; 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 += `<button class="btn success ed-fix-start" type="button">${fixStartLab}</button>`;
if (showEnd) btns += `<button class="btn success ed-fix-end" type="button">${fixEndLab}</button>`;
// No extra cancel here; use the modal cancel button
errEl.innerHTML = `<div>${msg}</div><div style=\"margin-top:8px; display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap;\">${btns}</div>`;
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;i<this._onboardSlides.length;i++){
const d = document.createElement('div'); d.className = 'dot' + (i===step?' active':''); dots.append(d);
}
}
if (next) next.style.display = step < this._onboardSlides.length-1 ? '' : 'none';
if (done) done.style.display = step === this._onboardSlides.length-1 ? '' : 'none';
// Apply i18n for title/buttons
this._applyCardI18n();
} catch {}
}
// ---------- Mutations ----------
_addBlock(entity){ const before = this._desiredNowSnapshot(); const row=this._schedules[entity]; const now=this._getNowMin(); const start=this._clamp(Math.round(now),0,1380); const end=this._clamp(Math.round(start+60), start+15, 1440); const id=Math.random().toString(36).slice(2,9); let t=(row.defaultTemp||20)+2; const mx=this._config?.max_temp ?? 25; if (Number.isFinite(mx)) t=Math.min(t,mx); const b={id,startMin:start,endMin:end,temp:t}; row.blocks.push(b); this._applyNoOverlapResize(entity,b,"left",b.startMin); this._applyNoOverlapResize(entity,b,"right",b.endMin); this._active={entity,id}; this._debouncedSaveStore(); this._render(); if (this._config.apply_on_edit) this._applyIfDesiredChanged(before); this._scheduleNextApply(); }
_deleteBlock(entity,id){ const before = this._desiredNowSnapshot(); const row=this._schedules[entity]; row.blocks=row.blocks.filter(b=>b.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 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 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 = ['<option value="">'+(label||'Select')+'</option>'].concat(
opts.map(o=>`<option value="${o.id}">${o.name} (${o.id})</option>`)
);
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 = `<style>${css}</style><div class="wrap"><div class="label"></div><div class="sel"><select aria-label="${label}">${selOpts.join('')}</select><span class="dd-arrow" aria-hidden="true"></span></div></div>`;
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 = `
<style>
.wrap { padding:8px 12px; display:grid; gap:16px; max-width: 480px; }
.owd-content { display:grid; gap:16px; }
.row { display:grid; gap:10px; }
.label { font-size:.85rem; color: var(--secondary-text-color); }
.entities { display:grid; gap:10px; }
/* Entity row (summary + collapsible details) */
.inline { transition: none; display:grid; gap:6px; max-width: 480px; grid-template-columns: 1fr; grid-auto-rows: auto; align-items: start; }
/* actions are inside summary; details always stack below summary */
.inline > div:first-child { grid-column: 1; grid-row: 1 / span 2; }
.inline > .actions { grid-column: 2; grid-row: 2; align-self: center; }
.inline .summary { display:flex; align-items:center; justify-content:space-between; padding:6px; border:1px dashed var(--divider-color); border-radius:8px; background: var(--secondary-background-color, transparent); }
.inline .summary-left { display:flex; align-items:center; gap:8px; min-width:0; }
.inline .summary-title { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width: 320px; }
/* New: subtitle below title with merged entities */
.inline .summary-text { display:grid; gap:2px; min-width:0; }
.inline .summary-sub { font-size:.8rem; color: var(--secondary-text-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width: 320px; }
.inline .expander { width:28px; height:28px; border-radius:8px; border:1px solid var(--divider-color); background: var(--card-background-color); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; }
.inline.open .expander { transform: rotate(180deg); }
.inline .details { display:none; grid-template-columns: minmax(250px, 440px); gap:6px; padding:6px; border:1px dashed var(--divider-color); border-radius:8px; background: var(--secondary-background-color, transparent); }
/* segmented control */
.seg { display:inline-flex; border:1px solid var(--divider-color); border-radius:10px; overflow:hidden; }
.seg button { padding:6px 10px; background: var(--card-background-color); color: var(--primary-text-color); border:0; cursor:pointer; }
.seg button.active { background: var(--primary-color); color: var(--text-primary-color, #fff); }
.inline.open .details { display:grid; }
ha-textfield { width:100%; max-width:440px; }
.label-input { width:100%; max-width:440px; display:block; }
/* Custom input style matching pickers */
.tt-input { height:36px; min-height:36px; padding:6px 10px; box-sizing:border-box; border:1px solid var(--divider-color); border-radius:8px; background: var(--card-background-color); color: var(--primary-text-color); }
.tt-input.tt-input-lg {
height:48px;
min-height:48px;
padding:0 14px;
border-radius:10px;
background: var(--secondary-background-color, transparent);
}
.tt-input.tt-input-lg::placeholder { color: var(--secondary-text-color); opacity: 1; }
.tt-input.tt-input-lg:focus { outline: none; }
.tt-input.tt-input-lg:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
/* entity picker width constraint */
.inline ha-entity-picker, .inline tt-entity-picker { width: 100%; min-width: 250px; max-width: 440px; overflow: hidden; text-overflow: ellipsis; display:block; min-height: 48px; }
/* Add entity button styles */
.add-entity-btn {
display:inline-flex; align-items:center; gap:8px;
width:max-content; padding:8px 12px;
border-radius:10px; border:1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
cursor:pointer; user-select:none;
max-width: 480px;
}
.add-entity-btn:focus { outline: 2px solid var(--primary-color); outline-offset: 2px; }
/* remove button */
.remove-btn { flex-shrink: 0; padding:4px 8px; border-radius:8px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--error-color); display:inline-flex; align-items:center; gap:6px; cursor:pointer; user-select:none; }
.actions { display:flex; gap:8px; justify-content:flex-end; align-items:center; }
.order-btn {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:8px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
cursor:pointer; user-select:none;
}
.order-btn[disabled] { opacity: .5; cursor: not-allowed; }
/* Drag & drop */
.drag-handle {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:8px;
border:1px dashed var(--divider-color);
background: var(--card-background-color);
cursor: grab; user-select:none;
}
.drag-handle:active { cursor: grabbing; }
.inline.drag-over { outline: 2px dashed var(--primary-color); outline-offset: 2px; }
/* Pointer-driven drag */
.drag-ghost {
position: fixed;
pointer-events: none;
opacity: .9;
z-index: 9999;
}
.placeholder { border:2px dashed var(--primary-color); border-radius:10px; margin:6px 0; }
/* Merge chips */
.linked-chips { display:flex; flex-wrap:wrap; gap:6px; }
.pill-chip { display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border:1px solid var(--divider-color); border-radius:999px; background: var(--secondary-background-color, transparent); color: var(--primary-text-color); }
.pill-chip .rm { cursor:pointer; border:none; background:transparent; color: var(--error-color); font-weight:700; line-height:1; padding:0 2px; }
.pill-chip .rm:focus { outline: 2px solid var(--primary-color); outline-offset:2px; }
/* Tabs */
/* Tabs: show as two rows (3 columns) to avoid cramped layout */
/* Tabs: wrap to next line when space runs out; keep nice gaps */
.tabs { display:flex; flex-wrap: wrap; gap:12px 18px; align-items:flex-end; padding:0 6px; margin:8px 0; border-bottom: 1px solid var(--divider-color); position:relative; z-index:3; }
.tab-btn { appearance:none; border:none; background:transparent; color: var(--secondary-text-color); padding:10px 2px; margin:0; cursor:pointer; font-weight:600; position:relative; max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tab-btn::after { content:""; position:absolute; left:0; right:0; bottom:-1px; height:2px; background: transparent; border-radius:2px; }
.tab-btn.active { color: var(--primary-text-color); }
.tab-btn.active::after { background: var(--primary-color); }
/* Integration availability status (small badge under tabs) */
.status-wrap { display:flex; justify-content:flex-start; padding:0 6px 4px; }
.int-status { display:inline-flex; align-items:center; gap:4px; padding:2px 6px; border:1px solid var(--divider-color); border-radius:999px; background: var(--secondary-background-color, transparent); cursor:pointer; }
.int-status.ok { color: var(--success-color, #2e7d32); border-color: color-mix(in oklab, var(--success-color, #2e7d32) 50%, var(--divider-color)); }
.int-status.bad { color: var(--error-color); border-color: color-mix(in oklab, var(--error-color) 50%, var(--divider-color)); }
.int-status ha-icon { --mdc-icon-size: 14px; }
.int-status .text { font-size:.8rem; white-space:nowrap; }
/* Remove blue focus ring on click; keep it for keyboard users only */
.tab-btn:focus { outline: none; }
.tab-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset:2px; border-radius:4px; }
/* Compact inputs for Colors tab */
.colors-row { display:grid; grid-template-columns: 88px 88px 48px 1fr; gap:8px; align-items:center; padding-right:6px; }
.colors-row input[type=number] { width:88px; height:28px; padding:4px 6px; border:1px solid var(--divider-color); border-radius:8px; background: var(--card-background-color); color: var(--primary-text-color); box-sizing: border-box; display:block; }
.colors-row input[type=color] { width:48px; height:28px; padding:0; border:1px solid var(--divider-color); border-radius:6px; background: transparent; box-sizing: border-box; display:block; }
.colors-row .remove-btn { padding:4px 10px; width:auto; justify-self:end; align-self:center; }
.colors-head { display:grid; grid-template-columns: 88px 88px 48px 1fr; gap:8px; align-items:center; margin:0; color: var(--secondary-text-color); font-size:.8rem; line-height:1; }
/* Visual card for each color range */
.color-item { border:1px dashed var(--divider-color); border-radius:10px; padding:6px; background: var(--secondary-background-color, transparent); }
.color-item + .color-item { margin-top:6px; }
/* Compact info icon with native tooltip (title) */
.info-icon { width:18px; height:18px; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--divider-color); border-radius:50%; color: var(--secondary-text-color); font-weight:700; font-size:.75rem; cursor:pointer; background: transparent; }
.info-icon::after { content:'?'; line-height:1; }
.info-pop { position: fixed; z-index: 10000; max-width: 520px; padding: 10px 12px; border-radius: 8px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); box-shadow: var(--ha-card-box-shadow, 0 4px 12px rgba(0,0,0,.2)); font-size:.9rem; }
.info-pop.hidden { display:none; }
/* Space the add button away from header line */
.tab-colors .add-entity-btn { margin-top:6px; }
.colors-head span:nth-child(1),
.colors-head span:nth-child(2),
.colors-head span:nth-child(3) { padding-left:10px; }
/* Away tab: fix picker width */
.tab-away .away-person-picker { width: 320px; min-width: 320px; max-width: 320px; }
/* Colors tab actions row */
.tab-colors .colors-actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:6px; }
/* Bottom action row: align heights of buttons */
.row-bottom-actions .reset-onboard { padding:4px 8px; border-radius:8px; display:none !important; }
/* Empty state + attention pulse */
.empty-box { border:1px dashed var(--divider-color); border-radius:10px; padding:14px; max-width:480px; color: var(--secondary-text-color); display:grid; gap:10px; }
.pulse-attn { position:relative; }
.pulse-attn.pulse { animation: tt-pulse 1.2s ease-in-out 0s 4; }
@keyframes tt-pulse { 0% { box-shadow: 0 0 0 0 rgba(var(--rgb-primary-color, 3,155,229), .8); }
70% { box-shadow: 0 0 0 10px rgba(0,0,0,0); }
100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); } }
/* Store controls: dropdown above, then buttons side-by-side */
.store-controls { display:flex; flex-direction: column; gap:8px; align-items: flex-start; }
.store-controls .storage, .store-controls .storage-settings { width: 100%; }
.store-controls .store-btns { display:flex; gap:8px; align-items:stretch; flex-wrap: nowrap; justify-content: flex-start; width:100%; }
.store-controls .store-btns .remove-btn { flex: 1 1 0; justify-content: center; height:100%; text-align:center; }
/* Holidays inline editor */
.holiday-ed { display:grid; gap:8px; }
.holiday-ed .wk-tab{ appearance:none; border:none; background:transparent; color: var(--secondary-text-color); cursor:pointer; padding:6px 10px; border-bottom:2px solid transparent; border-radius:0; font-weight:600; }
.holiday-ed .wk-tab.active{ color: var(--primary-text-color); border-bottom-color: var(--primary-color); }
.holiday-ed .week-scale{ position:relative; height:32px; margin:0 6px; }
.holiday-ed .week-track{ position:relative; height:64px; border:1px dashed var(--divider-color); border-radius:10px; overflow:hidden; margin:0 6px; }
.holiday-ed .block{ position:absolute; top:6px; bottom:6px; border:1px solid var(--divider-color); border-radius:10px; background: var(--secondary-background-color, rgba(0,0,0,.05)); display:flex; align-items:center; gap:10px; padding:0 10px; box-sizing:border-box; }
.holiday-ed .block .pill{ display:none; }
.holiday-ed .hol-toolbar{ display:flex; justify-content:flex-end; gap:8px; }
.holiday-ed .mini-ed{ border:1px solid var(--divider-color); border-radius:10px; padding:8px; display:grid; gap:8px; max-width: 460px; background: var(--secondary-background-color, transparent); }
.holiday-ed .mini-ed .row{ display:flex; gap:8px; align-items:center; }
.holiday-ed input[type=time], .holiday-ed input[type=number]{ height:28px; padding:2px 6px; border:1px solid var(--divider-color); border-radius:8px; background: var(--card-background-color); color: var(--primary-text-color); }
/* Make mini-editor buttons same height */
.holiday-ed .mini-ed .row .add-entity-btn,
.holiday-ed .mini-ed .row .remove-btn{ height:36px; min-height:36px; display:inline-flex; align-items:center; }
/* Make Backup row buttons equal height */
.tab-backup .add-entity-btn,
.tab-backup .remove-btn{ height:40px; min-height:40px; display:inline-flex; align-items:center; }
/* Match add-date button height to date input */
.tab-holidays .settings-card .add-entity-btn { height:36px; min-height:36px; display:inline-flex; align-items:center; }
.tab-holidays .settings-card input.tt-input[type=date] { height:36px; min-height:36px; }
/* Tooltip style (same as card) */
.wk-tooltip{ position:absolute; z-index:10000; pointer-events:none; padding:6px 8px; border-radius:8px; border:1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size:.85rem; box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0,0,0,.2)); }
</style>
<div class="tabs" role="tablist"><button type="button" class="tab-btn tab-settings-btn active" role="tab" aria-selected="true">Settings</button><button type="button" class="tab-btn tab-backup-btn" role="tab" aria-selected="false">Backup</button></div>
<div class="wrap tab-settings">
<div class="row"><div class="label">Titel</div><ha-textfield class="title" label="Titel"></ha-textfield></div>
<style>
/* Settings (grouped card with separators) */
.settings-card {
border:1px solid var(--divider-color);
border-radius:12px;
background: var(--card-background-color);
padding:12px;
display:grid;
gap:10px;
max-width: 480px;
}
.settings-card .grid2 {
display:grid;
grid-template-columns: repeat(4, 1fr);
gap:10px;
margin-bottom: 6px;
}
.settings-card .sfield { display:grid; gap:2px; align-items:start; }
.settings-card .slabel { font-size:.8rem; color: var(--secondary-text-color); line-height:1; padding-left:4px; }
.settings-card .settings-input { width:88px; height:28px; padding:4px 6px; border:1px solid var(--divider-color); border-radius:8px; background: var(--card-background-color); color: var(--primary-text-color); box-sizing:border-box; }
.settings-card .settings-input:disabled { opacity:.55; cursor:not-allowed; filter: grayscale(60%); color: var(--disabled-text-color, var(--secondary-text-color)); background: var(--secondary-background-color, var(--card-background-color)); }
.settings-card .setting {
display:grid;
grid-template-columns: 1fr auto;
align-items:center;
gap:10px;
padding:10px 4px;
}
.settings-card .setting + .setting {
border-top: 1px solid var(--divider-color);
}
.settings-card .title { font-weight:600; }
.settings-card .desc { font-size:.85rem; color: var(--secondary-text-color); margin-top:2px; }
</style>
<!-- Sync Engine (moved into Settings tab) -->
<div class="row">
<div class="label"></div>
<div class="settings-card store-card">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div style="display:grid; gap:2px;">
<div class="store-title" style="font-weight:600; display:flex; align-items:center; gap:6px;"><span class="store-title-text">Shared storage + background control</span><button type="button" class="info-icon" aria-label="Info"></button></div>
<div class="store-desc" style="font-size:.85rem; color:var(--secondary-text-color);"><span class="store-desc-text">Saves schedules to file storage and enables background control.</span></div>
</div>
<ha-switch class="store-enable"></ha-switch>
</div>
<div class="store-controls" style="display:flex; flex-direction:column; gap:8px; align-items:flex-start; margin-top:6px;">
<!-- Note: Entity pickers removed - file-based storage is always used -->
<div class="store-btns">
<button type="button" class="remove-btn migrate-to-store"><ha-icon icon="mdi:upload"></ha-icon><span>Transfer browser data to storage</span></button>
</div>
</div>
</div>
</div>
<!-- Bottom actions: clear local + reset onboarding -->
<div class="row row-bottom-actions" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button class="remove-btn clear-local-only" type="button"><ha-icon icon="mdi:delete-outline"></ha-icon><span>Clear local only</span></button>
<button class="add-entity-btn reset-onboard" type="button"><ha-icon icon="mdi:movie-play"></ha-icon><span>Show welcome again</span></button>
</div>
</div>
<!-- Backup tab (top-level sibling) -->
<div class="wrap tab-backup" style="display:none; max-width:640px;">
<div class="settings-card">
<div class="setting">
<div class="text">
<div class="title backup-title">Backup</div>
<div class="desc backup-desc">Kopiér hele indholdet af din syncsensor til backupsensoren og gendan den igen ved behov.</div>
</div>
<div></div>
</div>
<div class="setting" style="display:flex; flex-direction:column; gap:10px; align-items:stretch;">
<div class="text">
<div class="title backup-slot-title">Choose backup</div>
<div class="desc backup-slot-desc">Select which backup slot to restore.</div>
</div>
<div style="display:flex; width:100%;">
<select class="backup-slot-select" style="min-width:200px; max-width:320px; width:100%;"></select>
</div>
<div class="backup-actions-row" style="display:flex; align-items:center; gap:8px; justify-content:flex-start; flex-wrap:wrap; width:100%;">
<button type="button" class="add-entity-btn btn-backup-now"><ha-icon icon="mdi:archive-arrow-down"></ha-icon><span class="backup-now-label">Backup now</span></button>
<button type="button" class="remove-btn btn-restore-now"><ha-icon icon="mdi:archive-arrow-up"></ha-icon><span class="backup-restore-label">Restore</span></button>
<button type="button" class="remove-btn btn-delete-backup"><ha-icon icon="mdi:delete-outline"></ha-icon><span class="backup-delete-label">Delete</span></button>
</div>
</div>
<div class="setting" style="display:flex; flex-direction:column; gap:10px; align-items:stretch;">
<div class="text">
<div class="title backup-import-title">Import thermostat_timeline.json</div>
<div class="desc backup-import-desc">Upload a thermostat_timeline.json file and import it. A new backup is created first.</div>
</div>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; width:100%;">
<input class="backup-import-file" type="file" accept=".json,application/json" />
<button type="button" class="add-entity-btn btn-import-store"><ha-icon icon="mdi:file-upload-outline"></ha-icon><span class="backup-import-btn-label">Import file</span></button>
</div>
<div class="backup-import-error" style="display:none; color: var(--error-color); font-size:.85rem;"></div>
</div>
<div class="setting">
<div class="text">
<div class="title backup-auto-title">Auto backup</div>
<div class="desc backup-auto-desc">Lav automatisk backup med faste intervaller.</div>
</div>
<ha-switch class="backup-auto"></ha-switch>
</div>
<div class="setting backup-interval-row">
<div class="text">
<div class="title backup-int-title">Interval (dage)</div>
<div class="desc backup-int-desc">Hvor mange dage der skal gå mellem automatiske backups (min. 1).</div>
</div>
<input class="backup-days settings-input" type="number" min="1" max="365" step="1" />
</div>
<div class="setting">
<div class="text">
<div class="title backup-last-title">Sidste backup</div>
<div class="desc last-backup-desc"></div>
</div>
<div></div>
</div>
</div>
</div>`; 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,
// open_window is now configured via in-card popup; preserve base.open_window
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 p = { settings };
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) : '';
const label = labelTs || 'Backup';
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;
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<want;i++) {
const line = this._makeEntityRow(i);
const key = (this._config.entities?.[i]) || `#idx:${i}`;
if (this._openRows.has(key)) line.classList.add('open');
wrap.append(line);
}
// If Rooms tab is visible, proactively hydrate all pickers
try { if (this._activeTab === 'rooms') this._forcePickerRefresh(); } catch {}
for (let i=0;i<want;i++){
const row = wrap.children[i];
const picker = row.querySelector("tt-entity-picker, ha-entity-picker");
if (picker) {
picker.hass = this._hass;
try { picker.style.display = 'block'; picker.style.minHeight = '48px'; } catch {}
try { if (typeof picker.requestUpdate === 'function') picker.requestUpdate(); } catch {}
picker.value = this._config.entities[i] || "";
// Exclude entities already selected in other rows, or linked elsewhere; allow the current selection
picker.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[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 = '<ha-icon icon="mdi:plus"></ha-icon><span>' + (this._t('editor.colors.go_add') || 'Add room') + '</span>';
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 = '<ha-icon icon="mdi:chevron-down"></ha-icon>';
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 = `<span>${this._t('editor.colors.col_from')}</span><span>${this._t('editor.colors.col_to')}</span><span>${this._t('editor.colors.col_color')}</span><span></span>`;
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 = '<ha-icon icon="mdi:close"></ha-icon><span>' + (this._t('editor.remove') || 'Remove') + '</span>';
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 = '<ha-icon icon="mdi:plus"></ha-icon><span>' + (this._t('editor.heat_colors.add') || 'Add color range') + '</span>';
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 = '<ha-icon icon="mdi:chevron-down"></ha-icon>';
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 = `<span>${this._t('editor.colors.col_from')}</span><span>${this._t('editor.colors.col_to')}</span><span>${this._t('editor.colors.col_color')}</span><span></span>`;
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 = '<ha-icon icon="mdi:close"></ha-icon><span>' + (this._t('editor.remove') || 'Remove') + '</span>';
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 = '<ha-icon icon="mdi:plus"></ha-icon><span>' + (this._t('editor.heat_colors.add') || 'Add color range') + '</span>';
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 = '<ha-icon icon="mdi:refresh"></ha-icon><span>' + (this._t('editor.colors.refresh') || 'Update schedule') + '</span>';
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 = '<ha-icon icon="mdi:delete-alert"></ha-icon><span>' + (this._t('editor.colors.clear_all') || 'Clear all color data') + '</span>';
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 = '<ha-icon icon="mdi:plus"></ha-icon><span>'+(this._t('editor.add_entity')||'Add room')+'</span>';
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 = '<ha-icon icon="mdi:plus"></ha-icon><span>'+(this._t('holidays.add_date')||'Add date')+'</span>';
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 = '<ha-icon icon="mdi:calendar-range"></ha-icon><span>'+(this._t('holidays.add_range')||'Add range')+'</span>';
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 = '<ha-icon icon="mdi:plus"></ha-icon><span>'+ (this._t('ui.add_block') || 'Add block') +'</span>';
const saveBtn = document.createElement('button'); saveBtn.className='add-entity-btn'; saveBtn.innerHTML = '<ha-icon icon="mdi:content-save"></ha-icon><span>'+ (this._t('ui.save')||'Save') +'</span>';
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 = `
<div class="row"><label style="min-width:64px;">${this._t('ui.from')}</label><input class="mi-from" type="time" step="60"></div>
<div class="row"><label style="min-width:64px;">${this._t('ui.to')}</label><input class="mi-to" type="time" step="60"></div>
<div class="row"><label style="min-width:64px;">${this._t('ui.temperature')}</label><input class="mi-temp" type="number" step="0.5"></div>
<div class="row" style="justify-content:flex-end; gap:8px;">
<button type="button" class="remove-btn mi-del"><ha-icon icon="mdi:close"></ha-icon><span>${this._t('ui.delete')}</span></button>
<button type="button" class="add-entity-btn mi-cancel"><ha-icon icon="mdi:close-circle-outline"></ha-icon><span>${this._t('ui.cancel')}</span></button>
<button type="button" class="add-entity-btn mi-save"><ha-icon icon="mdi:content-save"></ha-icon><span>${this._t('ui.save')}</span></button>
</div>
`;
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 = `<ha-icon icon="mdi:chevron-down"></ha-icon>`; 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 = `<ha-icon icon="mdi:drag"></ha-icon>`; 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 = `<ha-icon icon="mdi:close"></ha-icon><span>${this._t('editor.remove')}</span>`; 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 doesnt 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','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 = ' <a href="https://my.home-assistant.io/redirect/hacs_repository/?owner=qlerup&repository=thermostat-pro-timeline-sync" target="_blank" rel="noopener noreferrer" style="color:var(--error-color);text-decoration:underline;font-weight:500;">[Åbn i HACS]</a>';
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 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();