/** * Main Application Logic * Connects the UI components with the Home Assistant API */ // Helper function to get the correct document root for DOM queries function getDocumentRoot() { // If panel is using shadow DOM (LitElement), use shadowRoot if (window.climateSchedulerPanelRoot?.shadowRoot) { return window.climateSchedulerPanelRoot.shadowRoot; } // Otherwise use the panel element directly or document return window.climateSchedulerPanelRoot || document; } function normalizeGroupsPayload(result) { let payload = result?.response || result || {}; if (payload && typeof payload === 'object' && payload.groups && typeof payload.groups === 'object') { payload = payload.groups; } return (payload && typeof payload === 'object') ? payload : {}; } let haAPI; let graph; let nodeSettingsTimeline = null; let climateEntities = []; let entitySchedules = new Map(); // Track which entities have schedules locally let temperatureUnit = '°C'; // Default to Celsius, updated from HA config let storedTemperatureUnit = null; // Unit that schedules were saved in // Temperature conversion functions are now in utils.js function convertScheduleNodes(nodes, fromUnit, toUnit) { if (!nodes || nodes.length === 0 || fromUnit === toUnit) return nodes; return nodes.map(node => ({ ...node, temp: normalizeTemperature(convertTemperature(node.temp, fromUnit, toUnit), graphSnapStep) })); } function getStepPrecision(step) { if (!Number.isFinite(step) || step <= 0) { return 3; } const stepString = step.toString().toLowerCase(); if (stepString.includes('e-')) { const exponent = Number(stepString.split('e-')[1]); return Number.isFinite(exponent) ? exponent : 3; } const decimalPart = stepString.split('.')[1]; return decimalPart ? decimalPart.length : 0; } function roundToPrecision(value, precision) { const safePrecision = Math.max(0, Math.min(precision, 10)); const factor = 10 ** safePrecision; return Math.round((value + Number.EPSILON) * factor) / factor; } function normalizeTemperature(value, step = null) { if (!Number.isFinite(value)) { return value; } if (Number.isFinite(step) && step > 0) { const snapped = Math.round(value / step) * step; return roundToPrecision(snapped, getStepPrecision(step)); } return roundToPrecision(value, 4); } function resolveNoChangeLockedTemp(nodes, targetNode) { if (!Array.isArray(nodes) || nodes.length === 0 || !targetNode) { return null; } const toNumericTemp = (value) => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }; const ownTemp = toNumericTemp(targetNode.temp); // Single-node schedules lock to the node's own value. if (nodes.length === 1) { return ownTemp; } const sortedNodes = [...nodes].sort((a, b) => { const [aHours, aMinutes] = (a.time || '00:00').split(':').map(Number); const [bHours, bMinutes] = (b.time || '00:00').split(':').map(Number); const aDecimal = aHours + (aMinutes / 60); const bDecimal = bHours + (bMinutes / 60); return aDecimal - bDecimal; }); let targetIndex = sortedNodes.findIndex((node) => node === targetNode); if (targetIndex === -1 && targetNode.time) { targetIndex = sortedNodes.findIndex((node) => node.time === targetNode.time); } if (targetIndex === -1) { return ownTemp; } const previousIndex = (targetIndex - 1 + sortedNodes.length) % sortedNodes.length; const previousTemp = toNumericTemp(sortedNodes[previousIndex]?.temp); return previousTemp ?? ownTemp; } let allGroups = {}; // Store all groups data let allEntities = {}; // Store all entities data with their schedules let currentGroup = null; // Currently selected group let currentEntityId = null; // Currently selected individual entity (legacy path, mostly unused now) let editingProfile = null; // Profile being edited (null means editing active profile) let tooltipMode = 'history'; // 'history' or 'cursor' let keyframeTimelineLoaded = false; // Track if keyframe-timeline.js is loaded let debugPanelEnabled = localStorage.getItem('debugPanelEnabled') === 'true'; // Debug panel visibility let graphSnapStep = parseFloat(localStorage.getItem('graphSnapStep')) || 0.5; // Temperature snap step for graph dragging let inputTempStep = parseFloat(localStorage.getItem('inputTempStep')) || 0.1; // Temperature step for input fields let humidityStep = parseFloat(localStorage.getItem('humidityStep')) || 1; // Step for humidity slider in node settings dialog const cleanupAutoDownloadReports = localStorage.getItem('cleanupAutoDownloadReports') === 'true'; // Opt-in: auto-download cleanup preview/result JSON files let currentDay = null; // Currently selected day for editing (e.g., 'mon', 'weekday') let currentScheduleMode = 'all_days'; // Current schedule mode: 'all_days', '5/2', 'individual' let currentSchedule = []; // Currently loaded schedule nodes with all properties (time, temp, hvac_mode, etc.) let isLoadingSchedule = false; // Flag to prevent auto-save during schedule loading let isSaveInProgress = false; // Flag to prevent concurrent saves let pendingSaveNeeded = false; // Flag to indicate a save was skipped and needs to be retried let saveTimeout = null; // Timeout for debouncing save operations const SAVE_DEBOUNCE_MS = 300; // Wait 300ms after last change before saving let serverTimeZone = null; // Store the Home Assistant server timezone function flushPendingSaveIfNeeded() { if (!isLoadingSchedule && !isSaveInProgress && pendingSaveNeeded) { pendingSaveNeeded = false; saveSchedule(); } } // Debug logging function function debugLog(message, type = 'info') { const timestamp = new Date().toLocaleTimeString(); const shouldMirrorToConsole = typeof message === 'string' && (message.includes('[Undo]') || message.includes('[Dialog]')); if (shouldMirrorToConsole) { const consoleMessage = `[Climate Scheduler ${timestamp}] ${message}`; if (type === 'error') { console.error(consoleMessage); } else if (type === 'warning') { console.warn(consoleMessage); } else { console.info(consoleMessage); } } if (debugPanelEnabled) { const debugContent = getDocumentRoot().querySelector('#debug-content'); if (debugContent) { const messageDiv = document.createElement('div'); messageDiv.className = `debug-message ${type}`; messageDiv.innerHTML = `${timestamp}${message}`; debugContent.appendChild(messageDiv); debugContent.scrollTop = debugContent.scrollHeight; } } } function showToast(message, type = 'info', duration = 4000) { const root = getDocumentRoot(); let container = root.querySelector('.toast-container'); // Create container if it doesn't exist if (!container) { container = document.createElement('div'); container.className = 'toast-container'; root.appendChild(container); } // Create toast element const toast = document.createElement('div'); toast.className = `toast ${type}`; // Icon based on type const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; toast.innerHTML = ` ${icons[type] || icons.info} ${message} `; container.appendChild(toast); // Auto-remove after duration setTimeout(() => { toast.classList.add('hiding'); setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } // Remove container if empty if (container.children.length === 0) { if (container.parentNode) { container.parentNode.removeChild(container); } } }, 300); // Match animation duration }, duration); } // Initialize application async function initApp() { try { // Detect mobile app environment const isMobileApp = /HomeAssistant|Home%20Assistant/.test(navigator.userAgent); if (isMobileApp) { // Running in Home Assistant mobile app } // Initialize Home Assistant API (only if not already initialized) if (!haAPI) { haAPI = new HomeAssistantAPI(); } await haAPI.connect(); // Get Home Assistant configuration for temperature unit and timezone const config = await haAPI.getConfig(); if (config && config.unit_system && config.unit_system.temperature) { temperatureUnit = config.unit_system.temperature === '°F' ? '°F' : '°C'; } if (config && config.time_zone) { serverTimeZone = config.time_zone; debugLog(`Server timezone: ${serverTimeZone}`); } // Note: graph is initialized when entity/group is selected for editing // No need to initialize it on page load // Load climate entities await loadClimateEntities(); // Load all schedules from backend await loadAllSchedules(); // Load groups await loadGroups(); // Subscribe to state changes await haAPI.subscribeToStateChanges(); haAPI.onStateUpdate(handleStateUpdate); // Subscribe to profile change events await haAPI.subscribeToEvents('climate_scheduler_profile_changed', handleProfileChanged); // Set up UI event listeners setupEventListeners(); } catch (error) { console.error('Failed to initialize app:', error); console.error('Error stack:', error.stack); console.error('Error message:', error.message); // Show user-friendly error in the UI const container = getDocumentRoot().querySelector('.container'); if (container) { container.innerHTML = `

❌ Connection Failed

Could not connect to Home Assistant

Technical Details
${error.message}\n\n${error.stack}

Troubleshooting:
• Try refreshing the page (pull down on mobile)
• Check if you're logged into Home Assistant
• Restart the Home Assistant app
• Check Settings → Companion App → Debugging

`; } } } // Load all climate entities async function loadClimateEntities() { try { climateEntities = await haAPI.getClimateEntities(); await renderEntityList(); // Entity list rendered } catch (error) { console.error('Failed to load climate entities:', error); alert('Failed to load climate entities'); } } // Load all schedules from backend to populate entitySchedules Map async function loadAllSchedules() { try { // Load schedules for all entities for (const entity of climateEntities) { const result = await haAPI.getSchedule(entity.entity_id); // Extract schedule from response wrapper const schedule = result?.response || result; // Only add to entitySchedules if it has nodes AND is not ignored // The enabled state affects visual display, not categorization if (schedule && schedule.nodes && schedule.nodes.length > 0 && !schedule.ignored) { // Entity has a schedule and is not ignored - add to Map entitySchedules.set(entity.entity_id, schedule.nodes); } } // Re-render entity list with loaded schedules await renderEntityList(); } catch (error) { console.error('Failed to load schedules:', error); } } // Load all groups from backend async function loadGroups() { try { const result = await haAPI.getGroups(); allGroups = normalizeGroupsPayload(result); // Render groups section renderGroups(); // Render unmonitored entities section renderIgnoredEntities(); // Refresh global profile editor section await refreshGlobalProfileEditor(); // Re-render entity list now that groups are loaded // This will hide entities that are in groups await renderEntityList(); } catch (error) { console.error('Failed to load groups:', error); allGroups = {}; } } // Render groups in the groups section function renderGroups() { const groupsList = getDocumentRoot().querySelector('#groups-list'); const groupsCount = getDocumentRoot().querySelector('#groups-count'); if (!groupsList) return; // Save current expanded/collapsed state and current editing group const expandedStates = {}; const containers = groupsList.querySelectorAll('.group-container'); containers.forEach(container => { const groupName = container.dataset.groupName; if (groupName) { expandedStates[groupName] = { collapsed: container.classList.contains('collapsed'), editing: container.classList.contains('expanded') }; } }); // Clear existing groups groupsList.innerHTML = ''; // Filter out ignored single-entity groups const groupNames = Object.keys(allGroups).filter(groupName => { const groupData = allGroups[groupName]; // Hide single-entity groups that are marked as ignored const isSingleEntity = groupData.entities && groupData.entities.length === 1; const isIgnored = groupData.ignored === true; return !(isSingleEntity && isIgnored); }); // Update count if (groupsCount) { groupsCount.textContent = groupNames.length; } if (groupNames.length === 0) { groupsList.innerHTML = '

No groups created yet

'; return; } // Create container for each group groupNames.forEach(groupName => { const groupContainer = createGroupContainer(groupName, allGroups[groupName]); // Restore previous state if it existed const savedState = expandedStates[groupName]; if (savedState) { if (savedState.collapsed) { groupContainer.classList.add('collapsed'); const toggleIcon = groupContainer.querySelector('.group-toggle-icon'); if (toggleIcon) { toggleIcon.style.transform = 'rotate(-90deg)'; } } if (savedState.editing) { // Re-expand the editor for this group setTimeout(() => editGroupSchedule(groupName), 0); } } groupsList.appendChild(groupContainer); }); } // Render unmonitored entities in the unmonitored section function renderIgnoredEntities() { const ignoredContainer = getDocumentRoot().querySelector('#ignored-entities-container'); const ignoredCount = getDocumentRoot().querySelector('#ignored-count'); if (!ignoredContainer) return; // Find all climate entities that are NOT in a monitored group // This includes: 1) entities not in any group, 2) entities in single-entity groups with ignored=true const unmonitoredEntities = climateEntities.filter(entity => { const entityId = entity.entity_id; // Check if entity is in any group let isInMonitoredGroup = false; for (const [groupName, groupData] of Object.entries(allGroups)) { if (groupData.entities && groupData.entities.includes(entityId)) { // Found the entity in a group const isSingleEntity = groupData.entities.length === 1; const isIgnored = groupData.ignored === true; // If it's in a single-entity group that's ignored, it's unmonitored // If it's in any other group (multi-entity or monitored single-entity), it's monitored if (!isSingleEntity || !isIgnored) { isInMonitoredGroup = true; } break; } } // Include if NOT in a monitored group return !isInMonitoredGroup; }); // Sort alphabetically by friendly name unmonitoredEntities.sort((a, b) => { const nameA = (a.attributes?.friendly_name || a.entity_id).toLowerCase(); const nameB = (b.attributes?.friendly_name || b.entity_id).toLowerCase(); return nameA.localeCompare(nameB); }); // Update count if (ignoredCount) { ignoredCount.textContent = unmonitoredEntities.length; } // Clear existing content ignoredContainer.innerHTML = ''; if (unmonitoredEntities.length === 0) { ignoredContainer.innerHTML = '

No unmonitored entities

'; return; } // Render each unmonitored entity unmonitoredEntities.forEach(entity => { const entityId = entity.entity_id; const friendlyName = entity.attributes?.friendly_name || entityId; const item = document.createElement('div'); item.className = 'ignored-entity-item'; item.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer;'; const nameSpan = document.createElement('span'); nameSpan.textContent = friendlyName; nameSpan.style.flex = '1'; const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'display: flex; gap: 8px;'; const unignoreBtn = document.createElement('button'); unignoreBtn.textContent = 'Monitor'; unignoreBtn.className = 'btn-secondary-outline'; unignoreBtn.style.cssText = 'padding: 4px 12px; font-size: 0.85rem;'; unignoreBtn.onclick = async (e) => { e.stopPropagation(); if (confirm(`Start monitoring ${friendlyName}?\n\nThis entity will be managed by the scheduler again.`)) { try { await haAPI.setIgnored(entityId, false); // Verify the entity was updated successfully const result = await haAPI.getSchedule(entityId); const schedule = result?.response || result; if (schedule && schedule.ignored === false) { showToast(`${friendlyName} is now monitored.`, 'success'); } else { // Still show success since setIgnored didn't throw showToast(`${friendlyName} is now monitored.`, 'success'); } // Reload groups to update the display await loadGroups(); } catch (error) { console.error('Failed to monitor entity:', error); showToast('Failed to monitor entity: ' + error.message, 'error'); } } }; const addToGroupBtn = document.createElement('button'); addToGroupBtn.textContent = 'Add to Group'; addToGroupBtn.className = 'btn-primary-outline'; addToGroupBtn.style.cssText = 'padding: 4px 12px; font-size: 0.85rem;'; addToGroupBtn.onclick = async (e) => { e.stopPropagation(); // Show the add to group modal without monitoring yet const modal = getDocumentRoot().querySelector('#add-to-group-modal'); const entityNameEl = getDocumentRoot().querySelector('#add-entity-name'); if (modal && entityNameEl) { // Store entity info on modal modal.dataset.entityId = entityId; modal.dataset.isUnmonitoredAdd = 'true'; entityNameEl.textContent = friendlyName; // Show the modal (will populate groups in existing handler) showAddToGroupModal(entityId); } else { showToast(`Failed to show group selection modal`, 'error'); } }; buttonContainer.appendChild(unignoreBtn); buttonContainer.appendChild(addToGroupBtn); item.appendChild(nameSpan); item.appendChild(buttonContainer); ignoredContainer.appendChild(item); }); } // Track last click time per group to prevent duplicate clicks const lastClickTime = {}; const CLICK_DEBOUNCE_MS = 500; let isProcessingGroupClick = false; // Create a group container element function createGroupContainer(groupName, groupData) { const container = document.createElement('div'); container.className = 'group-container collapsed'; container.dataset.groupName = groupName; // Create header const header = document.createElement('div'); header.className = 'group-header'; const leftSide = document.createElement('div'); leftSide.style.display = 'flex'; leftSide.style.alignItems = 'center'; leftSide.style.gap = '8px'; const toggleIcon = document.createElement('span'); toggleIcon.className = 'group-toggle-icon'; toggleIcon.textContent = '▼'; toggleIcon.style.transform = 'rotate(-90deg)'; const title = document.createElement('span'); title.className = 'group-title'; // Always display the actual group name for consistency with service calls/actions title.textContent = groupName; leftSide.appendChild(toggleIcon); leftSide.appendChild(title); header.appendChild(leftSide); // Add rename button for all groups const actions = document.createElement('div'); actions.className = 'group-actions'; actions.style.cssText = 'display: flex; gap: 12px; align-items: center;'; // Add Active Profile selector const profileSelector = document.createElement('div'); profileSelector.className = 'group-profile-selector'; profileSelector.style.cssText = 'display: flex; align-items: center; gap: 8px;'; const profileDropdown = document.createElement('select'); profileDropdown.className = 'group-profile-dropdown'; profileDropdown.dataset.groupName = groupName; profileDropdown.style.cssText = 'padding: 4px 8px; font-size: 0.9rem; border: 1px solid var(--border); border-radius: 4px; background: var(--background); color: var(--text-primary);'; // Get profiles for this group const activeProfile = groupData.active_profile || 'Default'; const profiles = groupData.profiles ? Object.keys(groupData.profiles) : ['Default']; profiles.forEach(profileName => { const option = document.createElement('option'); option.value = profileName; option.textContent = profileName; if (profileName === activeProfile) { option.selected = true; } profileDropdown.appendChild(option); }); // Add event listener for profile change profileDropdown.addEventListener('click', (e) => e.stopPropagation()); profileDropdown.addEventListener('change', async (e) => { e.stopPropagation(); const newProfile = e.target.value; try { await haAPI.setActiveProfile(groupName, newProfile); // Reload group data from server const groupsResult = await haAPI.getGroups(); allGroups = normalizeGroupsPayload(groupsResult); // If this group is currently being edited, reload it if (currentGroup === groupName) { await editGroupSchedule(groupName); } showToast(`Switched ${groupName} to profile: ${newProfile}`, 'success'); } catch (error) { console.error('Failed to switch profile:', error); showToast('Failed to switch profile', 'error'); // Revert dropdown profileDropdown.value = activeProfile; } }); profileSelector.appendChild(profileDropdown); actions.appendChild(profileSelector); // Enable/disable toggle - reuse the styled toggle switch from settings const toggleLabel = document.createElement('label'); toggleLabel.className = 'toggle-switch'; toggleLabel.title = 'Enable schedule'; const toggleInput = document.createElement('input'); toggleInput.type = 'checkbox'; toggleInput.checked = groupData.enabled !== false; const sliderSpan = document.createElement('span'); sliderSpan.className = 'slider'; // Handle toggle changes toggleInput.addEventListener('click', async (e) => { e.stopPropagation(); }); toggleInput.addEventListener('change', async (e) => { e.stopPropagation(); const newState = toggleInput.checked; toggleInput.disabled = true; try { if (newState) { await haAPI.enableGroup(groupName); showToast(`Enabled scheduling for ${groupName}`, 'success'); } else { await haAPI.disableGroup(groupName); showToast(`Disabled scheduling for ${groupName}`, 'info'); } await loadGroups(); } catch (error) { console.error('Failed to toggle group enabled state:', error); showToast('Failed to update enabled state: ' + (error.message || error), 'error'); // Revert toggleInput.checked = !newState; } finally { toggleInput.disabled = false; } }); toggleLabel.appendChild(toggleInput); toggleLabel.appendChild(sliderSpan); actions.appendChild(toggleLabel); const renameBtn = document.createElement('button'); renameBtn.textContent = '✎'; renameBtn.className = 'btn-icon'; renameBtn.title = 'Rename group'; renameBtn.style.cssText = 'padding: 4px 8px; font-size: 1rem; background: none; border: none; cursor: pointer; color: var(--text-secondary); transform: scaleX(-1);'; renameBtn.onclick = (e) => { e.stopPropagation(); showEditGroupModal(groupName); }; actions.appendChild(renameBtn); header.appendChild(actions); // Toggle collapse/expand and edit schedule on header click header.onclick = async (e) => { // Don't trigger if clicking on action buttons if (e.target.closest('.group-actions')) return; // Prevent event bubbling e.stopPropagation(); e.preventDefault(); // Block if there's a save in progress if (isSaveInProgress) { return; } // Global lock: only process one group click at a time if (isProcessingGroupClick) { return; } // Debounce: prevent multiple rapid clicks on the same group const now = Date.now(); const lastClick = lastClickTime[groupName] || 0; if (now - lastClick < CLICK_DEBOUNCE_MS) { return; } lastClickTime[groupName] = now; // Set processing flag isProcessingGroupClick = true; try { // Check if we're currently editing this group const isCurrentlyExpanded = currentGroup === groupName && container.classList.contains('expanded'); if (isCurrentlyExpanded) { // Collapse the editor collapseAllEditors(); currentGroup = null; } else { // Expand the editor await editGroupSchedule(groupName); } // Also toggle the entities list visibility container.classList.toggle('collapsed'); toggleIcon.style.transform = container.classList.contains('collapsed') ? 'rotate(-90deg)' : 'rotate(0deg)'; } finally { // Release the lock isProcessingGroupClick = false; } }; container.appendChild(header); return container; } // Load keyframe-timeline.js dynamically async function loadKeyframeTimeline() { if (keyframeTimelineLoaded) return true; try { // Get the base path from where scripts are loaded (same as graph.js, ha-api.js) // We can infer this from the current location or use a known path const basePath = '/climate_scheduler/static'; // Try to get version from graph.js or ha-api.js script tags let version = null; const scripts = document.querySelectorAll('script[src*="graph.js"], script[src*="ha-api.js"]'); for (const s of scripts) { const url = new URL(s.src, window.location.href); version = url.searchParams.get('v'); if (version) break; } const script = document.createElement('script'); script.type = 'module'; script.src = `${basePath}/keyframe-timeline.js${version ? `?v=${version}` : ''}`; await new Promise((resolve, reject) => { script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); keyframeTimelineLoaded = true; return true; } catch (error) { console.error('Failed to load keyframe-timeline.js:', error); showToast('Failed to load canvas graph component', 'error'); return false; } } // Convert schedule nodes {time: "HH:MM", temp: number} to keyframes {time: decimal_hours, value: number} function scheduleNodesToKeyframes(nodes) { const keyframes = nodes .filter(node => node.temp !== null && node.temp !== undefined) .map(node => { const [hours, minutes] = node.time.split(':').map(Number); const decimalHours = hours + (minutes / 60); return { time: decimalHours, value: normalizeTemperature(node.temp), noChange: Boolean(node.noChange), hvac_mode: node.hvac_mode ?? null, fan_mode: node.fan_mode ?? null, swing_mode: node.swing_mode ?? null, swing_horizontal_mode: node.swing_horizontal_mode ?? null, preset_mode: node.preset_mode ?? null, target_temp_low: Number.isFinite(node.target_temp_low) ? node.target_temp_low : null, target_temp_high: Number.isFinite(node.target_temp_high) ? node.target_temp_high : null, target_humidity: Number.isFinite(node.target_humidity) ? node.target_humidity : null, aux_heat: node.aux_heat ?? null }; }) .sort((a, b) => a.time - b.time); // Sort by time return keyframes; } function applyOptionalStringField(target, key, value) { if (typeof value === 'string' && value.trim() !== '') { target[key] = value; return; } delete target[key]; } function applyOptionalNumberField(target, key, value) { if (Number.isFinite(value)) { target[key] = value; return; } delete target[key]; } // Convert keyframes {time: decimal_hours, value: number} to schedule nodes {time: "HH:MM", temp: number} function keyframesToScheduleNodes(keyframes) { // Sort keyframes by time to maintain correct order const sortedKeyframes = [...keyframes].sort((a, b) => a.time - b.time); return sortedKeyframes.map((kf) => { // Clamp time to 23:59 (23.9833 hours) to prevent 24:00 which would clash with 00:00 on next day const clampedTime = Math.min(kf.time, 23 + (59/60)); const hours = Math.floor(clampedTime); const minutes = Math.round((clampedTime - hours) * 60); const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; // Find existing node in currentSchedule to preserve properties const existingNode = currentSchedule.find(n => n.time === timeStr); const scheduleNode = existingNode ? { ...existingNode, time: timeStr, temp: normalizeTemperature(kf.value, graphSnapStep) } : { time: timeStr, temp: normalizeTemperature(kf.value, graphSnapStep) }; // Mirror keyframe metadata back to schedule node so undo restores non-temp fields. applyOptionalStringField(scheduleNode, 'hvac_mode', kf.hvac_mode); applyOptionalStringField(scheduleNode, 'fan_mode', kf.fan_mode); applyOptionalStringField(scheduleNode, 'swing_mode', kf.swing_mode); applyOptionalStringField(scheduleNode, 'swing_horizontal_mode', kf.swing_horizontal_mode); applyOptionalStringField(scheduleNode, 'preset_mode', kf.preset_mode); applyOptionalStringField(scheduleNode, 'aux_heat', kf.aux_heat); applyOptionalNumberField(scheduleNode, 'target_temp_low', kf.target_temp_low); applyOptionalNumberField(scheduleNode, 'target_temp_high', kf.target_temp_high); applyOptionalNumberField(scheduleNode, 'target_humidity', kf.target_humidity); if (kf.noChange === true) { scheduleNode.noChange = true; } else { delete scheduleNode.noChange; } return scheduleNode; }); } // Get nodes from graph (canvas timeline) function getGraphNodes() { if (!graph) return []; // Return currentSchedule which has all properties (hvac_mode, fan_mode, A/B/C, etc.) // Don't convert from keyframes as that loses all non-temp properties return currentSchedule || []; } // Set nodes on graph (canvas timeline) function setGraphNodes(nodes) { if (!graph) return; // Canvas graph - convert nodes to keyframes const keyframes = scheduleNodesToKeyframes(nodes); // Force reactivity by creating new array reference graph.keyframes = [...keyframes]; } // Set history data on graph (canvas timeline) function setGraphHistoryData(historyDataArray) { if (!graph) return; // Canvas graph - convert to backgroundGraphs format if (!historyDataArray || historyDataArray.length === 0) { graph.backgroundGraphs = []; return; } // Detect format: old format [{time, temp}] or new format [{entityId, data, color}] const isNewFormat = historyDataArray[0] && historyDataArray[0].entityId !== undefined; if (isNewFormat) { // New format: array of {entityId, entityName, data, color} const backgroundGraphs = historyDataArray.map((entity) => { const keyframes = entity.data .map(node => ({ time: timeStringToDecimalHours(node.time), value: node.temp })) .sort((a, b) => a.time - b.time); // Sort by time return { keyframes: keyframes, color: entity.color ? entity.color + '80' : undefined, // Add transparency label: entity.entityName || entity.entityId }; }); // Force reactivity by creating new array reference graph.backgroundGraphs = [...backgroundGraphs]; } else { // Old format: single array of {time, temp} const keyframes = historyDataArray .map(node => ({ time: timeStringToDecimalHours(node.time), value: node.temp })) .sort((a, b) => a.time - b.time); // Sort by time graph.backgroundGraphs = [{ keyframes: keyframes, color: '#2196f380', label: 'Temperature' }]; } } // Helper function to convert time string "HH:MM" to decimal hours function timeStringToDecimalHours(timeStr) { const [hours, minutes] = timeStr.split(':').map(Number); return hours + minutes / 60; } function getScheduleNodesForDay(schedules, dayKey) { let nodes = schedules?.[dayKey] || []; if ((!nodes || nodes.length === 0) && dayKey === 'weekday' && schedules?.mon) { nodes = schedules.mon; } else if ((!nodes || nodes.length === 0) && dayKey === 'weekend' && schedules?.sat) { nodes = schedules.sat; } return nodes || []; } function cloneScheduleNodes(nodes) { if (!Array.isArray(nodes)) return []; return nodes.map(node => ({ ...node })); } // Mode-switch seeding: prefer the destination mode/day schedule first, // then fall back to current timeline or other populated periods. function getModeTransitionSeedNodes({ schedules, previousMode, previousDay, newMode, newDay, currentNodes }) { const current = cloneScheduleNodes(currentNodes); const scheduleMap = (schedules && typeof schedules === 'object') ? schedules : {}; const targetKeys = []; if (newMode === 'all_days') { targetKeys.push('all_days'); } else if (newMode === '5/2') { if (newDay === 'weekday' || newDay === 'weekend') { targetKeys.push(newDay); } targetKeys.push('weekday', 'weekend'); } else { if (newDay) { targetKeys.push(newDay); } targetKeys.push('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'); } const seenTargets = new Set(); for (const key of targetKeys) { if (!key || seenTargets.has(key)) continue; seenTargets.add(key); const nodes = scheduleMap[key]; if (Array.isArray(nodes) && nodes.length > 0) { return cloneScheduleNodes(nodes); } } if (current.length > 0) { return current; } const preferredKeys = []; if (previousDay) { preferredKeys.push(previousDay); } if (previousMode === 'individual') { preferredKeys.push('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'); } else if (previousMode === '5/2') { preferredKeys.push('weekday', 'weekend'); } else { preferredKeys.push('all_days'); } if (newMode === 'all_days') { preferredKeys.push('all_days'); } else if (newMode === '5/2') { preferredKeys.push('weekday', 'weekend'); } else { preferredKeys.push('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'); } const seen = new Set(); for (const key of preferredKeys) { if (!key || seen.has(key)) continue; seen.add(key); const nodes = scheduleMap[key]; if (Array.isArray(nodes) && nodes.length > 0) { return cloneScheduleNodes(nodes); } } for (const nodes of Object.values(scheduleMap)) { if (Array.isArray(nodes) && nodes.length > 0) { return cloneScheduleNodes(nodes); } } return []; } function getMainEditorDisplayedDay(scheduleMode) { if (scheduleMode === 'all_days') { return 'all_days'; } const activeDayButton = getDocumentRoot().querySelector('#main-day-period-buttons .day-period-btn.active'); const dayFromButton = activeDayButton?.dataset?.day; if (dayFromButton) { return dayFromButton; } const now = new Date(); const weekday = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][now.getDay()]; if (scheduleMode === '5/2') { return (weekday === 'sat' || weekday === 'sun') ? 'weekend' : 'weekday'; } return weekday; } function resolveModeTransitionDay(previousMode, previousDay, newMode) { if (newMode === 'all_days') { return 'all_days'; } const now = new Date(); const todayIndividual = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][now.getDay()]; const todayPeriod = (todayIndividual === 'sat' || todayIndividual === 'sun') ? 'weekend' : 'weekday'; const individualDays = new Set(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); if (newMode === '5/2') { if (previousMode === '5/2' && (previousDay === 'weekday' || previousDay === 'weekend')) { return previousDay; } if (previousMode === 'individual') { if (previousDay === 'sat' || previousDay === 'sun') { return 'weekend'; } if (individualDays.has(previousDay)) { return 'weekday'; } } return todayPeriod; } if (previousMode === 'individual' && individualDays.has(previousDay)) { return previousDay; } if (previousMode === '5/2') { if (previousDay === 'weekend') { return 'sat'; } if (previousDay === 'weekday') { return 'mon'; } } return todayIndividual; } function syncMainTimelineForActiveProfileEdit(targetGroup, editedProfileName) { if (!targetGroup || currentGroup !== targetGroup) { return; } const groupData = allGroups[targetGroup]; if (!groupData) { return; } const activeProfile = groupData.active_profile || 'Default'; if (activeProfile !== editedProfileName) { return; } const mainTimeline = getDocumentRoot().querySelector('#main-timeline'); if (!mainTimeline || !mainTimeline.isConnected) { return; } const scheduleMode = groupData.schedule_mode || 'all_days'; const displayedDay = getMainEditorDisplayedDay(scheduleMode); const nodes = getScheduleNodesForDay(groupData.schedules || {}, displayedDay).map(node => ({ ...node })); mainTimeline.keyframes = scheduleNodesToKeyframes(nodes); updateScheduledTemp(); } function setMainEditingContext(editorRoot = null) { const wasEditingProfile = editingProfile !== null; editingProfile = null; const activeProfile = (currentGroup && allGroups[currentGroup]) ? (allGroups[currentGroup].active_profile || 'Default') : 'Default'; showEditingProfileIndicator(null, activeProfile); const mainTimeline = editorRoot?.querySelector?.('#main-timeline') || getDocumentRoot().querySelector('#main-timeline'); if (mainTimeline) { graph = mainTimeline; } // Regression guard: // Do NOT reload currentSchedule from group cache during normal node selection. // That can overwrite in-memory edits before the debounced save runs, causing // "new nodes save, existing-node edits do not" behavior. if (wasEditingProfile && currentGroup && allGroups[currentGroup] && allGroups[currentGroup].schedules) { const schedules = allGroups[currentGroup].schedules; const nodesForDay = getScheduleNodesForDay(schedules, currentDay); currentSchedule = nodesForDay.map(n => ({ ...n })); } } function placeNodeSettingsPanelAfter(anchorElement) { if (!anchorElement || !anchorElement.parentNode) return; const panel = getDocumentRoot().querySelector('#node-settings-panel'); if (!panel) return; if (panel.parentNode === anchorElement.parentNode && panel.previousElementSibling === anchorElement) { return; } anchorElement.after(panel); } function getAddedKeyframeIndex(timeline, event) { const keyframes = timeline?.keyframes || []; if (keyframes.length === 0) return -1; const addedTime = Number(event?.detail?.time); const addedValue = Number(event?.detail?.value); if (Number.isFinite(addedTime)) { const timeTolerance = 0.0001; const valueTolerance = 0.0001; const byTimeAndValue = keyframes.findIndex(kf => Math.abs(kf.time - addedTime) < timeTolerance && (!Number.isFinite(addedValue) || Math.abs((kf.value || 0) - addedValue) < valueTolerance) ); if (byTimeAndValue !== -1) return byTimeAndValue; const byTime = keyframes.findIndex(kf => Math.abs(kf.time - addedTime) < timeTolerance); if (byTime !== -1) return byTime; } return keyframes.length - 1; } function refreshOpenNodeSettingsForAddedKeyframe({ editor, timeline, event, beforeShow, anchorElement }) { if (!editor || !timeline) return; const panel = editor.querySelector('#node-settings-panel'); if (!panel || panel.style.display === 'none') return; if (nodeSettingsTimeline && nodeSettingsTimeline !== timeline) return; const keyframeIndex = getAddedKeyframeIndex(timeline, event); if (keyframeIndex < 0) return; const keyframe = timeline.keyframes?.[keyframeIndex]; if (!keyframe) return; if (typeof beforeShow === 'function') { beforeShow(); } if (anchorElement) { placeNodeSettingsPanelAfter(anchorElement); } showNodeSettingsPanel(editor, keyframeIndex, keyframe, timeline); } function refreshOpenNodeSettingsAfterUndo({ editor, timeline, beforeShow, anchorElement }) { if (!editor || !timeline) return; const panel = editor.querySelector('#node-settings-panel'); if (!panel || panel.style.display === 'none') return; if (nodeSettingsTimeline && nodeSettingsTimeline !== timeline) return; const keyframes = timeline.keyframes || []; if (keyframes.length === 0) { panel.style.display = 'none'; return; } const panelIndex = Number.parseInt(panel.dataset.nodeIndex, 10); const keyframeIndex = Number.isFinite(panelIndex) ? Math.max(0, Math.min(panelIndex, keyframes.length - 1)) : 0; const keyframe = keyframes[keyframeIndex]; if (!keyframe) return; if (typeof beforeShow === 'function') { beforeShow(); } if (anchorElement) { placeNodeSettingsPanelAfter(anchorElement); } debugLog(`[Dialog][Undo] refreshing panel after restore index=${keyframeIndex}`); showNodeSettingsPanel(editor, keyframeIndex, keyframe, timeline); } function attachUndoStackDebugLogging(timeline, label) { if (!timeline || timeline.__undoDebugLoggingAttached) return; timeline.addEventListener('undo-stack-changed', (event) => { const detail = event.detail || {}; const action = detail.action || 'unknown'; const size = Number.isFinite(detail.size) ? detail.size : 'n/a'; const removedCount = Number.isFinite(detail.removedCount) ? detail.removedCount : 0; const keyframeCount = Number.isFinite(detail.keyframeCount) ? detail.keyframeCount : 'n/a'; debugLog(`[Undo][${label}] action=${action} size=${size} removed=${removedCount} keyframes=${keyframeCount}`); }); timeline.__undoDebugLoggingAttached = true; } // Show node settings panel for a clicked keyframe function showNodeSettingsPanel(editor, keyframeIndex, keyframe, sourceTimeline = graph) { nodeSettingsTimeline = sourceTimeline || nodeSettingsTimeline; if (!keyframe || typeof keyframe.time !== 'number') { console.warn('showNodeSettingsPanel called without a valid keyframe', { keyframeIndex, keyframe }); return; } // Convert keyframe time (decimal hours) to HH:MM format const hours = Math.floor(keyframe.time); const minutes = Math.round((keyframe.time - hours) * 60); const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; // Convert keyframe to node format const node = { time: timeStr, // Use formatted time string temp: keyframe.value, noChange: false // Keyframes always have a value }; // Find corresponding node in currentSchedule to get additional properties const scheduleNode = currentSchedule.find(n => n.time === timeStr || ( Math.abs(timeStringToDecimalHours(n.time) - keyframe.time) < 0.01 && Math.abs((n.temp || 0) - keyframe.value) < 0.1 ) ); if (scheduleNode) { // Copy all properties from schedule node Object.assign(node, scheduleNode); } // Get the panel from the editor (not globally) const panel = editor.querySelector('#node-settings-panel'); if (!panel) { console.error('Node settings panel not found in editor'); return; } // Create event detail and call handleNodeSettings const event = { detail: { nodeIndex: keyframeIndex, node: node, timeline: sourceTimeline } }; handleNodeSettings(event); } // Edit group schedule - load group schedule into editor async function editGroupSchedule(groupName, day = null) { // Fetch fresh group data from server before editing try { const groupsData = await haAPI.getGroups(); if (groupsData && groupsData[groupName]) { allGroups[groupName] = groupsData[groupName]; } } catch (error) { console.error('Failed to fetch fresh group data:', error); } const groupData = allGroups[groupName]; if (!groupData) return; // Set loading flag to prevent auto-saves during editor setup isLoadingSchedule = true; // // Collapse all other editors first collapseAllEditors(); // Set current group currentGroup = groupName; // Load schedule mode and day currentScheduleMode = groupData.schedule_mode || 'all_days'; if (!day) { // Determine which day to load based on mode const now = new Date(); const weekday = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][now.getDay()]; if (currentScheduleMode === 'all_days') { currentDay = 'all_days'; } else if (currentScheduleMode === '5/2') { currentDay = (weekday === 'sat' || weekday === 'sun') ? 'weekend' : 'weekday'; } else { currentDay = weekday; } } else { currentDay = day; } // Find the group container const groupContainer = getDocumentRoot().querySelector(`.group-container[data-group-name="${groupName}"]`); if (!groupContainer) { isLoadingSchedule = false; flushPendingSaveIfNeeded(); return; } // Mark as expanded groupContainer.classList.add('expanded'); // Create and insert editor inside the group container (append to the end) const editor = createScheduleEditor(); groupContainer.appendChild(editor); // Hide entity status section (replaced by group table) const entityStatus = editor.querySelector('.entity-status'); if (entityStatus) { entityStatus.style.display = 'none'; } // Use keyframe-timeline component const canvasLoaded = await loadKeyframeTimeline(); if (!canvasLoaded) { console.error('Failed to load keyframe-timeline component'); isLoadingSchedule = false; flushPendingSaveIfNeeded(); return; } // Create timeline editor using reusable function const graphContainer = editor.querySelector('.graph-container'); if (graphContainer) { const editorInstance = createTimelineEditor({ idPrefix: 'main', buttons: [ { id: 'graph-prev-btn', text: '◀', title: 'Previous keyframe' }, { id: 'graph-next-btn', text: '▶', title: 'Next keyframe' }, { id: 'graph-copy-btn', text: 'Copy', title: 'Copy schedule' }, { id: 'graph-paste-btn', text: 'Paste', title: 'Paste schedule', disabled: true }, { id: 'graph-undo-btn', text: 'Undo', title: 'Undo last change' }, { id: 'advance-schedule-btn', text: 'Advance', title: 'Advance to next scheduled node' }, { id: 'save-schedule-btn', text: 'Save', title: 'Save schedule', className: 'btn-quick-action btn-primary' } ], minValue: minTempSetting !== null ? minTempSetting : (temperatureUnit === '°F' ? 41 : 5), maxValue: maxTempSetting !== null ? maxTempSetting : (temperatureUnit === '°F' ? 86 : 30), snapValue: graphSnapStep, title: getScheduleTitle(), yAxisLabel: `Temperature (${temperatureUnit})`, xAxisLabel: `Time of Day (24hr)`, showCurrentTime: true, tooltipMode: tooltipMode, showHeader: false, allowCollapse: false }); const { container: timelineEditorContainer, timeline, controls } = editorInstance; // Replace the old editor-header with the new one from timelineEditorContainer const oldEditorHeader = editor.querySelector('.editor-header-inline'); const newEditorHeader = timelineEditorContainer.querySelector('.editor-header-inline'); if (oldEditorHeader && newEditorHeader) { oldEditorHeader.replaceWith(newEditorHeader); } // Insert timeline into graph-container graphContainer.innerHTML = ''; graphContainer.appendChild(timeline); // Store reference graph = timeline; attachUndoStackDebugLogging(timeline, 'main'); // Attach event listeners for keyframe changes timeline.addEventListener('keyframe-moved', handleKeyframeTimelineChange); timeline.addEventListener('keyframe-added', handleKeyframeTimelineChange); timeline.addEventListener('keyframe-deleted', handleKeyframeTimelineChange); timeline.addEventListener('keyframes-cleared', handleKeyframeTimelineChange); timeline.addEventListener('keyframe-restored', handleKeyframeTimelineChange); timeline.addEventListener('keyframe-added', (e) => { refreshOpenNodeSettingsForAddedKeyframe({ editor, timeline, event: e, beforeShow: () => setMainEditingContext(editor), anchorElement: editor.querySelector('.graph-container') }); }); timeline.addEventListener('keyframe-restored', () => { refreshOpenNodeSettingsAfterUndo({ editor, timeline, beforeShow: () => setMainEditingContext(editor), anchorElement: editor.querySelector('.graph-container') }); }); // Show node settings panel when keyframe is clicked timeline.addEventListener('keyframe-clicked', (e) => { graph = timeline; setMainEditingContext(editor); placeNodeSettingsPanelAfter(editor.querySelector('.graph-container')); const { index, keyframe } = e.detail; showNodeSettingsPanel(editor, index, keyframe, timeline); }); // Update node settings panel when selection changes (prev/next navigation) timeline.addEventListener('keyframe-selected', (e) => { graph = timeline; setMainEditingContext(editor); placeNodeSettingsPanelAfter(editor.querySelector('.graph-container')); const index = e.detail?.index ?? e.detail?.nodeIndex; const keyframe = e.detail?.keyframe ?? (Number.isInteger(index) ? graph?.keyframes?.[index] : undefined); showNodeSettingsPanel(editor, index, keyframe, timeline); }); // Link external undo button to timeline's undo system const graphUndoBtn = controls.buttons['graph-undo-btn']; if (graphUndoBtn && typeof timeline.setUndoButton === 'function') { timeline.setUndoButton(graphUndoBtn); } // Link previous/next buttons to timeline navigation const graphPrevBtn = controls.buttons['graph-prev-btn']; if (graphPrevBtn && typeof timeline.setPreviousButton === 'function') { timeline.setPreviousButton(graphPrevBtn); } const graphNextBtn = controls.buttons['graph-next-btn']; if (graphNextBtn && typeof timeline.setNextButton === 'function') { timeline.setNextButton(graphNextBtn); } // Link other control buttons if they exist const graphCopyBtn = controls.buttons['graph-copy-btn']; const graphPasteBtn = controls.buttons['graph-paste-btn']; const advanceBtn = controls.buttons['advance-schedule-btn']; const saveBtn = controls.buttons['save-schedule-btn']; // Attach copy/paste handlers if (graphCopyBtn) { graphCopyBtn.onclick = () => copySchedule(); } if (graphPasteBtn) { graphPasteBtn.onclick = () => pasteSchedule(); } // Note: Advance and Save buttons have custom handlers in attachEditorEventListeners } // Get graph element (canvas timeline) const graphElement = editor.querySelector('#main-timeline'); if (graphElement) { // Create and insert settings panel below timeline controls and node settings panel const graphContainer = graphElement.closest('.graph-container') || graphElement.parentElement; const nodeSettingsPanel = editor.querySelector('#node-settings-panel'); const insertionAnchor = nodeSettingsPanel || graphContainer; if (insertionAnchor) { let insertAfter = insertionAnchor; const settingsPanel = createSettingsPanel(groupData, editor); if (settingsPanel) { insertAfter.after(settingsPanel); insertAfter = settingsPanel; } // Check if any entities in the group are preset-only and show notice if needed const presetOnlyNotice = createPresetOnlyNotice(groupData.entities, groupName); if (presetOnlyNotice) { insertAfter.after(presetOnlyNotice); insertAfter = presetOnlyNotice; } // Insert group members table beneath settings/notice block const groupTable = createGroupMembersTable(groupData.entities); if (groupTable) { insertAfter.after(groupTable); } } } // Load nodes for the selected day let nodes = []; if (groupData.schedules && groupData.schedules[currentDay]) { nodes = groupData.schedules[currentDay]; // } else if (currentDay === "weekday" && groupData.schedules && groupData.schedules["mon"]) { // If weekday key doesn't exist, try loading from Monday nodes = groupData.schedules["mon"]; // } else if (currentDay === "weekend" && groupData.schedules && groupData.schedules["sat"]) { // If weekend key doesn't exist, try loading from Saturday nodes = groupData.schedules["sat"]; // } else if (groupData.nodes) { // Backward compatibility nodes = groupData.nodes; // } else { // } // Migrate old nodes that have temp=null to use noChange property nodes = nodes.map(node => { if ((node.temp === null || node.temp === undefined) && !node.hasOwnProperty('noChange')) { return { ...node, noChange: true, temp: 20 }; // Default position at 20°C } return node; }); currentSchedule = nodes.length > 0 ? nodes.map(n => ({...n})) : []; // Canvas graph - set keyframes const keyframes = scheduleNodesToKeyframes(currentSchedule); // Force reactivity by creating new array reference graph.keyframes = [...keyframes]; // Set previous day's last temperature const prevTemp = getPreviousDayLastTemp(groupData, currentDay); graph.previousDayEndValue = prevTemp; // Update schedule mode UI updateScheduleModeUI(); // Load history data for all entities in the group await loadGroupHistoryData(groupData.entities); // Load advance history for the first entity in the group if (groupData.entities && groupData.entities.length > 0) { await loadAdvanceHistory(groupData.entities[0]); } // Set enabled state from saved group data const scheduleEnabled = editor.querySelector('#schedule-enabled'); if (scheduleEnabled) { scheduleEnabled.checked = groupData.enabled !== false; } updateScheduledTemp(); // Reattach event listeners attachEditorEventListeners(editor); // Update paste button state updatePasteButtonState(); // Clear loading flag now that setup is complete // isLoadingSchedule = false; flushPendingSaveIfNeeded(); } // Create settings panel with controls and mode selector function createSettingsPanel(groupData, editor) { const container = document.createElement('div'); container.className = 'schedule-settings-container'; // Create toggle header const toggleHeader = document.createElement('div'); toggleHeader.className = 'schedule-settings-toggle'; toggleHeader.innerHTML = ` Schedule Settings `; toggleHeader.style.cursor = 'pointer'; toggleHeader.style.userSelect = 'none'; // Create settings panel content const settingsPanel = document.createElement('div'); settingsPanel.className = 'schedule-settings-panel collapsed'; settingsPanel.style.display = 'none'; // Check if this is a single-entity group (for ignore button visibility) const isSingleEntityGroup = groupData && groupData.entities && groupData.entities.length === 1; // Add editor controls (buttons) let controlsHTML = `
`; // Only show unmonitor button for single-entity groups if (isSingleEntityGroup) { controlsHTML += ``; } controlsHTML += `
`; settingsPanel.innerHTML = controlsHTML; // Toggle functionality toggleHeader.onclick = () => { const isCollapsed = settingsPanel.classList.contains('collapsed'); if (isCollapsed) { settingsPanel.classList.remove('collapsed'); settingsPanel.style.display = 'block'; toggleHeader.querySelector('.toggle-icon').style.transform = 'rotate(0deg)'; } else { settingsPanel.classList.add('collapsed'); settingsPanel.style.display = 'none'; toggleHeader.querySelector('.toggle-icon').style.transform = 'rotate(-90deg)'; } }; container.appendChild(toggleHeader); container.appendChild(settingsPanel); return container; } // Show/hide editing profile indicator function showEditingProfileIndicator(editingProfile, activeProfile) { let indicator = getDocumentRoot().querySelector('#editing-profile-indicator'); if (editingProfile && editingProfile !== activeProfile) { if (!indicator) { // Create indicator if it doesn't exist const graphContainer = getDocumentRoot().querySelector('.graph-container'); if (graphContainer) { indicator = document.createElement('div'); indicator.id = 'editing-profile-indicator'; indicator.style.cssText = 'font-weight: bold; padding: 8px; margin-bottom: 8px; text-align: center; color: var(--accent-color); border: 1px solid var(--divider-color); border-radius: 4px; display: flex; align-items: center; justify-content: center; gap: 12px;'; graphContainer.insertBefore(indicator, graphContainer.firstChild); } } if (indicator) { indicator.innerHTML = ` Editing Profile: ${editingProfile} `; indicator.style.display = 'flex'; // Add click handler to Close button const closeBtn = indicator.querySelector('#close-editing-profile'); if (closeBtn) { closeBtn.onclick = async () => { // Clear editing state editingProfile = null; // Load the active profile back into the graph if (currentGroup && allGroups[currentGroup]) { const groupData = allGroups[currentGroup]; const activeProfile = groupData.active_profile || 'Default'; const profileData = groupData.profiles && groupData.profiles[activeProfile]; if (profileData) { currentScheduleMode = profileData.schedule_mode || 'all_days'; const schedules = profileData.schedules || {}; const day = currentDay || 'all_days'; const nodes = schedules[day] || schedules['all_days'] || []; if (graph) { setGraphNodes(nodes); } // Update UI updateScheduleModeUI(); } } // Hide the indicator showEditingProfileIndicator(null, activeProfile); showToast('Returned to active profile', 'info'); }; } } } else { if (indicator) { indicator.style.display = 'none'; } } } function getProfileEditorTargetGroup(container) { if (currentGroup && allGroups[currentGroup]) { return currentGroup; } const firstMonitored = Object.keys(allGroups).find(groupName => { const groupData = allGroups[groupName] || {}; const isIgnored = groupData.ignored === true; return !isIgnored; }); return firstMonitored || null; } async function refreshGlobalProfileEditor() { const container = getDocumentRoot().querySelector('#global-profile-list'); if (!container) return; await setupProfileHandlers(container, null); } // Setup profile management event handlers async function setupProfileHandlers(container, groupData) { const targetGroup = getProfileEditorTargetGroup(container); if (!targetGroup || !allGroups[targetGroup]) { console.warn('setupProfileHandlers - no target group available, aborting'); return; } const targetGroupData = allGroups[targetGroup]; // Load and populate profiles await loadProfiles(container, targetGroup); // Profile dropdown change handler - automatically open editor when selection changes const profileDropdown = container.querySelector('#profile-dropdown'); if (profileDropdown) { profileDropdown.onchange = async () => { const selectedProfile = profileDropdown.value; // Don't load anything if no profile is selected (placeholder option) if (!selectedProfile) { return; } const activeProfile = targetGroupData.active_profile; try { // Load group data to get the latest profile schedules const groupsResult = await haAPI.getGroups(); allGroups = groupsResult?.groups || groupsResult?.response?.groups || groupsResult || {}; // Load the selected profile's schedule data const updatedGroupData = allGroups[targetGroup]; if (updatedGroupData && updatedGroupData.profiles && updatedGroupData.profiles[selectedProfile]) { const profileData = updatedGroupData.profiles[selectedProfile]; // Get schedule data (don't modify global state) let profileScheduleMode = profileData.schedule_mode || 'all_days'; const schedules = profileData.schedules || {}; const day = currentDay || 'all_days'; const nodes = schedules[day] || schedules['all_days'] || []; // Find the profile editor host container const profileEditorHost = container.querySelector('.profile-selector') || container; if (profileEditorHost) { // Remove any existing profile editor const existingEditor = profileEditorHost.querySelector('.timeline-editor-container'); if (existingEditor) { existingEditor.remove(); } // Get temperature settings from globals (same as main timeline) const temperatureUnit = updatedGroupData.temperature_unit || '°C'; // Create timeline editor using reusable function const editorInstance = createTimelineEditor({ idPrefix: 'profile', buttons: [ { id: 'graph-prev-btn', text: '◀', title: 'Previous keyframe' }, { id: 'graph-next-btn', text: '▶', title: 'Next keyframe' }, { id: 'graph-copy-btn', text: 'Copy', title: 'Copy schedule' }, { id: 'graph-paste-btn', text: 'Paste', title: 'Paste schedule', disabled: true }, { id: 'graph-undo-btn', text: 'Undo', title: 'Undo last change', disabled: true }, { id: 'graph-clear-btn', text: 'Clear', title: 'Clear all nodes' }, { id: 'save-schedule-btn', text: 'Save', title: 'Save schedule', className: 'btn-quick-action btn-primary' }, { id: 'close-btn', text: 'Close', title: 'Close profile editor' } ], minValue: minTempSetting !== null ? minTempSetting : (temperatureUnit === '°F' ? 41 : 5), maxValue: maxTempSetting !== null ? maxTempSetting : (temperatureUnit === '°F' ? 86 : 30), snapValue: graphSnapStep, title: `Editing Profile: ${selectedProfile}`, yAxisLabel: `Temperature (${temperatureUnit})`, xAxisLabel: `Time of Day (24hr)`, showCurrentTime: false, tooltipMode: tooltipMode || 'hover', showHeader: false, allowCollapse: false }); const { container: editorContainer, timeline, controls } = editorInstance; const { modeDropdown, dayPeriodSelector, dayPeriodButtons, buttons } = controls; const prevBtn = buttons['graph-prev-btn']; const nextBtn = buttons['graph-next-btn']; const copyBtn = buttons['graph-copy-btn']; const pasteBtn = buttons['graph-paste-btn']; const undoBtn = buttons['graph-undo-btn']; const clearBtn = buttons['graph-clear-btn']; const saveBtn = buttons['save-schedule-btn']; const closeBtn = buttons['close-btn']; // Attach copy/paste handlers for profile timeline if (copyBtn) { copyBtn.onclick = () => copySchedule(); } if (pasteBtn) { pasteBtn.onclick = () => pasteSchedule(); } // Link undo button to timeline's undo system if (undoBtn && typeof timeline.setUndoButton === 'function') { timeline.setUndoButton(undoBtn); } // Link previous/next buttons to timeline navigation if (prevBtn && typeof timeline.setPreviousButton === 'function') { timeline.setPreviousButton(prevBtn); } if (nextBtn && typeof timeline.setNextButton === 'function') { timeline.setNextButton(nextBtn); } // Link clear button to timeline's clear functionality if (clearBtn && typeof timeline.setClearButton === 'function') { timeline.setClearButton(clearBtn); } // Set initial mode modeDropdown.value = profileScheduleMode; dayPeriodSelector.style.display = profileScheduleMode === 'all_days' ? 'none' : 'block'; // Add to profile selector editorContainer.style.marginTop = '16px'; profileEditorHost.appendChild(editorContainer); // Track current day for profile editor let currentProfileDay = day; // Backing schedule state for the profile timeline (preserves node properties beyond temp/time) let profileCurrentSchedule = (nodes || []).map(n => ({ ...n })); // Keep global node-settings context aligned to profile timeline while editing profile const editorRoot = container.closest('.schedule-editor-inline') || getDocumentRoot(); const syncProfileScheduleFromTimeline = () => { const previousSchedule = (profileCurrentSchedule || []).map(n => ({ ...n })); currentSchedule = previousSchedule; profileCurrentSchedule = keyframesToScheduleNodes(timeline.keyframes || []); currentSchedule = profileCurrentSchedule; return profileCurrentSchedule; }; const setProfileEditingContext = () => { editingProfile = selectedProfile; showEditingProfileIndicator(editingProfile, activeProfile); graph = timeline; currentSchedule = (profileCurrentSchedule || []).map(n => ({ ...n })); currentScheduleMode = profileScheduleMode; currentDay = currentProfileDay; }; const loadProfileDayIntoTimeline = (dayKey) => { currentProfileDay = dayKey; const schedules = profileData.schedules || {}; const nodesForDay = schedules[dayKey] || []; profileCurrentSchedule = nodesForDay.map(n => ({ ...n })); timeline.keyframes = scheduleNodesToKeyframes(profileCurrentSchedule); setProfileEditingContext(); }; const persistProfileSchedule = async (scheduleModeOverride = profileScheduleMode) => { setProfileEditingContext(); const updatedNodes = syncProfileScheduleFromTimeline(); if (!profileData.schedules) { profileData.schedules = {}; } profileData.schedules[currentProfileDay] = updatedNodes.map(n => ({ ...n })); await haAPI.setGroupSchedule(targetGroup, updatedNodes, currentProfileDay, scheduleModeOverride, selectedProfile); const refreshedGroupData = await haAPI.getGroups(); allGroups = normalizeGroupsPayload(refreshedGroupData); syncMainTimelineForActiveProfileEdit(targetGroup, selectedProfile); return updatedNodes; }; // Function to update day/period buttons const updateDayPeriodButtons = (mode, activeDay) => { dayPeriodButtons.innerHTML = ''; dayPeriodSelector.style.display = mode === 'all_days' ? 'none' : 'block'; if (mode === 'individual') { const days = [ { value: 'mon', label: 'Mon' }, { value: 'tue', label: 'Tue' }, { value: 'wed', label: 'Wed' }, { value: 'thu', label: 'Thu' }, { value: 'fri', label: 'Fri' }, { value: 'sat', label: 'Sat' }, { value: 'sun', label: 'Sun' } ]; days.forEach(dayInfo => { const btn = document.createElement('button'); btn.className = 'day-period-btn'; btn.textContent = dayInfo.label; btn.dataset.day = dayInfo.value; if (activeDay === dayInfo.value) { btn.classList.add('active'); } btn.addEventListener('click', async () => { loadProfileDayIntoTimeline(dayInfo.value); dayPeriodButtons.querySelectorAll('.day-period-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); dayPeriodButtons.appendChild(btn); }); } else if (mode === '5/2') { const periods = [ { value: 'weekday', label: 'Weekday' }, { value: 'weekend', label: 'Weekend' } ]; periods.forEach(period => { const btn = document.createElement('button'); btn.className = 'day-period-btn'; btn.textContent = period.label; btn.dataset.day = period.value; if (activeDay === period.value) { btn.classList.add('active'); } btn.addEventListener('click', async () => { loadProfileDayIntoTimeline(period.value); dayPeriodButtons.querySelectorAll('.day-period-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); dayPeriodButtons.appendChild(btn); }); } }; // Initial day/period buttons updateDayPeriodButtons(profileScheduleMode, currentProfileDay); // Mode dropdown change handler modeDropdown.addEventListener('change', async (e) => { const newMode = e.target.value; const previousMode = profileScheduleMode; const previousDay = currentProfileDay; const previousNodes = syncProfileScheduleFromTimeline().map(n => ({ ...n })); if (!profileData.schedules) { profileData.schedules = {}; } profileData.schedules[previousDay] = previousNodes.map(n => ({ ...n })); const profileScheduleSnapshot = JSON.parse(JSON.stringify(profileData.schedules)); profileScheduleMode = newMode; // Determine destination day preserving current editing context when possible const newDay = resolveModeTransitionDay(previousMode, previousDay, newMode); const seedNodes = getModeTransitionSeedNodes({ schedules: profileScheduleSnapshot, previousMode, previousDay, newMode, newDay, currentNodes: previousNodes }); currentProfileDay = newDay; profileCurrentSchedule = seedNodes.map(n => ({ ...n })); profileData.schedules[currentProfileDay] = profileCurrentSchedule.map(n => ({ ...n })); timeline.keyframes = scheduleNodesToKeyframes(profileCurrentSchedule); setProfileEditingContext(); // Update day/period buttons updateDayPeriodButtons(newMode, newDay); // Save mode change to profile try { await persistProfileSchedule(newMode); } catch (error) { console.error('Failed to save mode change:', error); showToast('Failed to save mode change', 'error'); } }); // Update undo button state const updateUndoBtn = () => { undoBtn.disabled = !timeline.undoStack || timeline.undoStack.length === 0; }; timeline.addEventListener('keyframe-moved', updateUndoBtn); timeline.addEventListener('keyframe-added', updateUndoBtn); timeline.addEventListener('keyframe-deleted', updateUndoBtn); timeline.addEventListener('keyframe-restored', updateUndoBtn); timeline.addEventListener('keyframes-cleared', updateUndoBtn); // Show node settings for profile timeline keyframe selection/click timeline.addEventListener('keyframe-clicked', (e) => { const { index, keyframe } = e.detail; setProfileEditingContext(); placeNodeSettingsPanelAfter(editorContainer); showNodeSettingsPanel(editorRoot, index, keyframe, timeline); }); timeline.addEventListener('keyframe-selected', (e) => { const index = e.detail?.index ?? e.detail?.nodeIndex; const keyframe = e.detail?.keyframe ?? (Number.isInteger(index) ? graph?.keyframes?.[index] : undefined); setProfileEditingContext(); placeNodeSettingsPanelAfter(editorContainer); showNodeSettingsPanel(editorRoot, index, keyframe, timeline); }); // Undo button handler undoBtn.onclick = () => { setProfileEditingContext(); if (timeline && typeof timeline.undo === 'function') { timeline.undo(); } }; // Copy button handler copyBtn.onclick = () => { setProfileEditingContext(); copiedSchedule = [...timeline.keyframes]; pasteBtn.disabled = false; showToast('Schedule copied', 'success'); }; // Paste button handler pasteBtn.onclick = () => { setProfileEditingContext(); if (copiedSchedule && copiedSchedule.length > 0) { timeline.keyframes = [...copiedSchedule]; syncProfileScheduleFromTimeline(); showToast('Schedule pasted', 'success'); } }; // Clear button handler clearBtn.onclick = () => { setProfileEditingContext(); if (confirm('Clear all nodes for this profile?')) { timeline.keyframes = []; profileCurrentSchedule = []; currentSchedule = []; } }; // Save button handler (manual save) saveBtn.onclick = async () => { try { await persistProfileSchedule(); showToast('Profile saved successfully', 'success'); } catch (error) { console.error('Failed to save profile changes:', error); showToast('Failed to save profile changes', 'error'); } }; // Close button handler closeBtn.onclick = () => { editorContainer.remove(); // Reset dropdown to placeholder const profileDropdown = container.querySelector('#profile-dropdown'); if (profileDropdown) { profileDropdown.value = ''; } setMainEditingContext(editorRoot); placeNodeSettingsPanelAfter(editorRoot.querySelector('.graph-container')); }; // Set the keyframes AFTER appending to DOM timeline.keyframes = scheduleNodesToKeyframes(profileCurrentSchedule); setProfileEditingContext(); attachUndoStackDebugLogging(timeline, `profile:${selectedProfile}`); // Add event listeners to save changes directly to the selected profile const saveProfileChanges = async () => { try { await persistProfileSchedule(); } catch (error) { console.error('Failed to save profile changes:', error); showToast('Failed to save profile changes', 'error'); } }; timeline.addEventListener('keyframe-moved', saveProfileChanges); timeline.addEventListener('keyframe-added', saveProfileChanges); timeline.addEventListener('keyframe-deleted', saveProfileChanges); timeline.addEventListener('keyframes-cleared', saveProfileChanges); timeline.addEventListener('keyframe-restored', saveProfileChanges); timeline.addEventListener('keyframe-added', (e) => { refreshOpenNodeSettingsForAddedKeyframe({ editor: editorRoot, timeline, event: e, beforeShow: () => setProfileEditingContext(), anchorElement: editorContainer }); }); timeline.addEventListener('keyframe-restored', () => { refreshOpenNodeSettingsAfterUndo({ editor: editorRoot, timeline, beforeShow: () => setProfileEditingContext(), anchorElement: editorContainer }); }); } } else { showToast('Selected profile is unavailable for the target schedule', 'warning'); } } catch (error) { console.error('Failed to load profile:', error); showToast('Failed to load profile: ' + error.message, 'error'); } }; } // New profile button const newProfileBtn = container.querySelector('#new-profile-btn'); if (newProfileBtn) { newProfileBtn.onclick = async () => { const selectedTargetGroup = getProfileEditorTargetGroup(container); if (!selectedTargetGroup) { showToast('Select a monitored schedule first', 'warning'); return; } const profileName = prompt('Enter name for new profile:'); if (!profileName || profileName.trim() === '') return; try { await haAPI.createProfile(selectedTargetGroup, profileName.trim()); showToast(`Created profile: ${profileName}`, 'success'); // Reload group data from server to get updated profiles const groupsResult = await haAPI.getGroups(); allGroups = normalizeGroupsPayload(groupsResult); await loadProfiles(container, selectedTargetGroup); updateGraphProfileDropdown(); } catch (error) { console.error('Failed to create profile:', error); showToast('Failed to create profile: ' + error.message, 'error'); } }; } // Rename profile button const renameProfileBtn = container.querySelector('#rename-profile-btn'); if (renameProfileBtn) { renameProfileBtn.onclick = async () => { const selectedTargetGroup = getProfileEditorTargetGroup(container); if (!selectedTargetGroup) { showToast('Select a monitored schedule first', 'warning'); return; } const dropdown = container.querySelector('#profile-dropdown'); const currentProfile = dropdown?.value; if (!currentProfile) return; const newName = prompt(`Rename profile "${currentProfile}" to:`, currentProfile); if (!newName || newName.trim() === '' || newName === currentProfile) return; try { await haAPI.renameProfile(selectedTargetGroup, currentProfile, newName.trim()); showToast(`Renamed profile to: ${newName}`, 'success'); // Reload group data from server to get updated profiles const groupsResult = await haAPI.getGroups(); allGroups = normalizeGroupsPayload(groupsResult); await loadProfiles(container, selectedTargetGroup); updateGraphProfileDropdown(); } catch (error) { console.error('Failed to rename profile:', error); showToast('Failed to rename profile: ' + error.message, 'error'); } }; } // Delete profile button const deleteProfileBtn = container.querySelector('#delete-profile-btn'); if (deleteProfileBtn) { deleteProfileBtn.onclick = async () => { const selectedTargetGroup = getProfileEditorTargetGroup(container); if (!selectedTargetGroup) { showToast('Select a monitored schedule first', 'warning'); return; } const dropdown = container.querySelector('#profile-dropdown'); const currentProfile = dropdown?.value; if (!currentProfile) return; if (!confirm(`Delete profile "${currentProfile}"?`)) return; try { await haAPI.deleteProfile(selectedTargetGroup, currentProfile); showToast(`Deleted profile: ${currentProfile}`, 'success'); // Reload group data from server to get updated profiles const groupsResult = await haAPI.getGroups(); allGroups = normalizeGroupsPayload(groupsResult); await loadProfiles(container, selectedTargetGroup); updateGraphProfileDropdown(); } catch (error) { console.error('Failed to delete profile:', error); showToast('Failed to delete profile: ' + error.message, 'error'); } }; } } // Load and populate profiles dropdown async function loadProfiles(container, targetId) { try { const result = await haAPI.getProfiles(targetId); const profiles = result.profiles || {}; const activeProfile = result.active_profile || 'Default'; const dropdown = container.querySelector('#profile-dropdown'); if (!dropdown) { console.warn('loadProfiles - dropdown not found'); return; } // Clear and repopulate dropdown dropdown.innerHTML = ''; // Add placeholder option const placeholderOption = document.createElement('option'); placeholderOption.value = ''; placeholderOption.textContent = 'Choose Profile to Edit'; placeholderOption.disabled = true; placeholderOption.selected = true; dropdown.appendChild(placeholderOption); Object.keys(profiles).forEach(profileName => { const option = document.createElement('option'); option.value = profileName; option.textContent = profileName; dropdown.appendChild(option); }); // Update button states const renameBtn = container.querySelector('#rename-profile-btn'); const deleteBtn = container.querySelector('#delete-profile-btn'); const profileCount = Object.keys(profiles).length; if (renameBtn) renameBtn.disabled = profileCount === 0; if (deleteBtn) deleteBtn.disabled = profileCount <= 1; } catch (error) { console.error('Failed to load profiles:', error); } } // Create preset-only notice for groups with entities that don't support temperature function createPresetOnlyNotice(entityIds, groupName) { if (!entityIds || entityIds.length === 0) { return null; // Virtual groups don't need this notice } // Check if any entities are preset-only (null current_temperature) const presetOnlyEntities = entityIds.filter(entityId => { const entity = climateEntities.find(e => e.entity_id === entityId); return entity && entity.attributes && entity.attributes.current_temperature === null; }); if (presetOnlyEntities.length === 0) { return null; // No preset-only entities } // Check if this notice has been dismissed for this group (session storage) const dismissKey = `preset-notice-dismissed-${groupName}`; if (sessionStorage.getItem(dismissKey) === 'true') { return null; } // Create notice banner const notice = document.createElement('div'); notice.className = 'preset-only-notice'; notice.style.cssText = ` background: var(--warning-color, #ff9800); color: white; padding: 12px 16px; margin: 8px 0; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; font-size: 14px; line-height: 1.4; `; const message = document.createElement('div'); message.style.flex = '1'; const count = presetOnlyEntities.length; const entityWord = count === 1 ? 'entity' : 'entities'; const hasWord = count === 1 ? 'has' : 'have'; message.innerHTML = ` ⚠️ Preset-Only ${count === 1 ? 'Entity' : 'Entities'} Detected
${count} ${entityWord} in this group ${hasWord} no temperature sensor and will only receive mode changes (HVAC, fan, swing, preset). `; const dismissBtn = document.createElement('button'); dismissBtn.textContent = '✕'; dismissBtn.style.cssText = ` background: transparent; border: none; color: white; font-size: 20px; cursor: pointer; padding: 0 8px; margin-left: 16px; opacity: 0.8; transition: opacity 0.2s; `; dismissBtn.title = 'Dismiss this notice'; dismissBtn.addEventListener('mouseenter', () => { dismissBtn.style.opacity = '1'; }); dismissBtn.addEventListener('mouseleave', () => { dismissBtn.style.opacity = '0.8'; }); dismissBtn.addEventListener('click', () => { sessionStorage.setItem(dismissKey, 'true'); notice.style.transition = 'opacity 0.3s, max-height 0.3s'; notice.style.opacity = '0'; notice.style.maxHeight = '0'; notice.style.padding = '0 16px'; notice.style.margin = '0'; setTimeout(() => { notice.remove(); }, 300); }); notice.appendChild(message); notice.appendChild(dismissBtn); return notice; } // Create group members table element function createGroupMembersTable(entityIds) { if (!entityIds || entityIds.length === 0) { // Return a message for virtual groups (no entities) const container = document.createElement('div'); container.className = 'group-members-container'; container.innerHTML = `
Virtual Schedule (No Entities) - Events Only
`; return container; } // Create container wrapper const container = document.createElement('div'); container.className = 'group-members-container'; // Create toggle header const toggleHeader = document.createElement('div'); toggleHeader.className = 'group-members-toggle'; toggleHeader.innerHTML = ` Member Entities (${entityIds.length}) `; // Create table const table = document.createElement('div'); table.className = 'group-members-table collapsed'; table.style.display = 'none'; // Toggle functionality toggleHeader.onclick = async () => { const isCollapsed = table.classList.contains('collapsed'); if (isCollapsed) { // Fetch fresh entity states before expanding await loadClimateEntities(); // Refresh table data const now = getServerNow(serverTimeZone); const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; const groupSchedule = getGraphNodes(); // Update each row with fresh data entityIds.forEach(entityId => { const row = table.querySelector(`.group-members-row[data-entity-id="${entityId}"]`); if (!row) return; const entity = climateEntities.find(e => e.entity_id === entityId); if (!entity) return; // Update current temp const currentCell = row.children[1]; const currentTemp = entity.attributes?.current_temperature; currentCell.textContent = (Number.isFinite(currentTemp)) ? `${currentTemp.toFixed(1)}${temperatureUnit}` : '--'; // Update target temp const targetCell = row.children[2]; const targetTemp = entity.attributes?.temperature; targetCell.textContent = (Number.isFinite(targetTemp)) ? `${targetTemp.toFixed(1)}${temperatureUnit}` : '--'; // Update scheduled temp const scheduledCell = row.children[3]; if (groupSchedule.length > 0) { const scheduledTemp = interpolateTemperature(groupSchedule, currentTime); scheduledCell.textContent = (Number.isFinite(scheduledTemp)) ? `${scheduledTemp.toFixed(1)}${temperatureUnit}` : 'No Change'; } else { scheduledCell.textContent = '--'; } }); table.classList.remove('collapsed'); table.style.display = 'block'; toggleHeader.querySelector('.toggle-icon').style.transform = 'rotate(90deg)'; } else { table.classList.add('collapsed'); table.style.display = 'none'; toggleHeader.querySelector('.toggle-icon').style.transform = 'rotate(0deg)'; } }; // Create header const header = document.createElement('div'); header.className = 'group-members-header'; header.innerHTML = 'NameCurrentTargetScheduledActions'; table.appendChild(header); // Get current time for scheduled temp calculation const now = getServerNow(serverTimeZone); const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; // Get the group's schedule if it exists const groupSchedule = getGraphNodes(); // Create rows for each entity entityIds.forEach(entityId => { const entity = climateEntities.find(e => e.entity_id === entityId); if (!entity) return; const row = document.createElement('div'); row.className = 'group-members-row'; row.dataset.entityId = entityId; const nameCell = document.createElement('span'); nameCell.textContent = entity.attributes?.friendly_name || entityId; const currentCell = document.createElement('span'); const currentTemp = entity.attributes?.current_temperature; currentCell.textContent = (Number.isFinite(currentTemp)) ? `${currentTemp.toFixed(1)}${temperatureUnit}` : '--'; const targetCell = document.createElement('span'); const targetTemp = entity.attributes?.temperature; targetCell.textContent = (Number.isFinite(targetTemp)) ? `${targetTemp.toFixed(1)}${temperatureUnit}` : '--'; const scheduledCell = document.createElement('span'); if (groupSchedule.length > 0) { const scheduledTemp = interpolateTemperature(groupSchedule, currentTime); scheduledCell.textContent = (Number.isFinite(scheduledTemp)) ? `${scheduledTemp.toFixed(1)}${temperatureUnit}` : 'No Change'; } else { scheduledCell.textContent = '--'; } // Add action buttons cell const actionCell = document.createElement('span'); actionCell.style.textAlign = 'center'; actionCell.style.display = 'flex'; actionCell.style.gap = '4px'; actionCell.style.justifyContent = 'center'; const moveBtn = document.createElement('button'); moveBtn.className = 'btn-icon-small move-entity-btn'; moveBtn.innerHTML = '⇄'; moveBtn.title = 'Move to another group'; moveBtn.onclick = (e) => { e.stopPropagation(); const groupName = currentGroup; if (groupName) { showMoveToGroupModal(groupName, entityId); } }; const removeBtn = document.createElement('button'); removeBtn.className = 'btn-icon-small remove-entity-btn'; removeBtn.innerHTML = '🗑'; removeBtn.title = 'Remove from group'; removeBtn.onclick = (e) => { e.stopPropagation(); // Get the group name from the current context const groupName = currentGroup; if (groupName) { removeEntityFromGroup(groupName, entityId); } }; actionCell.appendChild(moveBtn); actionCell.appendChild(removeBtn); row.appendChild(nameCell); row.appendChild(currentCell); row.appendChild(targetCell); row.appendChild(scheduledCell); row.appendChild(actionCell); table.appendChild(row); }); container.appendChild(toggleHeader); container.appendChild(table); return container; } // Display group members table above graph (deprecated - use createGroupMembersTable instead) function displayGroupMembersTable(entityIds) { // Remove existing table if present const existingTable = getDocumentRoot().querySelector('.group-members-table'); if (existingTable) { existingTable.remove(); } const table = createGroupMembersTable(entityIds); if (!table) return; // Insert table before graph container const graphContainer = getDocumentRoot().querySelector('.graph-container'); if (graphContainer) { graphContainer.parentNode.insertBefore(table, graphContainer); } } // Toggle entity inclusion (enable/disable entity) async function toggleEntityInclusion(entityId, enable = true) { try { if (enable) { // Check if entity already has a schedule const existingSchedule = await haAPI.getSchedule(entityId); const schedule = existingSchedule?.response || existingSchedule; if (!schedule || !schedule.nodes || schedule.nodes.length === 0) { // Entity has no schedule, initialize with default const defaultSchedule = defaultScheduleSettings.map(node => ({...node})); await haAPI.setSchedule(entityId, defaultSchedule, 'all_days', 'all_days'); // Add to local Map entitySchedules.set(entityId, defaultSchedule); } else { // Entity has a schedule, un-ignore it and enable it await haAPI.setIgnored(entityId, false); await haAPI.enableSchedule(entityId); // Add to local Map entitySchedules.set(entityId, schedule.nodes); } showToast('Entity enabled', 'success'); } else { // Disable entity - mark as ignored instead of deleting try { await haAPI.setIgnored(entityId, true); // Remove from local Map only after successfully marked as ignored entitySchedules.delete(entityId); showToast('Entity ignored', 'success'); } catch (ignoreError) { throw ignoreError; // Re-throw to be caught by outer catch } } // Re-render entity list to update UI await renderEntityList(); // If enabling, optionally expand the editor for this entity if (enable) { selectEntity(entityId); } } catch (error) { showToast(`Failed to ${enable ? 'enable' : 'disable'} entity`, 'error'); // On error, reload schedules from backend to sync state await loadAllSchedules(); } } // Remove entity from group async function removeEntityFromGroup(groupName, entityId) { // Get entity name for confirmation const entity = climateEntities.find(e => e.entity_id === entityId); const entityName = entity?.attributes?.friendly_name || entityId; if (!confirm(`Remove "${entityName}" from group "${groupName}"?\n\nThe entity will return to your active entities list.`)) { return; } try { await haAPI.removeFromGroup(groupName, entityId); // Close any open editors to prevent stale data collapseAllEditors(); // Reload groups await loadGroups(); // Reload entity list (entity should reappear in active/disabled) await renderEntityList(); showToast(`Removed ${entityName} from ${groupName}`, 'success'); } catch (error) { console.error('Failed to remove entity from group:', error); showToast('Failed to remove entity from group: ' + error.message, 'error'); } } // Show move to group modal function showMoveToGroupModal(currentGroupName, entityId) { const modal = getDocumentRoot().querySelector('#add-to-group-modal'); const select = getDocumentRoot().querySelector('#add-to-group-select'); const newGroupInput = getDocumentRoot().querySelector('#new-group-name-inline'); if (!modal || !select) return; // Store entity ID and current group on modal modal.dataset.entityId = entityId; modal.dataset.currentGroup = currentGroupName; modal.dataset.isMove = 'true'; // Populate group select (excluding current group) select.innerHTML = ''; Object.keys(allGroups).forEach(groupName => { if (groupName === currentGroupName) { return; // Skip current group } const groupData = allGroups[groupName]; // Skip ignored/unmonitored groups const isIgnored = groupData.ignored === true; if (isIgnored) { return; } // For single-entity groups, use the entity's friendly name only if group name matches entity ID const isSingleEntity = groupData.entities && groupData.entities.length === 1; let displayName = groupName; if (isSingleEntity) { const targetEntityId = groupData.entities[0]; // Only show friendly name if group name matches entity ID (auto-created) if (groupName === targetEntityId) { const entity = climateEntities.find(e => e.entity_id === targetEntityId); if (entity && entity.attributes?.friendly_name) { displayName = entity.attributes.friendly_name; } else { // Fallback to entity_id without the domain prefix displayName = targetEntityId.split('.')[1]?.replace(/_/g, ' ') || targetEntityId; } } // Otherwise keep displayName as groupName (user-created group) } const option = document.createElement('option'); option.value = groupName; option.textContent = displayName; select.appendChild(option); }); // Clear new group input if (newGroupInput) { newGroupInput.value = ''; } modal.style.display = 'flex'; } // Show add to group modal function showAddToGroupModal(entityId) { const modal = getDocumentRoot().querySelector('#add-to-group-modal'); const select = getDocumentRoot().querySelector('#add-to-group-select'); const newGroupInput = getDocumentRoot().querySelector('#new-group-name-inline'); if (!modal || !select) return; // Store entity ID on modal modal.dataset.entityId = entityId; // Populate group select select.innerHTML = ''; Object.keys(allGroups).forEach(groupName => { const groupData = allGroups[groupName]; // Skip ignored/unmonitored groups const isIgnored = groupData.ignored === true; if (isIgnored) { return; // Skip this group } // Skip disabled groups const isEnabled = groupData.enabled !== false; if (!isEnabled) { return; // Skip this group } // For single-entity groups, use the entity's friendly name only if group name matches entity ID const isSingleEntity = groupData.entities && groupData.entities.length === 1; let displayName = groupName; if (isSingleEntity) { const entityId = groupData.entities[0]; // Only show friendly name if group name matches entity ID (auto-created) if (groupName === entityId) { const entity = climateEntities.find(e => e.entity_id === entityId); if (entity && entity.attributes?.friendly_name) { displayName = entity.attributes.friendly_name; } else { // Fallback to entity_id without the domain prefix displayName = entityId.split('.')[1]?.replace(/_/g, ' ') || entityId; } } // Otherwise keep displayName as groupName (user-created group) } const option = document.createElement('option'); option.value = groupName; option.textContent = displayName; select.appendChild(option); }); // Clear new group input if (newGroupInput) { newGroupInput.value = ''; } modal.style.display = 'flex'; } // Show edit group modal function showEditGroupModal(groupName) { const modal = getDocumentRoot().querySelector('#edit-group-modal'); const input = getDocumentRoot().querySelector('#edit-group-name'); if (!modal || !input) return; // Store group name on modal dataset modal.dataset.groupName = groupName; input.value = groupName; modal.style.display = 'flex'; // Focus the input and select the text setTimeout(() => { input.focus(); input.select(); }, 100); } // Confirm delete group function confirmDeleteGroup(groupName) { if (!confirm(`Delete group "${groupName}"? All entities will be moved back to the entity list.`)) return; deleteGroup(groupName); } // Delete group async function deleteGroup(groupName) { try { await haAPI.deleteGroup(groupName); // Reload groups await loadGroups(); // Reload entity list (entities should reappear) await renderEntityList(); } catch (error) { console.error('Failed to delete group:', error); alert('Failed to delete group'); } } // Toggle group enabled/disabled async function toggleGroupEnabled(groupName, enabled) { try { if (enabled) { await haAPI.enableGroup(groupName); } else { await haAPI.disableGroup(groupName); } // Update local state if (allGroups[groupName]) { allGroups[groupName].enabled = enabled; } // Group toggled } catch (error) { console.error(`Failed to ${enabled ? 'enable' : 'disable'} group:`, error); // Reload to sync state await loadGroups(); } } // Render entity list (deprecated - all entities are now in groups) async function renderEntityList() { // All entities should be in groups now (either multi-entity or single-entity groups) // This function is kept for backward compatibility but does nothing return; } /** * Creates a timeline editor instance with controls * @param {Object} config - Configuration object * @param {string} config.idPrefix - Prefix for element IDs (e.g., 'main', 'profile') * @param {Array} config.buttons - Array of button configs: [{id, text, title, className?, onClick?}] * @param {number} config.minValue - Min temperature value * @param {number} config.maxValue - Max temperature value * @param {number} config.snapValue - Snap step value * @param {string} config.title - Timeline title * @param {string} config.yAxisLabel - Y-axis label * @param {string} config.xAxisLabel - X-axis label * @param {boolean} config.showCurrentTime - Show current time indicator * @param {string} config.tooltipMode - Tooltip mode * @param {boolean} config.showHeader - Show timeline header * @param {boolean} config.allowCollapse - Allow timeline collapse * @returns {Object} - {container, timeline, controls: {modeDropdown, dayPeriodSelector, dayPeriodButtons, buttons: {}}} */ function createTimelineEditor(config) { const { idPrefix = 'timeline', buttons = [], minValue = 5, maxValue = 30, snapValue = 0.5, title = 'Schedule', yAxisLabel = 'Temperature (°C)', xAxisLabel = 'Time of Day (24hr)', showCurrentTime = true, tooltipMode = 'hover', showHeader = false, allowCollapse = false, showModeDropdown = true } = config; // Create main container const container = document.createElement('div'); container.className = 'timeline-editor-container'; // Create editor header with controls const editorHeader = document.createElement('div'); editorHeader.className = 'editor-header-inline'; const graphTopControls = document.createElement('div'); graphTopControls.className = 'graph-top-controls'; // Day/period selector const dayPeriodSelector = document.createElement('div'); dayPeriodSelector.className = 'day-period-selector'; dayPeriodSelector.id = `${idPrefix}-day-period-selector`; dayPeriodSelector.style.display = 'none'; const dayPeriodButtons = document.createElement('div'); dayPeriodButtons.className = 'day-period-buttons'; dayPeriodButtons.id = `${idPrefix}-day-period-buttons`; dayPeriodSelector.appendChild(dayPeriodButtons); // Quick actions container const graphQuickActions = document.createElement('div'); graphQuickActions.className = 'graph-quick-actions'; // Mode dropdown const modeDropdown = document.createElement('select'); modeDropdown.id = `${idPrefix}-schedule-mode-dropdown`; modeDropdown.className = 'mode-dropdown'; modeDropdown.title = 'Schedule mode'; modeDropdown.innerHTML = ` `; if (showModeDropdown) { graphQuickActions.appendChild(modeDropdown); } // Create action buttons const buttonElements = {}; buttons.forEach(btnConfig => { const btn = document.createElement('button'); btn.id = `${idPrefix}-${btnConfig.id}`; btn.className = btnConfig.className || 'btn-quick-action'; btn.textContent = btnConfig.text; btn.title = btnConfig.title || ''; if (btnConfig.disabled) btn.disabled = true; if (btnConfig.onClick) btn.onclick = btnConfig.onClick; buttonElements[btnConfig.id] = btn; graphQuickActions.appendChild(btn); }); graphTopControls.appendChild(dayPeriodSelector); graphTopControls.appendChild(graphQuickActions); editorHeader.appendChild(graphTopControls); container.appendChild(editorHeader); // Create timeline instance const timeline = document.createElement('keyframe-timeline'); timeline.id = `${idPrefix}-timeline`; timeline.style.width = '100%'; timeline.style.setProperty('--timeline-height', '260px'); timeline.duration = 24; timeline.slots = 96; timeline.minValue = minValue; timeline.maxValue = maxValue; timeline.snapValue = snapValue; timeline.title = title; timeline.yAxisLabel = yAxisLabel; timeline.xAxisLabel = xAxisLabel; timeline.showCurrentTime = showCurrentTime; timeline.tooltipMode = tooltipMode; timeline.showHeader = showHeader; timeline.allowCollapse = allowCollapse; container.appendChild(timeline); return { container, timeline, controls: { modeDropdown, dayPeriodSelector, dayPeriodButtons, buttons: buttonElements } }; } // Create the schedule editor element function createScheduleEditor() { const editor = document.createElement('div'); editor.className = 'schedule-editor-inline'; editor.innerHTML = `
Current Temp: --°C
Target Temp: --°C
Scheduled Temp: --°C
`; return editor; } let climateDialogLoaded = false; async function loadClimateDialog() { if (climateDialogLoaded) return; try { const basePath = '/climate_scheduler/static'; const version = window.climateSchedulerVersion || Date.now(); const script = document.createElement('script'); script.type = 'module'; script.src = `${basePath}/climate-dialog.js?v=${version}`; await new Promise((resolve, reject) => { script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); climateDialogLoaded = true; } catch (error) { console.error('Failed to load climate dialog:', error); } } // Collapse all editors function collapseAllEditors() { const allEditors = getDocumentRoot().querySelectorAll('.schedule-editor-inline'); allEditors.forEach(editor => editor.remove()); // Remove all close buttons const allCloseButtons = getDocumentRoot().querySelectorAll('.close-entity-btn'); allCloseButtons.forEach(btn => btn.remove()); const allCards = getDocumentRoot().querySelectorAll('.entity-card'); allCards.forEach(card => { card.classList.remove('selected', 'expanded'); // Reset add to group button text to "+" const addToGroupBtn = card.querySelector('.add-to-group-btn'); if (addToGroupBtn) { addToGroupBtn.textContent = '+'; addToGroupBtn.style.padding = '4px 8px'; } }); const allGroupContainers = getDocumentRoot().querySelectorAll('.group-container'); allGroupContainers.forEach(container => container.classList.remove('expanded')); } // Attach event listeners to editor elements (for dynamically created editors) function attachEditorEventListeners(editorElement) { // Note: Undo buttons are now linked via timeline.setUndoButton() in editGroupSchedule // Ignore button (Unmonitor button for single-entity groups) const ignoreBtn = editorElement.querySelector('#ignore-entity-btn'); if (ignoreBtn) { ignoreBtn.onclick = async (event) => { // For single-entity groups, get entity from the group let entityIdToUnmonitor = null; if (currentGroup) { const groupData = allGroups[currentGroup]; if (groupData && groupData.entities && groupData.entities.length === 1) { entityIdToUnmonitor = groupData.entities[0]; } } if (!entityIdToUnmonitor) { console.error('No entity ID found to unmonitor'); return; } if (confirm(`Stop monitoring ${entityIdToUnmonitor}?\n\nUnmonitored entities will not be managed by the scheduler.`)) { try { await haAPI.setIgnored(entityIdToUnmonitor, true); showToast(`${entityIdToUnmonitor} is now unmonitored`, 'success'); // Reload groups to update the display await loadGroups(); // Close the editor collapseAllEditors(); currentGroup = null; } catch (error) { console.error('Failed to unmonitor entity:', error); showToast('Failed to unmonitor entity: ' + error.message, 'error'); } } }; } // Copy schedule button const copyBtn = editorElement.querySelector('#copy-schedule-btn'); if (copyBtn) { copyBtn.onclick = () => { copySchedule(); }; } // Paste schedule button const pasteBtn = editorElement.querySelector('#paste-schedule-btn'); if (pasteBtn) { pasteBtn.onclick = () => { pasteSchedule(); }; } // Advance schedule button const advanceBtn = editorElement.querySelector('#main-advance-schedule-btn, #advance-schedule-btn'); if (advanceBtn) { // Function to update button state const updateAdvanceButton = async () => { if (currentGroup) { // For groups, check if any entity has an active advance const groupData = allGroups[currentGroup]; if (groupData && groupData.entities) { let anyActive = false; for (const entityId of groupData.entities) { const status = await haAPI.getAdvanceStatus(entityId); if (status && status.is_active) { anyActive = true; break; } } if (anyActive) { advanceBtn.textContent = 'Cancel Advance'; advanceBtn.title = 'Cancel advance override for all entities in group'; advanceBtn.dataset.isOverride = 'true'; } else { advanceBtn.textContent = 'Advance'; advanceBtn.title = 'Advance all entities in group to next scheduled node'; advanceBtn.dataset.isOverride = 'false'; } } } }; // Check initial state updateAdvanceButton(); advanceBtn.onclick = async () => { advanceBtn.disabled = true; try { const isOverride = advanceBtn.dataset.isOverride === 'true'; if (isOverride) { // Cancel advance if (currentGroup) { // Cancel advance for all entities in group const groupData = allGroups[currentGroup]; if (groupData && groupData.entities) { for (const entityId of groupData.entities) { await haAPI.cancelAdvance(entityId); } showToast('Advance canceled for all entities in group', 'success'); // Optimistically update button state immediately advanceBtn.textContent = 'Advance'; advanceBtn.title = 'Advance all entities in group to next scheduled node'; advanceBtn.dataset.isOverride = 'false'; } } } else { // Advance to next if (currentGroup) { await haAPI.advanceGroup(currentGroup); showToast(`Advanced all entities in group to next scheduled node`, 'success'); // Optimistically update button state immediately advanceBtn.textContent = 'Cancel Advance'; advanceBtn.title = 'Cancel advance override for all entities in group'; advanceBtn.dataset.isOverride = 'true'; } } // Update button state after action await updateAdvanceButton(); // Small delay to ensure backend has updated await new Promise(resolve => setTimeout(resolve, 500)); // Re-check after backend settles (important if updateAdvanceButton ran too early) await updateAdvanceButton(); // Reload advance history to update graph if (currentGroup) { // For groups, reload history for first entity (since they share same schedule) const groupData = allGroups[currentGroup]; if (groupData && groupData.entities && groupData.entities.length > 0) { await loadAdvanceHistory(groupData.entities[0]); } } } catch (error) { console.error('Failed to advance/cancel schedule:', error); showToast('Failed: ' + error.message, 'error'); } finally { advanceBtn.disabled = false; } }; } // Clear advance history button const clearHistoryBtn = editorElement.querySelector('#clear-advance-history-btn'); if (clearHistoryBtn) { clearHistoryBtn.onclick = async () => { clearHistoryBtn.disabled = true; try { if (currentGroup) { // Clear history for all entities in group const groupData = allGroups[currentGroup]; if (groupData && groupData.entities) { for (const entityId of groupData.entities) { await haAPI.clearAdvanceHistory(entityId); } // Reload graph with first entity if (groupData.entities.length > 0) { await loadAdvanceHistory(groupData.entities[0]); } showToast('Advance history cleared for all entities in group', 'success'); } } } catch (error) { console.error('Failed to clear advance history:', error); showToast('Failed: ' + error.message, 'error'); } finally { clearHistoryBtn.disabled = false; } }; } // Clear schedule button const clearBtn = editorElement.querySelector('#clear-schedule-btn'); if (clearBtn) { clearBtn.onclick = async () => { if (currentGroup) { if (confirm(`Clear schedule for group "${currentGroup}"?`)) { await clearScheduleForGroup(currentGroup); } } }; } // Schedule enabled toggle const enabledToggle = editorElement.querySelector('#schedule-enabled'); if (enabledToggle) { enabledToggle.onchange = () => saveSchedule(); } // Save button const saveBtn = editorElement.querySelector('#main-save-schedule-btn, #save-schedule-btn'); if (saveBtn) { saveBtn.onclick = async () => { saveBtn.disabled = true; await saveSchedule(); // Visual feedback const originalText = saveBtn.textContent; saveBtn.textContent = 'Saved!'; setTimeout(() => { saveBtn.textContent = originalText; saveBtn.disabled = false; }, 1000); }; } // Node settings panel close const closeSettings = editorElement.querySelector('#close-settings'); if (closeSettings) { closeSettings.onclick = () => { const panel = editorElement.querySelector('#node-settings-panel'); if (panel) { panel.style.display = 'none'; // Clear selected node highlight const timeline = nodeSettingsTimeline || graph; if (timeline) { timeline.selectedNodeIndex = null; timeline.render(); } } }; } // Node navigation buttons const prevNodeBtn = editorElement.querySelector('#prev-node'); const nextNodeBtn = editorElement.querySelector('#next-node'); if (prevNodeBtn) { prevNodeBtn.onclick = () => { const timeline = nodeSettingsTimeline || graph; if (timeline && timeline.selectPrevious) { timeline.selectPrevious(); } }; } if (nextNodeBtn) { nextNodeBtn.onclick = () => { const timeline = nodeSettingsTimeline || graph; if (timeline && timeline.selectNext) { timeline.selectNext(); } }; } // Time and temperature adjustment controls const timeInput = editorElement.querySelector('#node-time-input'); const tempInput = editorElement.querySelector('#node-temp-input'); const timeUpBtn = editorElement.querySelector('#time-up'); const timeDownBtn = editorElement.querySelector('#time-down'); const tempUpBtn = editorElement.querySelector('#temp-up'); const tempDownBtn = editorElement.querySelector('#temp-down'); const getNodeSettingsState = () => { const panel = editorElement.querySelector('#node-settings-panel'); if (!panel) return null; const nodeIndex = parseInt(panel.dataset.nodeIndex); const timeline = nodeSettingsTimeline || graph; if (isNaN(nodeIndex) || !timeline) return null; const keyframe = timeline.keyframes?.[nodeIndex]; if (!keyframe) return null; return { panel, nodeIndex, timeline, keyframe }; }; const saveNodeSettingsUndoState = (timeline) => { if (timeline && typeof timeline.saveUndoState === 'function') { timeline.saveUndoState(); } }; const updateNodeFromInputs = (captureUndo = false) => { const state = getNodeSettingsState(); if (!state) return; const { panel, nodeIndex, timeline, keyframe } = state; if (captureUndo) { saveNodeSettingsUndoState(timeline); } // Find the corresponding node in currentSchedule const oldHours = Math.floor(keyframe.time); const oldMinutes = Math.round((keyframe.time - oldHours) * 60); const oldTimeStr = `${String(oldHours).padStart(2, '0')}:${String(oldMinutes).padStart(2, '0')}`; const scheduleNode = currentSchedule.find(n => n.time === oldTimeStr); // Update time if (timeInput && timeInput.value) { const [hours, minutes] = timeInput.value.split(':').map(Number); const decimalTime = hours + (minutes / 60); keyframe.time = decimalTime; // Update schedule node time if (scheduleNode) { scheduleNode.time = timeInput.value; } } // Update temperature const tempNoChange = editorElement.querySelector('#temp-no-change'); if (tempNoChange && tempNoChange.checked) { // Set noChange flag in schedule node if (scheduleNode) { scheduleNode.noChange = true; const lockedTemp = resolveNoChangeLockedTemp(currentSchedule, scheduleNode); if (lockedTemp !== null) { scheduleNode.temp = lockedTemp; keyframe.value = lockedTemp; if (tempInput) { tempInput.value = String(lockedTemp); } } } } else { if (scheduleNode) { scheduleNode.noChange = false; } if (tempInput && tempInput.value) { const temp = parseFloat(tempInput.value); if (!isNaN(temp)) { const normalizedTemp = normalizeTemperature(temp, inputTempStep); keyframe.value = normalizedTemp; tempInput.value = String(normalizedTemp); if (scheduleNode) { scheduleNode.temp = normalizedTemp; } } } } // Sort the schedule by time to maintain correct order currentSchedule.sort((a, b) => { const [aHours, aMinutes] = a.time.split(':').map(Number); const [bHours, bMinutes] = b.time.split(':').map(Number); const aDecimal = aHours + aMinutes / 60; const bDecimal = bHours + bMinutes / 60; return aDecimal - bDecimal; }); // Rebuild keyframes from sorted schedule const keyframes = scheduleNodesToKeyframes(currentSchedule); timeline.keyframes = keyframes; // Find the new index of the node we're editing (by time) const newNodeIndex = currentSchedule.findIndex(n => n.time === timeInput.value); if (newNodeIndex !== -1 && newNodeIndex !== nodeIndex) { // Update the panel's nodeIndex to the new position panel.dataset.nodeIndex = newNodeIndex; // Close and reopen the panel to refresh the time dropdown panel.style.display = 'none'; setTimeout(() => { // Trigger a node selection event to reopen with updated dropdown timeline.dispatchEvent(new CustomEvent('keyframe-selected', { detail: { index: newNodeIndex, keyframe: timeline.keyframes[newNodeIndex] }, bubbles: true })); }, 10); } saveSchedule(); }; if (timeInput) { timeInput.addEventListener('change', () => updateNodeFromInputs(true)); } if (tempInput) { tempInput.addEventListener('input', () => updateNodeFromInputs(false)); // Immediate update while typing tempInput.addEventListener('change', () => updateNodeFromInputs(true)); tempInput.addEventListener('blur', () => updateNodeFromInputs(false)); } if (timeUpBtn) { timeUpBtn.onclick = () => { const state = getNodeSettingsState(); if (!state) return; const { timeline, keyframe } = state; saveNodeSettingsUndoState(timeline); // Get old time string for finding scheduleNode const oldHours = Math.floor(keyframe.time); const oldMinutes = Math.round((keyframe.time - oldHours) * 60); const oldTimeStr = `${String(oldHours).padStart(2, '0')}:${String(oldMinutes).padStart(2, '0')}`; // Add 15 minutes to decimal time let newDecimalTime = keyframe.time + 0.25; // 15 minutes = 0.25 hours if (newDecimalTime >= 24) newDecimalTime -= 24; // Wrap at 24h // Update keyframe time (decimal) keyframe.time = newDecimalTime; // Update corresponding scheduleNode time (string) const newHours = Math.floor(newDecimalTime); const newMinutes = Math.round((newDecimalTime - newHours) * 60); const newTimeStr = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`; const scheduleNode = currentSchedule.find(n => n.time === oldTimeStr); if (scheduleNode) { scheduleNode.time = newTimeStr; } timeInput.value = newTimeStr; // Update UI immediately timeline.render(); // Save in background (deferred to next event loop tick) setTimeout(() => { timeline.keyframes = [...timeline.keyframes]; // Trigger reactivity saveSchedule(); }, 0); }; } if (timeDownBtn) { timeDownBtn.onclick = () => { const state = getNodeSettingsState(); if (!state) return; const { timeline, keyframe } = state; saveNodeSettingsUndoState(timeline); // Get old time string for finding scheduleNode const oldHours = Math.floor(keyframe.time); const oldMinutes = Math.round((keyframe.time - oldHours) * 60); const oldTimeStr = `${String(oldHours).padStart(2, '0')}:${String(oldMinutes).padStart(2, '0')}`; // Subtract 15 minutes from decimal time let newDecimalTime = keyframe.time - 0.25; // 15 minutes = 0.25 hours if (newDecimalTime < 0) newDecimalTime += 24; // Wrap at 0 // Update keyframe time (decimal) keyframe.time = newDecimalTime; // Update corresponding scheduleNode time (string) const newHours = Math.floor(newDecimalTime); const newMinutes = Math.round((newDecimalTime - newHours) * 60); const newTimeStr = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`; const scheduleNode = currentSchedule.find(n => n.time === oldTimeStr); if (scheduleNode) { scheduleNode.time = newTimeStr; } timeInput.value = newTimeStr; // Update UI immediately timeline.render(); // Save in background (deferred to next event loop tick) setTimeout(() => { timeline.keyframes = [...timeline.keyframes]; // Trigger reactivity saveSchedule(); }, 0); }; } if (tempUpBtn) { tempUpBtn.onclick = () => { const state = getNodeSettingsState(); if (!state) return; const { timeline, keyframe } = state; saveNodeSettingsUndoState(timeline); // Check if no-change is enabled const tempNoChange = editorElement.querySelector('#temp-no-change'); if (tempNoChange && tempNoChange.checked) return; // Increment based on inputTempStep setting keyframe.value = normalizeTemperature(keyframe.value + inputTempStep, inputTempStep); // Update corresponding scheduleNode.temp const hours = Math.floor(keyframe.time); const minutes = Math.round((keyframe.time - hours) * 60); const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; const scheduleNode = currentSchedule.find(n => n.time === timeStr); if (scheduleNode) { scheduleNode.temp = keyframe.value; } tempInput.value = String(keyframe.value); // Update UI immediately timeline.render(); // Save in background (deferred to next event loop tick) setTimeout(() => { timeline.keyframes = [...timeline.keyframes]; // Trigger reactivity saveSchedule(); }, 0); }; } if (tempDownBtn) { tempDownBtn.onclick = () => { const state = getNodeSettingsState(); if (!state) return; const { timeline, keyframe } = state; saveNodeSettingsUndoState(timeline); // Check if no-change is enabled const tempNoChange = editorElement.querySelector('#temp-no-change'); if (tempNoChange && tempNoChange.checked) return; // Decrement based on inputTempStep setting keyframe.value = normalizeTemperature(keyframe.value - inputTempStep, inputTempStep); // Update corresponding scheduleNode.temp const hours = Math.floor(keyframe.time); const minutes = Math.round((keyframe.time - hours) * 60); const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; const scheduleNode = currentSchedule.find(n => n.time === timeStr); if (scheduleNode) { scheduleNode.temp = keyframe.value; } tempInput.value = String(keyframe.value); // Update UI immediately timeline.render(); // Save in background (deferred to next event loop tick) setTimeout(() => { timeline.keyframes = [...timeline.keyframes]; // Trigger reactivity saveSchedule(); }, 0); }; }; // Test fire event button (in node settings) const testFireBtn = editorElement.querySelector('#test-fire-event-btn'); if (testFireBtn) { testFireBtn.onclick = async () => { const state = getNodeSettingsState(); if (!state || !currentGroup) return; const { keyframe } = state; const hours = Math.floor(keyframe.time); const minutes = Math.round((keyframe.time - hours) * 60); const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; const scheduleNode = currentSchedule.find(n => n.time === timeStr || Math.abs(timeStringToDecimalHours(n.time) - keyframe.time) < 0.01 ) || { time: timeStr, temp: keyframe.value }; const targetDay = currentDay || 'all_days'; testFireBtn.disabled = true; try { await haAPI.testFireEvent(currentGroup, scheduleNode, targetDay); showToast(`Test event fired for node at ${scheduleNode.time}`, 'success'); } catch (error) { console.error('Failed to test fire event:', error); showToast('Failed to fire test event: ' + error.message, 'error'); } finally { testFireBtn.disabled = false; } }; } // Delete node button const deleteNode = editorElement.querySelector('#delete-node'); if (deleteNode) { deleteNode.onclick = () => { const state = getNodeSettingsState(); if (state) { const { panel, nodeIndex, timeline } = state; if (timeline.keyframes.length <= 1) { alert('Cannot delete the last node. A schedule must have at least one node.'); return; } if (!confirm('Delete this node?')) return; saveNodeSettingsUndoState(timeline); // Remove keyframe at index timeline.keyframes = timeline.keyframes.filter((_, i) => i !== nodeIndex); panel.style.display = 'none'; // Trigger keyframe-deleted event (canvas redraws automatically) timeline.dispatchEvent(new CustomEvent('keyframe-deleted')); saveSchedule(); } }; } // Handle no-change checkbox const tempNoChange = editorElement.querySelector('#temp-no-change'); if (tempNoChange) { tempNoChange.onchange = () => { const state = getNodeSettingsState(); if (!state) return; const { timeline, keyframe: node } = state; saveNodeSettingsUndoState(timeline); if (tempNoChange.checked) { // Set to no change node.noChange = true; tempInput.disabled = true; tempUpBtn.disabled = true; tempDownBtn.disabled = true; } else { // Restore to normal temperature node node.noChange = false; if (node.temp === null || node.temp === undefined) { node.temp = 20; // Default temp } tempInput.value = node.temp; tempInput.disabled = false; tempUpBtn.disabled = false; tempDownBtn.disabled = false; } timeline.render(); saveSchedule(); }; } // Auto-save node settings when dropdowns change const hvacModeSelect = editorElement.querySelector('#node-hvac-mode'); const fanModeSelect = editorElement.querySelector('#node-fan-mode'); const swingModeSelect = editorElement.querySelector('#node-swing-mode'); const presetModeSelect = editorElement.querySelector('#node-preset-mode'); const autoSaveNodeSettings = async () => { const state = getNodeSettingsState(); if (!state) return; const { timeline, keyframe } = state; // Find the corresponding node in currentSchedule by time const hours = Math.floor(keyframe.time); const minutes = Math.round((keyframe.time - hours) * 60); const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; const scheduleNode = currentSchedule.find(n => n.time === timeStr); if (!scheduleNode) return; saveNodeSettingsUndoState(timeline); // Update or delete properties based on dropdown values if (hvacModeSelect && hvacModeSelect.closest('.setting-item').style.display !== 'none') { const hvacMode = hvacModeSelect.value; if (hvacMode) { scheduleNode.hvac_mode = hvacMode; } else { delete scheduleNode.hvac_mode; } } if (fanModeSelect && fanModeSelect.closest('.setting-item').style.display !== 'none') { const fanMode = fanModeSelect.value; if (fanMode) { scheduleNode.fan_mode = fanMode; } else { delete scheduleNode.fan_mode; } } if (swingModeSelect && swingModeSelect.closest('.setting-item').style.display !== 'none') { const swingMode = swingModeSelect.value; if (swingMode) { scheduleNode.swing_mode = swingMode; } else { delete scheduleNode.swing_mode; } } if (presetModeSelect && presetModeSelect.closest('.setting-item').style.display !== 'none') { const presetMode = presetModeSelect.value; if (presetMode) { scheduleNode.preset_mode = presetMode; } else { delete scheduleNode.preset_mode; } } // Update value fields const valueAInput = editorElement.querySelector('#node-value-A'); const valueBInput = editorElement.querySelector('#node-value-B'); const valueCInput = editorElement.querySelector('#node-value-C'); if (valueAInput) { const val = valueAInput.value.trim(); scheduleNode['A'] = val !== '' ? parseFloat(val) : null; } if (valueBInput) { const val = valueBInput.value.trim(); scheduleNode['B'] = val !== '' ? parseFloat(val) : null; } if (valueCInput) { const val = valueCInput.value.trim(); scheduleNode['C'] = val !== '' ? parseFloat(val) : null; } // Trigger save - check if using canvas timeline or old graph if (timeline.notifyChange) { // Old SVG graph has notifyChange method timeline.render(); timeline.notifyChange(true); } else { // Canvas timeline - save directly graph = timeline; handleGraphChange({ detail: { force: true } }, true); } }; // Immediate UI update on input (no save) const updateUIOnly = () => { const state = getNodeSettingsState(); if (!state) return; const { timeline, keyframe: node } = state; // Update value fields for immediate visual feedback const valueAInput = editorElement.querySelector('#node-value-A'); const valueBInput = editorElement.querySelector('#node-value-B'); const valueCInput = editorElement.querySelector('#node-value-C'); if (valueAInput) { const val = valueAInput.value.trim(); const parsed = parseFloat(val); if (!isNaN(parsed)) { node['A'] = parsed; } } if (valueBInput) { const val = valueBInput.value.trim(); const parsed = parseFloat(val); if (!isNaN(parsed)) { node['B'] = parsed; } } if (valueCInput) { const val = valueCInput.value.trim(); const parsed = parseFloat(val); if (!isNaN(parsed)) { node['C'] = parsed; } } // Only update UI, don't trigger save timeline.render(); }; // Attach change listeners to all dropdowns and value inputs if (hvacModeSelect) hvacModeSelect.addEventListener('change', autoSaveNodeSettings); if (fanModeSelect) fanModeSelect.addEventListener('change', autoSaveNodeSettings); if (swingModeSelect) swingModeSelect.addEventListener('change', autoSaveNodeSettings); if (presetModeSelect) presetModeSelect.addEventListener('change', autoSaveNodeSettings); const valueAInput = editorElement.querySelector('#node-value-A'); const valueBInput = editorElement.querySelector('#node-value-B'); const valueCInput = editorElement.querySelector('#node-value-C'); if (valueAInput) { valueAInput.addEventListener('input', updateUIOnly); // Immediate UI update while typing valueAInput.addEventListener('change', autoSaveNodeSettings); // Save when done } if (valueBInput) { valueBInput.addEventListener('input', updateUIOnly); // Immediate UI update while typing valueBInput.addEventListener('change', autoSaveNodeSettings); // Save when done } if (valueCInput) { valueCInput.addEventListener('input', updateUIOnly); // Immediate UI update while typing valueCInput.addEventListener('change', autoSaveNodeSettings); // Save when done } // Schedule mode dropdown const modeDropdown = editorElement.querySelector('#main-schedule-mode-dropdown'); if (modeDropdown) { modeDropdown.addEventListener('change', async (e) => { const newMode = e.target.value; await switchScheduleMode(newMode); }); } // Graph quick action buttons const graphCopyBtn = editorElement.querySelector('#main-graph-copy-btn, #graph-copy-btn'); if (graphCopyBtn) { graphCopyBtn.onclick = () => { copySchedule(); }; } const graphPasteBtn = editorElement.querySelector('#main-graph-paste-btn, #graph-paste-btn'); if (graphPasteBtn) { graphPasteBtn.onclick = () => { pasteSchedule(); }; } } // Clipboard for schedule copy/paste let scheduleClipboard = null; // Update paste button state based on clipboard function updatePasteButtonState() { const hasClipboard = scheduleClipboard && scheduleClipboard.length > 0; // Update all paste buttons (handles both old IDs and new prefixed IDs) const pasteButtons = [ '#paste-schedule-btn', '#graph-paste-btn', '#main-graph-paste-btn', '#profile-graph-paste-btn', '#default-graph-paste-btn' ]; pasteButtons.forEach(selector => { const btn = getDocumentRoot().querySelector(selector); if (btn) { btn.disabled = !hasClipboard; } }); } // Copy current schedule to clipboard function copySchedule() { const nodes = getGraphNodes(); if (nodes && nodes.length > 0) { // Deep copy the nodes scheduleClipboard = nodes.map(n => ({...n})); // Enable paste buttons updatePasteButtonState(); // Visual feedback const copyBtn = getDocumentRoot().querySelector('#main-graph-copy-btn, #profile-graph-copy-btn, #default-graph-copy-btn, #copy-schedule-btn, #graph-copy-btn'); if (copyBtn) { const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = originalText; }, 1000); } } } // Paste schedule from clipboard async function pasteSchedule() { if (!scheduleClipboard || scheduleClipboard.length === 0) { return; } // Deep copy from clipboard const nodes = scheduleClipboard.map(n => ({...n})); // Update currentSchedule with pasted data currentSchedule = nodes; // Update graph setGraphNodes(nodes); // Save the pasted schedule await saveSchedule(); // Visual feedback const pasteBtn = getDocumentRoot().querySelector('#main-graph-paste-btn, #profile-graph-paste-btn, #default-graph-paste-btn, #paste-schedule-btn, #graph-paste-btn'); if (pasteBtn) { const originalText = pasteBtn.textContent; pasteBtn.textContent = 'Pasted!'; setTimeout(() => { pasteBtn.textContent = originalText; }, 1000); } } // Clear schedule for an entity async function clearScheduleForEntity(entityId) { try { // Reset to user-configured default schedule const defaultSchedule = defaultScheduleSettings.map(node => ({...node})); // Save default schedule to HA with current day and mode await haAPI.setSchedule(entityId, defaultSchedule, currentDay, currentScheduleMode); // Update local state entitySchedules.set(entityId, defaultSchedule); // Update graph with default schedule if (graph) { setGraphNodes(defaultSchedule); } // Update current schedule reference currentSchedule = defaultSchedule; showToast('Schedule cleared', 'success'); } catch (error) { console.error('Failed to clear schedule:', error); showToast('Failed to clear schedule. Please try again.', 'error'); } } // Clear schedule for a group async function clearScheduleForGroup(groupName) { try { const groupData = allGroups[groupName]; if (!groupData) return; // Reset to user-configured default schedule const defaultSchedule = defaultScheduleSettings.map(node => ({...node})); // Update group schedules based on schedule mode const scheduleMode = groupData.schedule_mode || 'all_days'; // Save default schedule for each day based on schedule mode if (scheduleMode === 'all_days') { await haAPI.setGroupSchedule(groupName, defaultSchedule, 'all_days', scheduleMode); groupData.schedules = { all_days: defaultSchedule }; } else if (scheduleMode === '5/2') { await haAPI.setGroupSchedule(groupName, defaultSchedule.map(node => ({...node})), 'weekday', scheduleMode); await haAPI.setGroupSchedule(groupName, defaultSchedule.map(node => ({...node})), 'weekend', scheduleMode); groupData.schedules = { weekday: defaultSchedule.map(node => ({...node})), weekend: defaultSchedule.map(node => ({...node})) }; } else if (scheduleMode === 'individual') { const days = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; for (const day of days) { await haAPI.setGroupSchedule(groupName, defaultSchedule.map(node => ({...node})), day, scheduleMode); } groupData.schedules = { mon: defaultSchedule.map(node => ({...node})), tue: defaultSchedule.map(node => ({...node})), wed: defaultSchedule.map(node => ({...node})), thu: defaultSchedule.map(node => ({...node})), fri: defaultSchedule.map(node => ({...node})), sat: defaultSchedule.map(node => ({...node})), sun: defaultSchedule.map(node => ({...node})) }; } // Update graph with default schedule for current day if (graph) { setGraphNodes(defaultSchedule); } // Update current schedule reference currentSchedule = defaultSchedule; showToast(`Cleared schedule for group "${groupName}"`, 'success'); } catch (error) { console.error('Failed to clear group schedule:', error); showToast('Failed to clear group schedule. Please try again.', 'error'); } } // Get schedule title with mode function getScheduleTitle() { if (currentScheduleMode === 'all_days') { return 'Schedule (All Days)'; } else if (currentScheduleMode === '5/2') { const dayName = currentDay === 'weekday' ? 'Weekdays' : 'Weekend'; return `Schedule (${dayName})`; } else { const dayNames = { 'mon': 'Monday', 'tue': 'Tuesday', 'wed': 'Wednesday', 'thu': 'Thursday', 'fri': 'Friday', 'sat': 'Saturday', 'sun': 'Sunday' }; return `Schedule (${dayNames[currentDay] || currentDay})`; } } // Update schedule mode UI to reflect current mode and day function updateScheduleModeUI() { // Update mode dropdown const modeDropdown = getDocumentRoot().querySelector('#main-schedule-mode-dropdown'); if (modeDropdown) { modeDropdown.value = currentScheduleMode; } // Update day/period selector above graph updateGraphDaySelector(); // Update graph title to show current day updateGraphTitle(); } // Update the day/period selector above the graph function updateGraphDaySelector() { const dayPeriodSelector = getDocumentRoot().querySelector('#main-day-period-selector'); const dayPeriodButtons = getDocumentRoot().querySelector('#main-day-period-buttons'); if (!dayPeriodSelector || !dayPeriodButtons) return; // Update profile dropdown updateGraphProfileDropdown(); // Hide selector if in all_days mode if (currentScheduleMode === 'all_days') { dayPeriodSelector.style.display = 'none'; return; } // Show selector dayPeriodSelector.style.display = 'block'; // Clear existing buttons dayPeriodButtons.innerHTML = ''; // Create buttons based on mode if (currentScheduleMode === 'individual') { const days = [ { value: 'mon', label: 'Mon' }, { value: 'tue', label: 'Tue' }, { value: 'wed', label: 'Wed' }, { value: 'thu', label: 'Thu' }, { value: 'fri', label: 'Fri' }, { value: 'sat', label: 'Sat' }, { value: 'sun', label: 'Sun' } ]; days.forEach(day => { const btn = document.createElement('button'); btn.className = 'day-period-btn'; btn.textContent = day.label; btn.dataset.day = day.value; if (currentDay === day.value) { btn.classList.add('active'); } btn.addEventListener('click', async () => { await switchDay(day.value); }); dayPeriodButtons.appendChild(btn); }); } else if (currentScheduleMode === '5/2') { const periods = [ { value: 'weekday', label: 'Weekday' }, { value: 'weekend', label: 'Weekend' } ]; periods.forEach(period => { const btn = document.createElement('button'); btn.className = 'day-period-btn'; btn.textContent = period.label; btn.dataset.day = period.value; if (currentDay === period.value) { btn.classList.add('active'); } btn.addEventListener('click', async () => { await switchDay(period.value); }); dayPeriodButtons.appendChild(btn); }); } } // Update the profile dropdown above the graph function updateGraphProfileDropdown() { if (!currentGroup) return; // Find the group header's profile dropdown const groupContainer = getDocumentRoot().querySelector(`.group-container[data-group-name="${currentGroup}"]`); if (!groupContainer) return; const profileDropdown = groupContainer.querySelector('.group-profile-dropdown'); if (!profileDropdown) return; // Get current active profile const activeProfile = (allGroups[currentGroup]?.active_profile) || 'Default'; // Get all profiles const profiles = (allGroups[currentGroup]?.profiles ? Object.keys(allGroups[currentGroup].profiles) : ['Default']); // Update dropdown options profileDropdown.innerHTML = ''; // Add placeholder option for profile editor dropdown if (profileDropdown.id === 'profile-dropdown') { const placeholderOption = document.createElement('option'); placeholderOption.value = ''; placeholderOption.textContent = 'Choose Profile to Edit'; placeholderOption.disabled = true; placeholderOption.selected = true; profileDropdown.appendChild(placeholderOption); } profiles.forEach(profileName => { const option = document.createElement('option'); option.value = profileName; option.textContent = profileName; if (profileName === activeProfile) { option.selected = true; } profileDropdown.appendChild(option); }); // Don't set value for profile editor dropdown (keep placeholder selected) if (profileDropdown.id !== 'profile-dropdown') { // Ensure the active profile is selected for group selector profileDropdown.value = activeProfile; } } // Set previous day's last temperature for graph rendering function setPreviousDayLastTempForGraph(groupData, currentDayParam) { if (!graph || !groupData || !groupData.schedules) return; const scheduleMode = groupData.schedule_mode || 'all_days'; // In all_days mode, previous day is same as current day if (scheduleMode === 'all_days') { graph.previousDayEndValue = null; return; } // Determine previous day based on schedule mode let previousDayKey = null; if (scheduleMode === '5/2') { // In weekday/weekend mode if (currentDayParam === 'weekday') { // Previous period is weekend previousDayKey = 'weekend'; } else if (currentDayParam === 'weekend') { // Previous period is weekday (Friday) previousDayKey = 'weekday'; } } else if (scheduleMode === 'individual') { // In 7-day mode, get actual previous day const days = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const currentIndex = days.indexOf(currentDayParam); if (currentIndex !== -1) { previousDayKey = days[(currentIndex - 1 + 7) % 7]; } } // Get previous day's schedule if (previousDayKey && groupData.schedules[previousDayKey]) { const previousDayNodes = groupData.schedules[previousDayKey]; if (previousDayNodes && previousDayNodes.length > 0) { // Find the last node with a temperature (not noChange) const nodesWithTemp = previousDayNodes.filter(n => !n.noChange && n.temp !== null && n.temp !== undefined); if (nodesWithTemp.length > 0) { const lastNode = nodesWithTemp[nodesWithTemp.length - 1]; // Set previous day end value for canvas timeline graph.previousDayEndValue = lastNode.temp || lastNode.value; return; } } } // If no previous day data found, clear it graph.previousDayEndValue = null; } // Get previous day's last temperature value (for canvas graph) function getPreviousDayLastTemp(groupData, currentDayParam) { if (!groupData || !groupData.schedules) return null; const scheduleMode = groupData.schedule_mode || 'all_days'; // In all_days mode, previous day is same as current day if (scheduleMode === 'all_days') { return null; } // Determine previous day based on schedule mode let previousDayKey = null; if (scheduleMode === '5/2') { // In weekday/weekend mode if (currentDayParam === 'weekday') { previousDayKey = 'weekend'; } else if (currentDayParam === 'weekend') { previousDayKey = 'weekday'; } } else if (scheduleMode === 'individual') { // In 7-day mode, get actual previous day const days = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const currentIndex = days.indexOf(currentDayParam); if (currentIndex !== -1) { previousDayKey = days[(currentIndex - 1 + 7) % 7]; } } // Get previous day's schedule if (previousDayKey && groupData.schedules[previousDayKey]) { const previousDayNodes = groupData.schedules[previousDayKey]; if (previousDayNodes && previousDayNodes.length > 0) { // Find the last node with a temperature (not noChange) const nodesWithTemp = previousDayNodes.filter(n => !n.noChange && n.temp !== null && n.temp !== undefined); if (nodesWithTemp.length > 0) { const lastNode = nodesWithTemp[nodesWithTemp.length - 1]; return lastNode.temp; } } } return null; } // Update graph title to show which day is being edited function updateGraphTitle() { // Update timeline title if (graph && graph.title !== undefined) { graph.title = getScheduleTitle(); } } // Switch to a different schedule mode async function switchScheduleMode(newMode) { if (!currentGroup) return; const previousMode = currentScheduleMode; const previousDay = currentDay; const previousNodes = cloneScheduleNodes(currentSchedule); const scheduleSnapshot = allGroups[currentGroup]?.schedules ? JSON.parse(JSON.stringify(allGroups[currentGroup].schedules)) : {}; currentScheduleMode = newMode; // Determine destination day preserving current editing context when possible currentDay = resolveModeTransitionDay(previousMode, previousDay, newMode); // Save the mode change to the active profile in backend if (currentGroup) { const nodes = getModeTransitionSeedNodes({ schedules: scheduleSnapshot, previousMode, previousDay, newMode, newDay: currentDay, currentNodes: previousNodes }); // Save without explicit profile_name so backend resolves active global profile authoritatively await haAPI.setGroupSchedule(currentGroup, nodes, currentDay, currentScheduleMode); // Update local group data if (allGroups[currentGroup]) { allGroups[currentGroup].schedule_mode = currentScheduleMode; } } // Update UI updateScheduleModeUI(); // Reload group data from backend to get latest saved state if (currentGroup) { const result = await haAPI.getGroups(); allGroups = normalizeGroupsPayload(result); const groupData = allGroups[currentGroup]; if (!groupData) return; // Load nodes for the selected day let nodes = []; if (groupData.schedules && groupData.schedules[currentDay]) { nodes = groupData.schedules[currentDay]; } else if (currentDay === "weekday" && groupData.schedules && groupData.schedules["mon"]) { // If weekday key doesn't exist, try loading from Monday nodes = groupData.schedules["mon"]; } else if (currentDay === "weekend" && groupData.schedules && groupData.schedules["sat"]) { // If weekend key doesn't exist, try loading from Saturday nodes = groupData.schedules["sat"]; } else if (groupData.nodes) { // Backward compatibility nodes = groupData.nodes; } currentSchedule = nodes.length > 0 ? nodes.map(n => ({...n})) : []; // Set loading flag to prevent auto-save during graph update isLoadingSchedule = true; // // Update graph with new nodes setGraphNodes(currentSchedule); // Set previous day's last temperature for graph rendering setPreviousDayLastTempForGraph(groupData, currentDay); // Clear loading flag after a delay setTimeout(() => { isLoadingSchedule = false; flushPendingSaveIfNeeded(); // }, 100); // Clear editing profile and hide indicator when returning to active profile editingProfile = null; showEditingProfileIndicator(null, groupData.active_profile); // Update scheduled temp display updateScheduledTemp(); } } // Switch to a different day async function switchDay(day) { if (!currentGroup) return; // Switching day - no need to save since auto-save already persisted changes currentDay = day; // Update UI first updateScheduleModeUI(); // Reload schedule for selected day - update in place without recreating editor if (currentGroup) { // Use cached group data - no need to fetch from backend since auto-save keeps it in sync const groupData = allGroups[currentGroup]; if (!groupData) return; // Load nodes for the selected day let nodes = []; if (groupData.schedules && groupData.schedules[currentDay]) { nodes = groupData.schedules[currentDay]; } else if (currentDay === "weekday" && groupData.schedules && groupData.schedules["mon"]) { // If weekday key doesn't exist, try loading from Monday nodes = groupData.schedules["mon"]; } else if (currentDay === "weekend" && groupData.schedules && groupData.schedules["sat"]) { // If weekend key doesn't exist, try loading from Saturday nodes = groupData.schedules["sat"]; } else if (groupData.nodes) { // Backward compatibility nodes = groupData.nodes; } currentSchedule = nodes.length > 0 ? nodes.map(n => ({...n})) : []; // Set loading flag to prevent auto-save during graph update isLoadingSchedule = true; // Update graph with new nodes setGraphNodes(currentSchedule); // Set previous day's last temperature for graph rendering setPreviousDayLastTempForGraph(groupData, currentDay); // Clear loading flag after a delay setTimeout(() => { isLoadingSchedule = false; flushPendingSaveIfNeeded(); }, 100); // Clear editing profile and hide indicator when returning to active profile editingProfile = null; showEditingProfileIndicator(null, groupData.active_profile); // Update scheduled temp display updateScheduledTemp(); } } // Load history data for current day async function loadHistoryData(entityId) { try { // Get start of today in server timezone const nowServer = getServerNow(serverTimeZone); const today = new Date(Date.UTC(nowServer.getFullYear(), nowServer.getMonth(), nowServer.getDate(), 0, 0, 0, 0)); // Get current time const now = new Date(); // Fetch history from Home Assistant const historyResult = await haAPI.getHistory(entityId, today, now); if (!historyResult || !historyResult[entityId]) { setGraphHistoryData([]); return; } // Process history data - extract current_temperature const historyData = []; const stateHistory = historyResult[entityId] || []; for (const state of stateHistory) { // Handle both abbreviated format (a, lu) and full format (attributes, last_updated) const attributes = state.a || state.attributes; const lastUpdated = state.lu || state.last_updated; if (!attributes) continue; const temp = parseFloat(attributes.current_temperature); if (!isNaN(temp)) { // Parse last_updated - could be ISO string, Unix timestamp, or Unix timestamp in milliseconds let stateTime; if (typeof lastUpdated === 'string') { stateTime = new Date(lastUpdated); } else if (typeof lastUpdated === 'number') { // Check if it's in seconds or milliseconds stateTime = lastUpdated > 10000000000 ? new Date(lastUpdated) : new Date(lastUpdated * 1000); } else { continue; // Skip if we can't parse the time } const hours = stateTime.getHours().toString().padStart(2, '0'); const minutes = stateTime.getMinutes().toString().padStart(2, '0'); const timeStr = `${hours}:${minutes}`; historyData.push({ time: timeStr, temp: temp }); } } setGraphHistoryData([historyData]); } catch (error) { console.error('Failed to load history data:', error); setGraphHistoryData([]); } } // Load history data for multiple entities (used for groups) async function loadGroupHistoryData(entityIds) { if (!entityIds || entityIds.length === 0) { setGraphHistoryData([]); return; } try { // Get start of today in server timezone const nowServer = getServerNow(serverTimeZone); const today = new Date(Date.UTC(nowServer.getFullYear(), nowServer.getMonth(), nowServer.getDate(), 0, 0, 0, 0)); // Get current time const now = new Date(); const allHistoryData = []; const defaultColors = ['#2196f3', '#4caf50', '#ff9800', '#e91e63', '#9c27b0', '#00bcd4', '#ffeb3b', '#795548']; // Load history for each entity for (let i = 0; i < entityIds.length; i++) { const entityId = entityIds[i]; const entity = climateEntities.find(e => e.entity_id === entityId); try { const historyResult = await haAPI.getHistory(entityId, today, now); if (historyResult && historyResult[entityId]) { const historyData = []; const stateHistory = historyResult[entityId] || []; for (const state of stateHistory) { // Handle both abbreviated format (a, lu) and full format (attributes, last_updated) const attributes = state.a || state.attributes; const lastUpdated = state.lu || state.last_updated; if (!attributes) continue; const temp = parseFloat(attributes.current_temperature); if (!isNaN(temp)) { // Parse last_updated - could be ISO string, Unix timestamp, or Unix timestamp in milliseconds let utcTime; if (typeof lastUpdated === 'string') { utcTime = new Date(lastUpdated); } else if (typeof lastUpdated === 'number') { // Check if it's in seconds or milliseconds utcTime = lastUpdated > 10000000000 ? new Date(lastUpdated) : new Date(lastUpdated * 1000); } else { continue; // Skip if we can't parse the time } // Convert to server timezone const stateTime = utcToServerDate(utcTime, serverTimeZone); const hours = stateTime.getHours().toString().padStart(2, '0'); const minutes = stateTime.getMinutes().toString().padStart(2, '0'); const timeStr = `${hours}:${minutes}`; historyData.push({ time: timeStr, temp: temp }); } } if (historyData.length > 0) { allHistoryData.push({ entityId: entityId, entityName: entity?.attributes?.friendly_name || entityId, data: historyData, color: defaultColors[i % defaultColors.length] }); } } } catch (error) { console.error(`Failed to load history for ${entityId}:`, error); } } setGraphHistoryData(allHistoryData); } catch (error) { console.error('Failed to load group history data:', error); setGraphHistoryData([]); } } // Save schedule (auto-save, no alerts) async function saveSchedule() { // Don't save if we're in the middle of loading a schedule if (isLoadingSchedule) { pendingSaveNeeded = true; return; } // If a save is already in progress, mark pending and return if (isSaveInProgress) { console.warn('[SAVE] Save in progress, marking pending save needed.'); pendingSaveNeeded = true; return; } // Clear any existing debounce timeout if (saveTimeout) { clearTimeout(saveTimeout); saveTimeout = null; } // Debounce: wait for changes to settle before saving saveTimeout = setTimeout(() => { saveTimeout = null; performSave(); }, SAVE_DEBOUNCE_MS); } // Perform the actual save operation (called after debounce delay) async function performSave() { const saveStartTime = performance.now(); // Set save in progress flag isSaveInProgress = true; // Check if we're editing a group schedule if (currentGroup) { try { const nodes = getGraphNodes(); const enabledToggle = getDocumentRoot().querySelector('#schedule-enabled'); const enabled = enabledToggle ? enabledToggle.checked : (allGroups[currentGroup]?.enabled !== false); // Check if we're editing a non-active profile const groupData = allGroups[currentGroup]; const activeProfile = groupData ? groupData.active_profile : null; const targetProfile = editingProfile && editingProfile !== activeProfile ? editingProfile : null; // Save to group schedule with day and mode const setGroupScheduleStart = performance.now(); await haAPI.setGroupSchedule(currentGroup, nodes, currentDay, currentScheduleMode, targetProfile || undefined); // Update local cache immediately with the saved data if (allGroups[currentGroup]) { if (!allGroups[currentGroup].schedules) { allGroups[currentGroup].schedules = {}; } allGroups[currentGroup].schedules[currentDay] = JSON.parse(JSON.stringify(nodes)); } // Update enabled state (do not fail schedule save if this secondary call fails) try { if (enabled) { await haAPI.enableGroup(currentGroup); } else { await haAPI.disableGroup(currentGroup); } } catch (enableError) { console.error('[SAVE] Schedule saved but failed to update enabled state:', { error: enableError, groupName: currentGroup, enabled }); showToast('Schedule saved, but failed to update enabled state.', 'warning'); } // Update local state if (allGroups[currentGroup]) { allGroups[currentGroup].enabled = enabled; } } catch (error) { console.error('[SAVE] Failed to auto-save group schedule:', { error, errorCode: error?.code, errorMessage: error?.message, translationKey: error?.translation_key, groupName: currentGroup, timeSinceSaveStart: (performance.now() - saveStartTime).toFixed(2) + 'ms' }); showToast('Failed to save schedule. Check browser console for details.', 'error'); } finally { isSaveInProgress = false; // If another save was requested while this one was in progress, trigger it now if (pendingSaveNeeded) { pendingSaveNeeded = false; // Call saveSchedule (not performSave) to go through debouncing again saveSchedule(); } } return; } // Note: Entity-only save path removed - all schedules (including single entities) // are now saved as groups via set_group_schedule above. isSaveInProgress = false; } // Handle graph changes - auto-save and sync if needed async function handleGraphChange(event, force = false) { // If event has detail.force, use that if (event && event.detail && event.detail.force !== undefined) { force = event.detail.force; } updateScheduledTemp(); const savePromise = saveSchedule(); await savePromise; return savePromise; // Check if we need to update thermostats immediately const now = new Date(); const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; const nodes = getGraphNodes(); // Find active node const sorted = [...nodes].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); const currentMinutes = timeToMinutes(currentTime); let activeNode = null; for (const node of sorted) { if (timeToMinutes(node.time) <= currentMinutes) { activeNode = node; } else { break; } } if (!activeNode && sorted.length > 0) { activeNode = sorted[sorted.length - 1]; } if (!activeNode) return; const scheduledTemp = activeNode.temp; // Handle group schedule updates if (currentGroup) { updateAllGroupMemberScheduledTemps(); // Update all thermostats in the group const groupData = allGroups[currentGroup]; if (groupData && groupData.entities) { for (const entityId of groupData.entities) { const entity = climateEntities.find(e => e.entity_id === entityId); if (!entity) continue; const currentTarget = entity.attributes.temperature; // If scheduled temp is different from current target, update immediately if (Math.abs(scheduledTemp - currentTarget) > 0.1) { try { await haAPI.callService('climate', 'set_temperature', { entity_id: entityId, temperature: scheduledTemp }); // Temperature updated } catch (error) { console.error(`Failed to update ${entityId}:`, error); } } // Apply HVAC mode if specified and entity supports it if (activeNode.hvac_mode && entity.attributes.hvac_modes && entity.attributes.hvac_modes.includes(activeNode.hvac_mode)) { const currentHvacMode = entity.state || entity.attributes.hvac_mode; if (force || currentHvacMode !== activeNode.hvac_mode) { // HVAC mode updated try { await haAPI.callService('climate', 'set_hvac_mode', { entity_id: entityId, hvac_mode: activeNode.hvac_mode }); } catch (error) { console.error(`Failed to set HVAC mode for ${entityId}:`, error); } } } // Apply fan mode if specified and entity supports it if (activeNode.fan_mode && entity.attributes.fan_modes && entity.attributes.fan_modes.includes(activeNode.fan_mode)) { if (force || entity.attributes.fan_mode !== activeNode.fan_mode) { // Fan mode updated try { await haAPI.callService('climate', 'set_fan_mode', { entity_id: entityId, fan_mode: activeNode.fan_mode }); } catch (error) { console.error(`Failed to set fan mode for ${entityId}:`, error); } } } // Apply swing mode if specified and entity supports it if (activeNode.swing_mode && entity.attributes.swing_modes && entity.attributes.swing_modes.includes(activeNode.swing_mode)) { if (force || entity.attributes.swing_mode !== activeNode.swing_mode) { // Swing mode updated try { await haAPI.callService('climate', 'set_swing_mode', { entity_id: entityId, swing_mode: activeNode.swing_mode }); } catch (error) { console.error(`Failed to set swing mode for ${entityId}:`, error); } } } // Apply preset mode if specified and entity supports it if (activeNode.preset_mode && entity.attributes.preset_modes && entity.attributes.preset_modes.includes(activeNode.preset_mode)) { if (force || entity.attributes.preset_mode !== activeNode.preset_mode) { // Preset mode updated try { await haAPI.callService('climate', 'set_preset_mode', { entity_id: entityId, preset_mode: activeNode.preset_mode }); } catch (error) { console.error(`Failed to set preset mode for ${entityId}:`, error); } } } } } return; } // Handle individual entity schedule updates // Get current entity const entity = climateEntities.find(e => e.entity_id === currentEntityId); if (!entity) return; const currentTarget = entity.attributes.temperature; // If scheduled temp is different from current target, update immediately if (Math.abs(scheduledTemp - currentTarget) > 0.1) { try { await haAPI.callService('climate', 'set_temperature', { entity_id: currentEntityId, temperature: scheduledTemp }); } catch (error) { console.error('Failed to update thermostat:', error); } } // Apply HVAC mode if specified if (activeNode.hvac_mode) { const currentHvacMode = entity.state || entity.attributes.hvac_mode; if (force || currentHvacMode !== activeNode.hvac_mode) { try { await haAPI.callService('climate', 'set_hvac_mode', { entity_id: currentEntityId, hvac_mode: activeNode.hvac_mode }); } catch (error) { console.error('Failed to set HVAC mode:', error); } } } // Apply fan mode if specified if (activeNode.fan_mode && (force || entity.attributes.fan_mode !== activeNode.fan_mode)) { try { await haAPI.callService('climate', 'set_fan_mode', { entity_id: currentEntityId, fan_mode: activeNode.fan_mode }); } catch (error) { console.error('Failed to set fan mode:', error); } } // Apply swing mode if specified if (activeNode.swing_mode && (force || entity.attributes.swing_mode !== activeNode.swing_mode)) { try { await haAPI.callService('climate', 'set_swing_mode', { entity_id: currentEntityId, swing_mode: activeNode.swing_mode }); } catch (error) { console.error('Failed to set swing mode:', error); } } // Apply preset mode if specified if (activeNode.preset_mode && (force || entity.attributes.preset_mode !== activeNode.preset_mode)) { try { await haAPI.callService('climate', 'set_preset_mode', { entity_id: currentEntityId, preset_mode: activeNode.preset_mode }); } catch (error) { console.error('Failed to set preset mode:', error); } } } // Handle keyframe-timeline changes (canvas graph) function handleKeyframeTimelineChange(event) { if (isLoadingSchedule) return; const timeline = event.currentTarget; const keyframes = timeline.keyframes || []; // Convert keyframes to schedule nodes const nodes = keyframesToScheduleNodes(keyframes); currentSchedule = nodes; // Trigger auto-save with debouncing if (saveTimeout) { clearTimeout(saveTimeout); } saveTimeout = setTimeout(() => { saveSchedule(); }, SAVE_DEBOUNCE_MS); } // Update scheduled temperature display function updateScheduledTemp() { const scheduledTempEl = getDocumentRoot().querySelector('#scheduled-temp'); // Element may not exist if entity card is collapsed if (!scheduledTempEl) return; const now = new Date(); const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; const nodes = getGraphNodes(); if (nodes.length > 0) { const temp = interpolateTemperature(nodes, currentTime); scheduledTempEl.textContent = (Number.isFinite(temp)) ? `${temp.toFixed(1)}${temperatureUnit}` : 'No Change'; } else { scheduledTempEl.textContent = '--'; } } // Time and temperature interpolation functions are now in utils.js // Update entity status display function updateEntityStatus(entity) { if (!entity) return; const currentTempEl = getDocumentRoot().querySelector('#current-temp'); const targetTempEl = getDocumentRoot().querySelector('#target-temp'); // Elements may not exist if entity card is collapsed if (!currentTempEl || !targetTempEl) return; const currentTemp = entity.attributes.current_temperature; const targetTemp = entity.attributes.temperature; currentTempEl.textContent = (Number.isFinite(currentTemp)) ? `${currentTemp.toFixed(1)}${temperatureUnit}` : '--'; targetTempEl.textContent = (Number.isFinite(targetTemp)) ? `${targetTemp.toFixed(1)}${temperatureUnit}` : '--'; // Update HVAC mode if available const hvacModeEl = getDocumentRoot().querySelector('#current-hvac-mode'); const hvacModeItem = getDocumentRoot().querySelector('#current-hvac-mode-item'); if (hvacModeEl && hvacModeItem) { // HVAC mode is in entity.state for climate entities, not attributes const hvacMode = entity.state || entity.attributes.hvac_mode; if (hvacMode && hvacMode !== 'unknown' && hvacMode !== 'unavailable') { hvacModeEl.textContent = hvacMode.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); hvacModeItem.style.display = ''; } else { hvacModeItem.style.display = 'none'; } } // Update fan mode if available const fanModeEl = getDocumentRoot().querySelector('#current-fan-mode'); const fanModeItem = getDocumentRoot().querySelector('#current-fan-mode-item'); if (fanModeEl && fanModeItem) { if (entity.attributes.fan_mode) { fanModeEl.textContent = entity.attributes.fan_mode.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); fanModeItem.style.display = ''; } else { fanModeItem.style.display = 'none'; } } // Update swing mode if available const swingModeEl = getDocumentRoot().querySelector('#current-swing-mode'); const swingModeItem = getDocumentRoot().querySelector('#current-swing-mode-item'); if (swingModeEl && swingModeItem) { if (entity.attributes.swing_mode) { swingModeEl.textContent = entity.attributes.swing_mode.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); swingModeItem.style.display = ''; } else { swingModeItem.style.display = 'none'; } } // Update preset mode if available const presetModeEl = getDocumentRoot().querySelector('#current-preset-mode'); const presetModeItem = getDocumentRoot().querySelector('#current-preset-mode-item'); if (presetModeEl && presetModeItem) { if (entity.attributes.preset_mode) { presetModeEl.textContent = entity.attributes.preset_mode.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); presetModeItem.style.display = ''; } else { presetModeItem.style.display = 'none'; } } } // Handle state updates from Home Assistant function handleStateUpdate(data) { const entityId = data.entity_id; const newState = data.new_state; if (!entityId || !newState || !entityId.startsWith('climate.')) return; // Update entity in list const entityIndex = climateEntities.findIndex(e => e.entity_id === entityId); if (entityIndex !== -1) { climateEntities[entityIndex] = newState; // Don't re-render entire list, just update the card if visible updateEntityCard(entityId, newState); } // Update current entity status if selected if (entityId === currentEntityId) { updateEntityStatus(newState); } // Update group members table if showing group and entity is in the group if (currentGroup) { const groupData = allGroups[currentGroup]; if (groupData && groupData.entities && groupData.entities.includes(entityId)) { updateGroupMemberRow(entityId, newState); } } } // Handle log entries from backend // Update a single entity card without re-rendering the entire list function updateEntityCard(entityId, entityState) { // Find the card in either the active or ignored entities list const card = getDocumentRoot().querySelector(`.entity-card[data-entity-id="${entityId}"]`); if (!card) return; // Update current temperature const currentTempEl = card.querySelector('.current-temp'); if (currentTempEl) { const ct = entityState.attributes.current_temperature; currentTempEl.textContent = Number.isFinite(ct) ? `${ct.toFixed(1)}${temperatureUnit}` : '--'; } // Update target temperature const targetTempEl = card.querySelector('.target-temp'); if (targetTempEl) { const tt = entityState.attributes.temperature; targetTempEl.textContent = Number.isFinite(tt) ? `${tt.toFixed(1)}${temperatureUnit}` : '--'; } } // Update a single row in the group members table function updateGroupMemberRow(entityId, entityState) { const row = getDocumentRoot().querySelector(`.group-members-row[data-entity-id="${entityId}"]`); if (!row) return; const currentCell = row.children[1]; const targetCell = row.children[2]; const scheduledCell = row.children[3]; if (currentCell) { const currentTemp = entityState.attributes?.current_temperature; currentCell.textContent = (Number.isFinite(currentTemp)) ? `${currentTemp.toFixed(1)}${temperatureUnit}` : '--'; } if (targetCell) { const targetTemp = entityState.attributes?.temperature; targetCell.textContent = (Number.isFinite(targetTemp)) ? `${targetTemp.toFixed(1)}${temperatureUnit}` : '--'; } // Update scheduled temp if we're viewing a group if (scheduledCell && currentGroup && graph) { const now = new Date(); const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; const nodes = getGraphNodes(); if (nodes.length > 0) { const scheduledTemp = interpolateTemperature(nodes, currentTime); scheduledCell.textContent = (Number.isFinite(scheduledTemp)) ? `${scheduledTemp.toFixed(1)}${temperatureUnit}` : 'No Change'; } else { scheduledCell.textContent = '--'; } } } // Update all rows in the group members table with current scheduled temperature function updateAllGroupMemberScheduledTemps() { if (!currentGroup || !graph) return; const rows = getDocumentRoot().querySelectorAll('.group-members-row'); if (rows.length === 0) return; const now = new Date(); const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; const nodes = getGraphNodes(); rows.forEach(row => { const scheduledCell = row.children[3]; if (scheduledCell) { if (nodes.length > 0) { const scheduledTemp = interpolateTemperature(nodes, currentTime); scheduledCell.textContent = (Number.isFinite(scheduledTemp)) ? `${scheduledTemp.toFixed(1)}${temperatureUnit}` : 'No Change'; } else { scheduledCell.textContent = '--'; } } }); } // Toggle entity inclusion in scheduler async function toggleEntityInclusion(entityId, include) { try { if (include) { // Check if entity already has a schedule in backend let existingSchedule = null; try { const result = await haAPI.getSchedule(entityId); const schedule = result?.response || result; if (schedule && schedule.nodes && schedule.nodes.length > 0) { existingSchedule = schedule.nodes; } } catch (err) { } // Use existing schedule or create default const scheduleToUse = existingSchedule || []; // Add to local state immediately with a unique copy for each entity entitySchedules.set(entityId, JSON.parse(JSON.stringify(scheduleToUse))); // If no existing schedule, persist the default to HA with current day if (!existingSchedule) { const now = new Date(); const weekday = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][now.getDay()]; haAPI.setSchedule(entityId, scheduleToUse, 'all_days', 'all_days').catch(err => { console.error('Failed to persist schedule to HA:', err); }); } // Always enable the schedule (whether it existed or not) haAPI.enableSchedule(entityId).catch(err => { console.error('Failed to enable schedule in HA:', err); }); // Re-render to move to active list await renderEntityList(); } else { // When disabling, just disable it but keep the schedule data entitySchedules.delete(entityId); // Disable the schedule in HA (but don't clear the data) haAPI.disableSchedule(entityId).catch(err => { console.error('Failed to disable schedule in HA:', err); }); // If it was selected, deselect it if (currentEntityId === entityId) { currentEntityId = null; const editorEl = getDocumentRoot().querySelector('#schedule-editor'); if (editorEl) { editorEl.style.display = 'none'; } } // Re-render to move to disabled list await renderEntityList(); } } catch (error) { console.error('Failed to toggle entity inclusion:', error); } } // Set up event listeners function setupEventListeners() { // Schedule mode radio buttons const modeRadios = getDocumentRoot().querySelectorAll('input[name="schedule-mode"]'); modeRadios.forEach(radio => { radio.addEventListener('change', async (e) => { if (e.target.checked) { const newMode = e.target.value; await switchScheduleMode(newMode); } }); }); // Day selector buttons (for individual mode) const dayButtons = getDocumentRoot().querySelectorAll('.day-btn'); dayButtons.forEach(btn => { btn.addEventListener('click', async () => { const day = btn.dataset.day; await switchDay(day); }); }); // Weekday selector buttons (for 5/2 mode) const weekdayButtons = getDocumentRoot().querySelectorAll('.weekday-btn'); weekdayButtons.forEach(btn => { btn.addEventListener('click', async () => { const day = btn.dataset.day; await switchDay(day); }); }); // Menu button and dropdown const menuButton = getDocumentRoot().querySelector('#menu-button'); const dropdownMenu = getDocumentRoot().querySelector('#dropdown-menu'); if (menuButton && dropdownMenu) { menuButton.addEventListener('click', (e) => { e.stopPropagation(); dropdownMenu.style.display = dropdownMenu.style.display === 'none' ? 'block' : 'none'; }); // Close menu when clicking outside document.addEventListener('click', (e) => { if (!menuButton.contains(e.target) && !dropdownMenu.contains(e.target)) { dropdownMenu.style.display = 'none'; } }); } // Menu items const refreshEntitiesMenu = getDocumentRoot().querySelector('#refresh-entities-menu'); if (refreshEntitiesMenu) { refreshEntitiesMenu.addEventListener('click', () => { if (dropdownMenu) dropdownMenu.style.display = 'none'; loadClimateEntities(); }); } const syncAllMenu = getDocumentRoot().querySelector('#sync-all-menu'); if (syncAllMenu) { syncAllMenu.addEventListener('click', () => { if (dropdownMenu) dropdownMenu.style.display = 'none'; syncAllTemperatures(); }); } const reloadIntegrationMenu = getDocumentRoot().querySelector('#reload-integration-menu'); if (reloadIntegrationMenu) { reloadIntegrationMenu.addEventListener('click', async () => { if (dropdownMenu) dropdownMenu.style.display = 'none'; try { await haAPI.callService('climate_scheduler', 'reload_integration', {}); showToast('Integration reloaded successfully', 'success'); } catch (error) { console.error('Failed to reload integration:', error); showToast('Failed to reload integration: ' + error.message, 'error'); } }); } // Toggle ignored entities section const toggleIgnored = getDocumentRoot().querySelector('#toggle-ignored'); const ignoredList = getDocumentRoot().querySelector('#ignored-entity-list'); const ignoredContainer = getDocumentRoot().querySelector('#ignored-container'); if (toggleIgnored && ignoredList && ignoredContainer) { toggleIgnored.addEventListener('click', () => { const toggleIcon = toggleIgnored.querySelector('.group-toggle-icon'); if (ignoredList.style.display === 'none') { ignoredList.style.display = 'flex'; ignoredContainer.classList.remove('collapsed'); ignoredContainer.classList.add('expanded'); if (toggleIcon) toggleIcon.style.transform = 'rotate(0deg)'; } else { ignoredList.style.display = 'none'; ignoredContainer.classList.remove('expanded'); ignoredContainer.classList.add('collapsed'); if (toggleIcon) toggleIcon.style.transform = 'rotate(-90deg)'; } }); } // Toggle global profile editor section const toggleGlobalProfiles = getDocumentRoot().querySelector('#toggle-global-profiles'); const globalProfileList = getDocumentRoot().querySelector('#global-profile-list'); const globalProfileContainer = getDocumentRoot().querySelector('#global-profile-container'); if (toggleGlobalProfiles && globalProfileList && globalProfileContainer) { toggleGlobalProfiles.addEventListener('click', async () => { const toggleIcon = toggleGlobalProfiles.querySelector('.group-toggle-icon'); if (globalProfileList.style.display === 'none') { globalProfileList.style.display = 'block'; globalProfileContainer.classList.remove('collapsed'); globalProfileContainer.classList.add('expanded'); if (toggleIcon) toggleIcon.style.transform = 'rotate(0deg)'; await refreshGlobalProfileEditor(); } else { globalProfileList.style.display = 'none'; globalProfileContainer.classList.remove('expanded'); globalProfileContainer.classList.add('collapsed'); if (toggleIcon) toggleIcon.style.transform = 'rotate(-90deg)'; } }); } // Filter ignored entities const ignoredFilter = getDocumentRoot().querySelector('#ignored-filter'); if (ignoredFilter) { ignoredFilter.addEventListener('input', () => { renderEntityList(); }); } // NOTE: The following elements are now dynamically created in inline editors // and have their event listeners attached in attachEditorEventListeners(): // - #clear-schedule-btn // - #schedule-enabled // - #close-settings // - #delete-node // - #save-node-settings // Group management event listeners // Create group button const createGroupBtn = getDocumentRoot().querySelector('#create-group-btn'); if (createGroupBtn) { createGroupBtn.addEventListener('click', () => { const modal = getDocumentRoot().querySelector('#create-group-modal'); if (modal) { modal.style.display = 'flex'; } const nameInput = getDocumentRoot().querySelector('#new-group-name'); if (nameInput) { nameInput.value = ''; } }); } // Create group modal - cancel const createGroupCancel = getDocumentRoot().querySelector('#create-group-cancel'); if (createGroupCancel) { createGroupCancel.addEventListener('click', () => { const modal = getDocumentRoot().querySelector('#create-group-modal'); if (modal) modal.style.display = 'none'; }); } // Create group modal - confirm const createGroupConfirm = getDocumentRoot().querySelector('#create-group-confirm'); if (createGroupConfirm) { createGroupConfirm.addEventListener('click', async () => { const groupName = getDocumentRoot().querySelector('#new-group-name').value.trim(); if (!groupName) { alert('Please enter a group name'); return; } if (allGroups[groupName]) { alert('A group with this name already exists'); return; } try { await haAPI.createGroup(groupName); // Close modal const modal = getDocumentRoot().querySelector('#create-group-modal'); if (modal) modal.style.display = 'none'; // Reload groups await loadGroups(); } catch (error) { console.error('Failed to create group:', error); alert('Failed to create group'); } }); } // Add to group modal - cancel const addToGroupCancel = getDocumentRoot().querySelector('#add-to-group-cancel'); if (addToGroupCancel) { addToGroupCancel.addEventListener('click', () => { const modal = getDocumentRoot().querySelector('#add-to-group-modal'); if (modal) { modal.style.display = 'none'; delete modal.dataset.currentGroup; delete modal.dataset.isMove; } }); } // Add to group modal - confirm const addToGroupConfirm = getDocumentRoot().querySelector('#add-to-group-confirm'); if (addToGroupConfirm) { addToGroupConfirm.addEventListener('click', async () => { const modal = getDocumentRoot().querySelector('#add-to-group-modal'); const entityId = modal ? modal.dataset.entityId : null; const currentGroupName = modal ? modal.dataset.currentGroup : null; const isMove = modal ? modal.dataset.isMove === 'true' : false; const isUnmonitoredAdd = modal ? modal.dataset.isUnmonitoredAdd === 'true' : false; const selectElement = getDocumentRoot().querySelector('#add-to-group-select'); const newGroupInput = getDocumentRoot().querySelector('#new-group-name-inline'); let groupName = selectElement ? selectElement.value : null; const newGroupName = newGroupInput ? newGroupInput.value.trim() : ''; // Check if user wants to create a new group if (newGroupName) { if (allGroups[newGroupName]) { alert('A group with this name already exists. Please select it from the dropdown or use a different name.'); return; } groupName = newGroupName; try { // Create the new group first await haAPI.createGroup(groupName); } catch (error) { console.error('Failed to create group:', error); alert('Failed to create group'); return; } } if (!groupName) { alert('Please select or create a group'); return; } try { // If moving, remove from current group first if (isMove && currentGroupName) { await haAPI.removeFromGroup(currentGroupName, entityId); } // Add to new group // Note: This works for both monitored and unmonitored entities // - For unmonitored entities, it adds them directly to the group // - For monitored entities in other groups, it moves them await haAPI.addToGroup(groupName, entityId); // Close modal and clear move state if (modal) { modal.style.display = 'none'; delete modal.dataset.currentGroup; delete modal.dataset.isMove; delete modal.dataset.isUnmonitoredAdd; } // Close any open editors to prevent stale data collapseAllEditors(); // Reload groups await loadGroups(); // Reload entity list (entity should disappear from active/disabled) await renderEntityList(); // Show appropriate message if (isMove && currentGroupName) { showToast(`Moved entity from ${currentGroupName} to ${groupName}`, 'success'); } else if (isUnmonitoredAdd) { showToast(`Added entity to ${groupName}`, 'success'); } else { showToast(`Added entity to ${groupName}`, 'success'); } } catch (error) { console.error('Failed to add entity to group:', error); alert('Failed to add entity to group'); } }); } // Close modals when clicking outside const createGroupModal = getDocumentRoot().querySelector('#create-group-modal'); if (createGroupModal) { createGroupModal.addEventListener('click', (e) => { if (e.target.id === 'create-group-modal') { createGroupModal.style.display = 'none'; } }); } const addToGroupModal = getDocumentRoot().querySelector('#add-to-group-modal'); if (addToGroupModal) { addToGroupModal.addEventListener('click', (e) => { if (e.target.id === 'add-to-group-modal') { addToGroupModal.style.display = 'none'; } }); } // Convert temperature button const convertTempBtn = getDocumentRoot().querySelector('#convert-temperature-btn'); if (convertTempBtn) { convertTempBtn.addEventListener('click', () => { const modal = getDocumentRoot().querySelector('#convert-temperature-modal'); if (modal) { // Determine the likely current unit from stored settings const likelyCurrentUnit = storedTemperatureUnit || temperatureUnit || '°C'; // Pre-select FROM unit (current) const fromCelsiusRadio = getDocumentRoot().querySelector('#convert-from-celsius'); const fromFahrenheitRadio = getDocumentRoot().querySelector('#convert-from-fahrenheit'); if (likelyCurrentUnit === '°C' && fromCelsiusRadio) { fromCelsiusRadio.checked = true; } else if (likelyCurrentUnit === '°F' && fromFahrenheitRadio) { fromFahrenheitRadio.checked = true; } else if (fromCelsiusRadio) { fromCelsiusRadio.checked = true; } // Pre-select TO unit (opposite of current) const toCelsiusRadio = getDocumentRoot().querySelector('#convert-to-celsius'); const toFahrenheitRadio = getDocumentRoot().querySelector('#convert-to-fahrenheit'); if (likelyCurrentUnit === '°C' && toFahrenheitRadio) { toFahrenheitRadio.checked = true; } else if (likelyCurrentUnit === '°F' && toCelsiusRadio) { toCelsiusRadio.checked = true; } else if (toCelsiusRadio) { toCelsiusRadio.checked = true; } modal.style.display = 'flex'; } }); } // Convert temperature modal - cancel const convertTempCancel = getDocumentRoot().querySelector('#convert-temperature-cancel'); if (convertTempCancel) { convertTempCancel.addEventListener('click', () => { const modal = getDocumentRoot().querySelector('#convert-temperature-modal'); if (modal) modal.style.display = 'none'; }); } // Convert temperature modal - confirm const convertTempConfirm = getDocumentRoot().querySelector('#convert-temperature-confirm'); if (convertTempConfirm) { convertTempConfirm.addEventListener('click', async () => { const modal = getDocumentRoot().querySelector('#convert-temperature-modal'); // Get FROM unit const fromCelsiusRadio = getDocumentRoot().querySelector('#convert-from-celsius'); const fromFahrenheitRadio = getDocumentRoot().querySelector('#convert-from-fahrenheit'); let fromUnit = null; if (fromCelsiusRadio && fromCelsiusRadio.checked) { fromUnit = '°C'; } else if (fromFahrenheitRadio && fromFahrenheitRadio.checked) { fromUnit = '°F'; } // Get TO unit const toCelsiusRadio = getDocumentRoot().querySelector('#convert-to-celsius'); const toFahrenheitRadio = getDocumentRoot().querySelector('#convert-to-fahrenheit'); let targetUnit = null; if (toCelsiusRadio && toCelsiusRadio.checked) { targetUnit = '°C'; } else if (toFahrenheitRadio && toFahrenheitRadio.checked) { targetUnit = '°F'; } if (!fromUnit) { showToast('Please select the current temperature unit (FROM)', 'warning'); return; } if (!targetUnit) { showToast('Please select the target temperature unit (TO)', 'warning'); return; } if (fromUnit === targetUnit) { showToast(`Cannot convert from ${fromUnit} to ${targetUnit} - they are the same unit`, 'warning'); return; } try { // Show loading indicator if (convertTempConfirm) { convertTempConfirm.disabled = true; convertTempConfirm.textContent = 'Converting...'; } // Get current settings const settings = await haAPI.getSettings(); // Convert all schedules using user-selected units await convertAllSchedules(fromUnit, targetUnit); // Convert min/max settings if (settings.min_temp !== undefined) { settings.min_temp = convertTemperature(settings.min_temp, fromUnit, targetUnit); } if (settings.max_temp !== undefined) { settings.max_temp = convertTemperature(settings.max_temp, fromUnit, targetUnit); } // Convert default schedule if (settings.defaultSchedule) { settings.defaultSchedule = convertScheduleNodes(settings.defaultSchedule, fromUnit, targetUnit); } // Update temperature unit settings.temperature_unit = targetUnit; temperatureUnit = targetUnit; storedTemperatureUnit = targetUnit; // Save settings await haAPI.saveSettings(settings); // Close modal before reload if (modal) modal.style.display = 'none'; // Show success toast briefly before reload showToast(`Successfully converted all schedules to ${targetUnit}. Reloading...`, 'success', 2000); // Reload the page after a brief delay to show the toast setTimeout(() => { window.location.reload(); }, 2000); } catch (error) { console.error('Failed to convert schedules:', error); showToast('Failed to convert schedules: ' + error.message, 'error'); } finally { // Restore button state if (convertTempConfirm) { convertTempConfirm.disabled = false; convertTempConfirm.textContent = 'Convert Schedules'; } } }); } // Close convert temperature modal when clicking outside const convertTempModal = getDocumentRoot().querySelector('#convert-temperature-modal'); if (convertTempModal) { convertTempModal.addEventListener('click', (e) => { if (e.target.id === 'convert-temperature-modal') { convertTempModal.style.display = 'none'; } }); } // Edit group modal handlers // Edit group - cancel const editGroupCancel = getDocumentRoot().querySelector('#edit-group-cancel'); if (editGroupCancel) { editGroupCancel.addEventListener('click', () => { const modal = getDocumentRoot().querySelector('#edit-group-modal'); if (modal) { modal.style.display = 'none'; delete modal.dataset.groupName; } }); } // Edit group - save (rename) const editGroupSave = getDocumentRoot().querySelector('#edit-group-save'); if (editGroupSave) { editGroupSave.addEventListener('click', async () => { const modal = getDocumentRoot().querySelector('#edit-group-modal'); const input = getDocumentRoot().querySelector('#edit-group-name'); const currentGroupName = modal?.dataset.groupName; const newName = input?.value.trim(); if (newName && newName !== '' && newName !== currentGroupName) { try { await haAPI.renameGroup(currentGroupName, newName); showToast(`Renamed group to: ${newName}`, 'success'); await loadGroups(); if (modal) { modal.style.display = 'none'; delete modal.dataset.groupName; } } catch (error) { console.error('Failed to rename group:', error); showToast('Failed to rename group: ' + error.message, 'error'); } } else if (newName === currentGroupName) { // Name unchanged, just close if (modal) { modal.style.display = 'none'; delete modal.dataset.groupName; } } }); } // Edit group - delete const editGroupDelete = getDocumentRoot().querySelector('#edit-group-delete'); if (editGroupDelete) { editGroupDelete.addEventListener('click', async () => { const modal = getDocumentRoot().querySelector('#edit-group-modal'); const groupName = modal?.dataset.groupName; if (!groupName) { console.error('No group name found in modal'); return; } if (confirm(`Delete group "${groupName}"? All entities will be moved back to the entity list.`)) { try { await haAPI.deleteGroup(groupName); showToast(`Deleted group: ${groupName}`, 'success'); await loadGroups(); await renderEntityList(); if (modal) { modal.style.display = 'none'; delete modal.dataset.groupName; } } catch (error) { console.error('Failed to delete group:', error); showToast('Failed to delete group: ' + error.message, 'error'); } } }); } // Close edit group modal when clicking outside const editGroupModal = getDocumentRoot().querySelector('#edit-group-modal'); if (editGroupModal) { editGroupModal.addEventListener('click', (e) => { if (e.target.id === 'edit-group-modal') { editGroupModal.style.display = 'none'; delete editGroupModal.dataset.groupName; } }); } // Edit group input - handle Enter key const editGroupInput = getDocumentRoot().querySelector('#edit-group-name'); if (editGroupInput) { editGroupInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const saveBtn = getDocumentRoot().querySelector('#edit-group-save'); if (saveBtn) saveBtn.click(); } else if (e.key === 'Escape') { const modal = getDocumentRoot().querySelector('#edit-group-modal'); if (modal) { modal.style.display = 'none'; delete modal.dataset.groupName; } } }); } // Initialize settings panel setupSettingsPanel(); } // Handle node settings panel async function handleNodeSettings(event) { const { nodeIndex, node, timeline } = event.detail; const timelineRef = timeline || graph; if (!timelineRef) return; graph = timelineRef; nodeSettingsTimeline = timelineRef; // Load the climate dialog component if not already loaded await loadClimateDialog(); const keyframe = timelineRef.keyframes[nodeIndex]; if (!keyframe) return; // Find the schedule node const scheduleNode = currentSchedule.find(n => n.time === node.time); if (!scheduleNode) return; // Get the first entity to determine available features const entityIds = currentGroup ? allGroups[currentGroup].entities : [currentEntityId]; const firstEntityId = entityIds[0]; const entity = climateEntities.find(e => e.entity_id === firstEntityId); if (!entity) return; // Create friendly display name let displayName; if (currentGroup) { displayName = currentGroup; } else { displayName = entity.attributes.friendly_name || entity.entity_id; } // Create climate state object const climateState = { entity_id: displayName, state: scheduleNode.hvac_mode ?? '', attributes: { supported_features: entity.attributes.supported_features, hvac_modes: entity.attributes.hvac_modes || ['heat', 'cool', 'heat_cool', 'off'], temperature: scheduleNode.temp, target_temp_low: scheduleNode.target_temp_low, target_temp_high: scheduleNode.target_temp_high, min_temp: entity.attributes.min_temp, max_temp: entity.attributes.max_temp, temperature_step: inputTempStep, humidity_step: humidityStep, noChange: Boolean(scheduleNode.noChange), fan_mode: scheduleNode.fan_mode ?? '', fan_modes: entity.attributes.fan_modes, preset_mode: scheduleNode.preset_mode ?? '', preset_modes: entity.attributes.preset_modes, swing_mode: scheduleNode.swing_mode ?? '', swing_modes: entity.attributes.swing_modes, swing_horizontal_mode: scheduleNode.swing_horizontal_mode ?? '', swing_horizontal_modes: entity.attributes.swing_horizontal_modes, target_humidity: scheduleNode.target_humidity, min_humidity: entity.attributes.min_humidity, max_humidity: entity.attributes.max_humidity, aux_heat: scheduleNode.aux_heat } }; // Get or create the dialog element in the panel const container = getDocumentRoot().querySelector('#climate-dialog-container'); if (!container) return; container.innerHTML = ''; const dialogEl = document.createElement('climate-control-dialog'); dialogEl.stateObj = climateState; dialogEl.dataset.nodeIndex = nodeIndex; let dialogUndoCaptured = false; const dialogNodeLabel = `${node.time} (index ${nodeIndex})`; debugLog(`[Dialog][State] open node=${dialogNodeLabel} undoCaptured=${dialogUndoCaptured} rev=undo-debug-v2`); const logDialogChange = (name, payload) => { try { debugLog(`[Dialog][Change] ${name} node=${dialogNodeLabel} payload=${JSON.stringify(payload)}`); } catch (error) { debugLog(`[Dialog][Change] ${name} node=${dialogNodeLabel} payload=`, 'warning'); } }; const captureDialogUndoState = ({ force = false, source = 'unknown' } = {}) => { if (dialogUndoCaptured && !force) { debugLog(`[Dialog][Undo] skip node=${dialogNodeLabel} reason=already-captured source=${source}`); return; } if (dialogUndoCaptured && force) { debugLog(`[Dialog][Undo] reset-before-capture node=${dialogNodeLabel} source=${source}`); dialogUndoCaptured = false; } const undoSizeBefore = Array.isArray(timelineRef.undoStack) ? timelineRef.undoStack.length : 'n/a'; if (typeof timelineRef.saveUndoState === 'function') { timelineRef.saveUndoState(); const undoSizeAfter = Array.isArray(timelineRef.undoStack) ? timelineRef.undoStack.length : 'n/a'; debugLog(`[Dialog][Undo] captured node=${dialogNodeLabel} stack=${undoSizeBefore}->${undoSizeAfter} source=${source}`); } else { debugLog(`[Dialog][Undo] saveUndoState unavailable for node=${dialogNodeLabel}`, 'warning'); } dialogUndoCaptured = true; }; const markDialogInteractionCommitted = () => { debugLog(`[Dialog][Commit] node=${dialogNodeLabel}`); dialogUndoCaptured = false; }; // Add event listeners for all the custom events dialogEl.addEventListener('hvac-mode-changed', (e) => { logDialogChange('hvac-mode-changed', e.detail); captureDialogUndoState({ force: true, source: 'hvac-mode-changed' }); if (e.detail.mode) { scheduleNode.hvac_mode = e.detail.mode; keyframe.hvac_mode = e.detail.mode; } else { delete scheduleNode.hvac_mode; keyframe.hvac_mode = null; } // Trigger Lit reactivity by reassigning the array timelineRef.keyframes = [...timelineRef.keyframes]; saveSchedule(); markDialogInteractionCommitted(); }); dialogEl.addEventListener('temperature-changed', (e) => { logDialogChange('temperature-changed', e.detail); captureDialogUndoState({ source: 'temperature-changed' }); const normalizedTemp = normalizeTemperature(e.detail.temperature); scheduleNode.temp = normalizedTemp; scheduleNode.noChange = false; keyframe.value = normalizedTemp; keyframe.noChange = false; // Trigger Lit reactivity by reassigning the array timelineRef.keyframes = [...timelineRef.keyframes]; }); dialogEl.addEventListener('target-temp-low-changed', (e) => { logDialogChange('target-temp-low-changed', e.detail); captureDialogUndoState({ source: 'target-temp-low-changed' }); const value = normalizeTemperature(e.detail.temperature); scheduleNode.target_temp_low = value; keyframe.target_temp_low = value; scheduleNode.noChange = false; keyframe.noChange = false; }); dialogEl.addEventListener('target-temp-high-changed', (e) => { logDialogChange('target-temp-high-changed', e.detail); captureDialogUndoState({ source: 'target-temp-high-changed' }); const value = normalizeTemperature(e.detail.temperature); scheduleNode.target_temp_high = value; keyframe.target_temp_high = value; scheduleNode.noChange = false; keyframe.noChange = false; }); dialogEl.addEventListener('no-temp-change-changed', (e) => { logDialogChange('no-temp-change-changed', e.detail); captureDialogUndoState({ force: true, source: 'no-temp-change-changed' }); const noTempChangeEnabled = Boolean(e.detail.enabled); scheduleNode.noChange = noTempChangeEnabled; keyframe.noChange = noTempChangeEnabled; if (noTempChangeEnabled) { const lockedTemp = resolveNoChangeLockedTemp(currentSchedule, scheduleNode); if (lockedTemp !== null) { scheduleNode.temp = lockedTemp; keyframe.value = lockedTemp; } } timelineRef.keyframes = [...timelineRef.keyframes]; saveSchedule(); markDialogInteractionCommitted(); }); dialogEl.addEventListener('humidity-changed', (e) => { logDialogChange('humidity-changed', e.detail); captureDialogUndoState({ source: 'humidity-changed' }); scheduleNode.target_humidity = e.detail.humidity; keyframe.target_humidity = e.detail.humidity; }); dialogEl.addEventListener('fan-mode-changed', (e) => { logDialogChange('fan-mode-changed', e.detail); captureDialogUndoState({ force: true, source: 'fan-mode-changed' }); if (e.detail.mode) { scheduleNode.fan_mode = e.detail.mode; keyframe.fan_mode = e.detail.mode; } else { delete scheduleNode.fan_mode; keyframe.fan_mode = null; } saveSchedule(); markDialogInteractionCommitted(); }); dialogEl.addEventListener('preset-mode-changed', (e) => { logDialogChange('preset-mode-changed', e.detail); captureDialogUndoState({ force: true, source: 'preset-mode-changed' }); if (e.detail.mode) { scheduleNode.preset_mode = e.detail.mode; keyframe.preset_mode = e.detail.mode; } else { delete scheduleNode.preset_mode; keyframe.preset_mode = null; } saveSchedule(); markDialogInteractionCommitted(); }); dialogEl.addEventListener('swing-mode-changed', (e) => { logDialogChange('swing-mode-changed', e.detail); captureDialogUndoState({ force: true, source: 'swing-mode-changed' }); if (e.detail.mode) { scheduleNode.swing_mode = e.detail.mode; keyframe.swing_mode = e.detail.mode; } else { delete scheduleNode.swing_mode; keyframe.swing_mode = null; } saveSchedule(); markDialogInteractionCommitted(); }); dialogEl.addEventListener('swing-horizontal-mode-changed', (e) => { logDialogChange('swing-horizontal-mode-changed', e.detail); captureDialogUndoState({ force: true, source: 'swing-horizontal-mode-changed' }); if (e.detail.mode) { scheduleNode.swing_horizontal_mode = e.detail.mode; keyframe.swing_horizontal_mode = e.detail.mode; } else { delete scheduleNode.swing_horizontal_mode; keyframe.swing_horizontal_mode = null; } saveSchedule(); markDialogInteractionCommitted(); }); dialogEl.addEventListener('aux-heat-changed', (e) => { logDialogChange('aux-heat-changed', e.detail); captureDialogUndoState({ force: true, source: 'aux-heat-changed' }); const value = e.detail.enabled ? 'on' : 'off'; scheduleNode.aux_heat = value; keyframe.aux_heat = value; saveSchedule(); markDialogInteractionCommitted(); }); const handleDialogSliderCommit = () => { saveSchedule(); markDialogInteractionCommitted(); }; dialogEl.addEventListener('temperature-change-committed', (e) => logDialogChange('temperature-change-committed', e.detail)); dialogEl.addEventListener('target-temp-low-committed', (e) => logDialogChange('target-temp-low-committed', e.detail)); dialogEl.addEventListener('target-temp-high-committed', (e) => logDialogChange('target-temp-high-committed', e.detail)); dialogEl.addEventListener('humidity-committed', (e) => logDialogChange('humidity-committed', e.detail)); dialogEl.addEventListener('temperature-change-committed', handleDialogSliderCommit); dialogEl.addEventListener('target-temp-low-committed', handleDialogSliderCommit); dialogEl.addEventListener('target-temp-high-committed', handleDialogSliderCommit); dialogEl.addEventListener('humidity-committed', handleDialogSliderCommit); container.appendChild(dialogEl); // Populate and update time dropdown with 15-minute increments const timeInput = getDocumentRoot().querySelector('#node-time-input'); if (timeInput) { // Clear existing options timeInput.innerHTML = ''; // Get all existing times in the schedule (excluding current node) const existingTimes = new Set(currentSchedule.map(n => n.time)); // Generate all 15-minute increment times (00:00, 00:15, 00:30, etc.) for (let hour = 0; hour < 24; hour++) { for (let minute = 0; minute < 60; minute += 15) { const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; const option = document.createElement('option'); option.value = timeStr; option.textContent = timeStr; // Check if this time is already used by a different node const isCurrentNodeTime = timeStr === node.time; const isAlreadyUsed = existingTimes.has(timeStr) && !isCurrentNodeTime; if (isAlreadyUsed) { option.disabled = true; option.textContent = `${timeStr} (in use)`; } if (timeStr === node.time) { option.selected = true; } timeInput.appendChild(option); } } // Add 23:59 as final option const lastOption = document.createElement('option'); lastOption.value = '23:59'; const isCurrentNodeTime = node.time === '23:59'; const isAlreadyUsed = existingTimes.has('23:59') && !isCurrentNodeTime; if (isAlreadyUsed) { lastOption.disabled = true; lastOption.textContent = '23:59 (in use)'; } else { lastOption.textContent = '23:59'; } if (node.time === '23:59') { lastOption.selected = true; } timeInput.appendChild(lastOption); } // Show panel const panel = getDocumentRoot().querySelector('#node-settings-panel'); panel.style.display = 'block'; panel.dataset.nodeIndex = nodeIndex; // Scroll to panel panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // Sync all thermostats to scheduled temperatures async function syncAllTemperatures() { try { const button = getDocumentRoot().querySelector('#sync-all-menu'); button.disabled = true; button.textContent = '⟲ Syncing...'; await haAPI.callService('climate_scheduler', 'sync_all', {}); button.textContent = '✓ Synced!'; setTimeout(() => { button.textContent = '⟲ Sync All Thermostats'; button.disabled = false; }, 2000); } catch (error) { console.error('Failed to sync temperatures:', error); alert('Failed to sync temperatures: ' + error.message); const button = getDocumentRoot().querySelector('#sync-all-menu'); if (button) { button.textContent = '⟲ Sync All Thermostats'; button.disabled = false; } } } // Update scheduled temp every minute setInterval(() => { updateScheduledTemp(); updateAllGroupMemberScheduledTemps(); }, 60000); // Handle profile change events from backend async function handleProfileChanged(event) { const { schedule_id: groupName, profile_name: newProfile } = event.data; if (!groupName || !newProfile) return; // Reload group data from server to get the new profile's schedules const groupsResult = await haAPI.getGroups(); allGroups = normalizeGroupsPayload(groupsResult); // Update the dropdown in the group header const groupContainer = getDocumentRoot().querySelector(`.group-container[data-group-name="${groupName}"]`); if (groupContainer) { const profileDropdown = groupContainer.querySelector('.group-profile-dropdown'); if (profileDropdown) { profileDropdown.value = newProfile; } } // If this group is currently being edited, reload it to show the new profile's schedule if (currentGroup === groupName) { await editGroupSchedule(groupName); } await refreshGlobalProfileEditor(); } // ===== Settings Panel ===== // Default schedule settings let defaultScheduleSettings = []; let defaultScheduleGraph = null; // Initialize default schedule graph (called when settings panel is expanded) async function initializeDefaultScheduleGraph() { // Load keyframe-timeline component const canvasLoaded = await loadKeyframeTimeline(); if (!canvasLoaded) { console.error('Failed to load keyframe-timeline component for default schedule'); return; } // Initialize the default schedule graph (canvas timeline) const container = getDocumentRoot().querySelector('#default-schedule-graph')?.parentElement; if (container) { // Remove existing element if present const existingGraph = getDocumentRoot().querySelector('#default-schedule-graph'); if (existingGraph) { existingGraph.remove(); } // Create timeline editor using reusable function const editorInstance = createTimelineEditor({ idPrefix: 'default', buttons: [ { id: 'graph-prev-btn', text: '◀', title: 'Previous keyframe' }, { id: 'graph-next-btn', text: '▶', title: 'Next keyframe' }, { id: 'graph-copy-btn', text: 'Copy', title: 'Copy schedule' }, { id: 'graph-paste-btn', text: 'Paste', title: 'Paste schedule', disabled: true }, { id: 'graph-undo-btn', text: 'Undo', title: 'Undo last change' }, { id: 'graph-clear-btn', text: 'Clear', title: 'Clear all nodes' } ], minValue: minTempSetting !== null ? minTempSetting : (temperatureUnit === '°F' ? 41 : 5), maxValue: maxTempSetting !== null ? maxTempSetting : (temperatureUnit === '°F' ? 86 : 30), snapValue: graphSnapStep, title: '', yAxisLabel: `Temperature (${temperatureUnit})`, xAxisLabel: `Time of Day (24hr)`, showCurrentTime: false, tooltipMode: tooltipMode, showHeader: false, allowCollapse: false, showModeDropdown: false }); const { container: timelineEditorContainer, timeline, controls } = editorInstance; timeline.id = 'default-schedule-graph'; timeline.className = 'temperature-graph'; container.appendChild(timelineEditorContainer); defaultScheduleGraph = timeline; attachUndoStackDebugLogging(timeline, 'default'); // Get button references const undoBtn = controls.buttons['graph-undo-btn']; const prevBtn = controls.buttons['graph-prev-btn']; const nextBtn = controls.buttons['graph-next-btn']; const copyBtn = controls.buttons['graph-copy-btn']; const pasteBtn = controls.buttons['graph-paste-btn']; const clearBtn = controls.buttons['graph-clear-btn']; // Attach copy/paste handlers for default timeline if (copyBtn) { copyBtn.onclick = () => copySchedule(); } if (pasteBtn) { pasteBtn.onclick = () => pasteSchedule(); } // Link external undo button to timeline's undo system if (undoBtn && typeof timeline.setUndoButton === 'function') { timeline.setUndoButton(undoBtn); } // Link previous/next buttons to timeline navigation if (prevBtn && typeof timeline.setPreviousButton === 'function') { timeline.setPreviousButton(prevBtn); } if (nextBtn && typeof timeline.setNextButton === 'function') { timeline.setNextButton(nextBtn); } // Link clear button to timeline's clear functionality if (clearBtn && typeof timeline.setClearButton === 'function') { timeline.setClearButton(clearBtn); } // Link external undo button to timeline's undo system\n const undoBtn = controls.buttons['graph-undo-btn'];\n if (undoBtn && typeof timeline.setUndoButton === 'function') {\n timeline.setUndoButton(undoBtn);\n }\n \n // Convert defaultScheduleSettings nodes to keyframes and set them // Always set keyframes, even if empty, to ensure timeline renders if (defaultScheduleSettings && defaultScheduleSettings.length > 0) { const keyframes = scheduleNodesToKeyframes(defaultScheduleSettings); // Force reactivity by creating new array reference defaultScheduleGraph.keyframes = [...keyframes]; // Set previousDayEndValue to last temperature from default schedule (wraparound) const nodesWithTemp = defaultScheduleSettings.filter(n => !n.noChange && n.temp !== null && n.temp !== undefined); if (nodesWithTemp.length > 0) { const lastNode = nodesWithTemp[nodesWithTemp.length - 1]; defaultScheduleGraph.previousDayEndValue = lastNode.temp; } else { // Use middle of range if no temperature nodes exist const midValue = (timeline.minValue + timeline.maxValue) / 2; defaultScheduleGraph.previousDayEndValue = midValue; } } else { // Set empty keyframes array to ensure timeline renders defaultScheduleGraph.keyframes = []; // Use middle of range as default when no schedule exists const midValue = (timeline.minValue + timeline.maxValue) / 2; defaultScheduleGraph.previousDayEndValue = midValue; } // Attach event listener for changes (canvas uses 'keyframe-moved' and 'keyframe-deleted') timeline.addEventListener('keyframe-moved', handleDefaultScheduleChange); timeline.addEventListener('keyframe-added', handleDefaultScheduleChange); timeline.addEventListener('keyframe-deleted', handleDefaultScheduleChange); timeline.addEventListener('keyframe-restored', handleDefaultScheduleChange); timeline.addEventListener('keyframes-cleared', handleDefaultScheduleChange); // Real-time updates during dragging timeline.addEventListener('nodeSettingsUpdate', handleDefaultScheduleUpdate); // Attach node settings listener (canvas uses 'keyframe-selected') timeline.addEventListener('keyframe-selected', handleDefaultNodeSettings); } } // Global min/max settings (populated from loadSettings) let minTempSetting = null; let maxTempSetting = null; // Convert all schedules from one unit to another async function convertAllSchedules(fromUnit, toUnit) { if (fromUnit === toUnit) return; try { // Convert entity schedules for (const entityId of entitySchedules.keys()) { const result = await haAPI.getSchedule(entityId); const schedule = result?.response || result; if (schedule && schedule.schedules) { const convertedSchedules = {}; for (const [day, nodes] of Object.entries(schedule.schedules)) { convertedSchedules[day] = convertScheduleNodes(nodes, fromUnit, toUnit); } // Save converted schedules for (const [day, nodes] of Object.entries(convertedSchedules)) { await haAPI.setSchedule(entityId, nodes, day, schedule.schedule_mode || 'all_days'); } } } // Convert group schedules const result = await haAPI.getGroups(); const groups = normalizeGroupsPayload(result); for (const [groupName, groupData] of Object.entries(groups)) { if (groupData.schedules) { const convertedSchedules = {}; for (const [day, nodes] of Object.entries(groupData.schedules)) { convertedSchedules[day] = convertScheduleNodes(nodes, fromUnit, toUnit); } // Save converted group schedules for (const [day, nodes] of Object.entries(convertedSchedules)) { await haAPI.setGroupSchedule(groupName, nodes, day, groupData.schedule_mode || 'all_days'); } } } } catch (error) { console.error('Failed to convert schedules:', error); } } // Check if Workday integration is available async function checkWorkdayIntegration(settings) { const checkbox = getDocumentRoot().querySelector('#use-workday-integration'); const helpText = getDocumentRoot().querySelector('#workday-help-text'); const label = getDocumentRoot().querySelector('#use-workday-label'); const workdaySelector = getDocumentRoot().querySelector('#workday-selector'); if (!checkbox || !helpText) { console.warn('Workday UI elements not found'); return; } try { // Wait for haAPI to be connected let hassObj = haAPI?.hass; // If hass not ready yet, wait a bit and retry if (!hassObj || !hassObj.states) { await new Promise(resolve => setTimeout(resolve, 1000)); hassObj = haAPI?.hass; } const hasWorkday = hassObj?.states?.['binary_sensor.workday_sensor'] !== undefined; if (hasWorkday) { // Workday is available - enable the checkbox checkbox.disabled = false; helpText.textContent = 'When enabled, uses the Workday integration for accurate 5/2 scheduling (respects holidays and custom workdays). When disabled, uses the selected workdays below.'; if (label) label.style.cursor = 'pointer'; // Load saved setting if (settings && typeof settings.use_workday_integration !== 'undefined') { checkbox.checked = settings.use_workday_integration; } // Show/hide workday selector based on checkbox state updateWorkdaySelectorVisibility(checkbox.checked); } else { // Workday not available - disable and show message checkbox.disabled = true; checkbox.checked = false; helpText.innerHTML = 'Workday integration not detected. Configure workdays manually below. Install Workday integration'; if (label) { label.style.cursor = 'not-allowed'; label.style.opacity = '0.6'; } // Always show workday selector when Workday integration is not available if (workdaySelector) workdaySelector.style.display = 'block'; } // Load workdays setting loadWorkdaysSetting(settings); } catch (error) { console.error('Failed to check Workday integration:', error); helpText.textContent = 'Could not determine if Workday integration is installed.'; } } // Update visibility of workday selector based on use_workday_integration setting function updateWorkdaySelectorVisibility(useWorkdayIntegration) { const workdaySelector = getDocumentRoot().querySelector('#workday-selector'); if (workdaySelector) { // Show selector when NOT using Workday integration workdaySelector.style.display = useWorkdayIntegration ? 'none' : 'block'; } } // Load workdays setting from settings function loadWorkdaysSetting(settings) { const defaultWorkdays = ['mon', 'tue', 'wed', 'thu', 'fri']; const workdays = settings?.workdays || defaultWorkdays; const checkboxes = getDocumentRoot().querySelectorAll('.workday-checkbox'); checkboxes.forEach(checkbox => { checkbox.checked = workdays.includes(checkbox.value); }); } // Get selected workdays from checkboxes function getSelectedWorkdays() { const checkboxes = getDocumentRoot().querySelectorAll('.workday-checkbox:checked'); return Array.from(checkboxes).map(cb => cb.value); } // Load settings from server async function loadSettings() { try { const settings = await haAPI.getSettings(); // Check if temperature unit changed and convert schedules if needed const savedUnit = settings?.temperature_unit; // Always set storedTemperatureUnit to track what's in storage storedTemperatureUnit = savedUnit || temperatureUnit; if (savedUnit && savedUnit !== temperatureUnit) { await convertAllSchedules(savedUnit, temperatureUnit); // Update stored unit settings.temperature_unit = temperatureUnit; storedTemperatureUnit = temperatureUnit; await haAPI.saveSettings(settings); } else if (!savedUnit) { // First time - check if default schedule needs conversion from Celsius to Fahrenheit if (temperatureUnit === '°F' && settings.defaultSchedule) { // Check if default schedule looks like it's in Celsius (temps < 40) const maxTemp = Math.max(...settings.defaultSchedule.map(n => n.temp)); if (maxTemp < 40) { settings.defaultSchedule = convertScheduleNodes(settings.defaultSchedule, '°C', '°F'); // Also convert min/max if they look like Celsius if (settings.min_temp && settings.min_temp < 40) { settings.min_temp = convertTemperature(settings.min_temp, '°C', '°F'); } if (settings.max_temp && settings.max_temp < 40) { settings.max_temp = convertTemperature(settings.max_temp, '°C', '°F'); } } } // Save current unit for future detection settings.temperature_unit = temperatureUnit; storedTemperatureUnit = temperatureUnit; await haAPI.saveSettings(settings); } if (settings && settings.defaultSchedule) { // Convert default schedule if needed if (storedTemperatureUnit && storedTemperatureUnit !== temperatureUnit) { defaultScheduleSettings = convertScheduleNodes(settings.defaultSchedule, storedTemperatureUnit, temperatureUnit); } else { defaultScheduleSettings = settings.defaultSchedule; } } if (settings && settings.tooltipMode) { tooltipMode = settings.tooltipMode; const tooltipSelect = getDocumentRoot().querySelector('#tooltip-mode'); if (tooltipSelect) { tooltipSelect.value = tooltipMode; } } // Graph type setting is no longer used - canvas timeline is the only option // Legacy code removed // Load derivative sensor setting if (settings && typeof settings.create_derivative_sensors !== 'undefined') { const checkbox = getDocumentRoot().querySelector('#create-derivative-sensors'); if (checkbox) { checkbox.checked = settings.create_derivative_sensors; } } // Check for Workday integration and load setting await checkWorkdayIntegration(settings); // Load min/max temps if present (convert if unit changed) if (settings && typeof settings.min_temp !== 'undefined') { let minTemp = parseFloat(settings.min_temp); if (storedTemperatureUnit && storedTemperatureUnit !== temperatureUnit) { minTemp = convertTemperature(minTemp, storedTemperatureUnit, temperatureUnit); } minTempSetting = minTemp; const minInput = getDocumentRoot().querySelector('#min-temp'); if (minInput) { minInput.value = minTemp; } else { console.warn('min-temp input not found in DOM during loadSettings'); } } if (settings && typeof settings.max_temp !== 'undefined') { let maxTemp = parseFloat(settings.max_temp); if (storedTemperatureUnit && storedTemperatureUnit !== temperatureUnit) { maxTemp = convertTemperature(maxTemp, storedTemperatureUnit, temperatureUnit); } maxTempSetting = maxTemp; const maxInput = getDocumentRoot().querySelector('#max-temp'); if (maxInput) { maxInput.value = maxTemp; } else { console.warn('max-temp input not found in DOM during loadSettings'); } } // Update unit labels (if present) try { const minUnitEl = getDocumentRoot().querySelector('#min-unit'); const maxUnitEl = getDocumentRoot().querySelector('#max-unit'); if (minUnitEl) minUnitEl.textContent = temperatureUnit; if (maxUnitEl) maxUnitEl.textContent = temperatureUnit; } catch (err) { // ignore } // If graphs already exist, update their ranges try { if (defaultScheduleGraph && minTempSetting !== null && maxTempSetting !== null) { defaultScheduleGraph.minValue = minTempSetting; defaultScheduleGraph.maxValue = maxTempSetting; } if (graph && minTempSetting !== null && maxTempSetting !== null) { graph.minValue = minTempSetting; graph.maxValue = maxTempSetting; } } catch (err) { console.warn('Failed to apply min/max to graphs:', err); } } catch (error) { console.error('Failed to load settings:', error); } } // Save settings to server async function saveSettings() { try { const settings = { defaultSchedule: defaultScheduleSettings, tooltipMode: tooltipMode }; // Read min/max inputs const minInput = getDocumentRoot().querySelector('#min-temp'); const maxInput = getDocumentRoot().querySelector('#max-temp'); if (minInput && minInput.value !== '') settings.min_temp = parseFloat(minInput.value); if (maxInput && maxInput.value !== '') settings.max_temp = parseFloat(maxInput.value); // Read derivative sensor checkbox const derivativeCheckbox = getDocumentRoot().querySelector('#create-derivative-sensors'); if (derivativeCheckbox) { settings.create_derivative_sensors = derivativeCheckbox.checked; } // Read workday integration checkbox const workdayCheckbox = getDocumentRoot().querySelector('#use-workday-integration'); if (workdayCheckbox) { settings.use_workday_integration = workdayCheckbox.checked; } // Read selected workdays const selectedWorkdays = getSelectedWorkdays(); if (selectedWorkdays.length > 0) { settings.workdays = selectedWorkdays; } await haAPI.saveSettings(settings); // Update runtime globals and graphs if (typeof settings.min_temp !== 'undefined') { minTempSetting = parseFloat(settings.min_temp); } if (typeof settings.max_temp !== 'undefined') { maxTempSetting = parseFloat(settings.max_temp); } try { if (defaultScheduleGraph && minTempSetting !== null && maxTempSetting !== null) { defaultScheduleGraph.minValue = minTempSetting; defaultScheduleGraph.maxValue = maxTempSetting; } if (graph && minTempSetting !== null && maxTempSetting !== null) { graph.minValue = minTempSetting; graph.maxValue = maxTempSetting; } } catch (err) { console.warn('Failed to apply min/max to graphs after save:', err); } // Settings saved return true; } catch (error) { console.error('Failed to save settings:', error); return false; } } // Handle default schedule graph changes function handleDefaultScheduleChange(event) { const timeline = event.currentTarget; const keyframes = timeline.keyframes || []; // Convert keyframes to schedule nodes defaultScheduleSettings = keyframesToScheduleNodes(keyframes); // Update previousDayEndValue to reflect the last temperature (wraparound) if (keyframes.length > 0) { const lastKeyframe = keyframes[keyframes.length - 1]; timeline.previousDayEndValue = lastKeyframe.value; } // Auto-save when default schedule is modified saveSettings(); } // Handle real-time updates during dragging (no save) function handleDefaultScheduleUpdate(event) { const timeline = event.currentTarget; const keyframes = timeline.keyframes || []; // Update previousDayEndValue in real-time to reflect the last temperature (wraparound) if (keyframes.length > 0) { const lastKeyframe = keyframes[keyframes.length - 1]; timeline.previousDayEndValue = lastKeyframe.value; } } // Setup settings panel event listeners async function setupSettingsPanel() { await loadSettings(); // Toggle collapse const toggle = getDocumentRoot().querySelector('#settings-toggle'); if (toggle) { toggle.addEventListener('click', async () => { const panel = getDocumentRoot().querySelector('#settings-panel'); const indicator = toggle.querySelector('.collapse-indicator'); if (panel.classList.contains('collapsed')) { panel.classList.remove('collapsed'); panel.classList.add('expanded'); if (indicator) indicator.style.transform = 'rotate(0deg)'; // Initialize the default schedule graph when expanding // Check if graph variable points to an element that's still in the DOM const graphStillValid = defaultScheduleGraph && getDocumentRoot().contains(defaultScheduleGraph); if (!graphStillValid) { await initializeDefaultScheduleGraph(); } } else { panel.classList.remove('expanded'); panel.classList.add('collapsed'); if (indicator) indicator.style.transform = 'rotate(-90deg)'; } }); } // Global instructions toggle const instructionsToggle = getDocumentRoot().querySelector('#global-instructions-toggle'); const instructionsContent = getDocumentRoot().querySelector('#global-graph-instructions'); if (instructionsToggle && instructionsContent) { instructionsToggle.onclick = () => { const isCollapsed = instructionsContent.classList.contains('collapsed'); const icon = instructionsToggle.querySelector('.toggle-icon'); if (isCollapsed) { instructionsContent.classList.remove('collapsed'); instructionsContent.style.display = 'block'; if (icon) icon.style.transform = 'rotate(90deg)'; } else { instructionsContent.classList.add('collapsed'); instructionsContent.style.display = 'none'; if (icon) icon.style.transform = 'rotate(0deg)'; } }; } // Tooltip mode selector const tooltipModeSelect = getDocumentRoot().querySelector('#tooltip-mode'); if (tooltipModeSelect) { tooltipModeSelect.addEventListener('change', (e) => { tooltipMode = e.target.value; // Update all canvas timeline instances if (defaultScheduleGraph) { defaultScheduleGraph.tooltipMode = tooltipMode; } if (graph) { graph.tooltipMode = tooltipMode; } // Auto-save the setting saveSettings(); }); } // Graph type selector removed - canvas timeline is now the only option const graphTypeSelect = getDocumentRoot().querySelector('#graph-type'); if (graphTypeSelect) { // Hide the selector since we only support canvas now const graphTypeContainer = graphTypeSelect.closest('.setting-row, .setting-item, .config-row'); if (graphTypeContainer) { graphTypeContainer.style.display = 'none'; } } // Debug panel toggle const debugToggle = getDocumentRoot().querySelector('#debug-panel-toggle'); const debugPanel = getDocumentRoot().querySelector('#debug-panel'); if (debugToggle && debugPanel) { // Restore saved state debugToggle.checked = debugPanelEnabled; debugPanel.style.display = debugPanelEnabled ? 'block' : 'none'; // Subscribe to logs if debug was previously enabled if (debugPanelEnabled) { debugLog('Debug panel restored from saved state'); // Set log level to debug haAPI.setLogLevel('debug').then(() => { debugLog('Log level set to debug (check Home Assistant logs)', 'info'); }).catch(error => { debugLog('Failed to set log level: ' + error.message, 'error'); }); } debugToggle.addEventListener('change', async (e) => { debugPanelEnabled = e.target.checked; localStorage.setItem('debugPanelEnabled', debugPanelEnabled); debugPanel.style.display = debugPanelEnabled ? 'block' : 'none'; if (debugPanelEnabled) { debugLog('Debug panel enabled'); // Set log level to debug try { await haAPI.setLogLevel('debug'); debugLog('Log level set to debug (check Home Assistant logs)', 'info'); debugLog('Frontend operations will be logged here. Backend logs are in Home Assistant system log.', 'info'); } catch (error) { debugLog('Failed to set log level: ' + error.message, 'error'); } } else { debugLog('Debug panel disabled'); // Reset log level to info when disabling try { await haAPI.setLogLevel('info'); debugLog('Log level reset to info', 'info'); } catch (error) { debugLog('Failed to reset log level: ' + error.message, 'error'); } } }); } // Clear debug button const clearDebugBtn = getDocumentRoot().querySelector('#clear-debug'); if (clearDebugBtn) { clearDebugBtn.addEventListener('click', () => { const debugContent = getDocumentRoot().querySelector('#debug-content'); if (debugContent) { debugContent.innerHTML = ''; debugLog('Debug console cleared'); } }); } // Graph snap step setting const graphSnapStepSelect = getDocumentRoot().querySelector('#graph-snap-step'); if (graphSnapStepSelect) { graphSnapStepSelect.value = graphSnapStep.toString(); graphSnapStepSelect.addEventListener('change', (e) => { graphSnapStep = parseFloat(e.target.value); localStorage.setItem('graphSnapStep', graphSnapStep); showToast(`Graph snap step set to ${graphSnapStep}°`, 'success'); // Update graph instance if it exists if (graph) { graph.graphSnapStep = graphSnapStep; } }); } // Input temperature step setting const inputTempStepSelect = getDocumentRoot().querySelector('#input-temp-step'); if (inputTempStepSelect) { inputTempStepSelect.value = inputTempStep.toString(); inputTempStepSelect.addEventListener('change', (e) => { inputTempStep = parseFloat(e.target.value); localStorage.setItem('inputTempStep', inputTempStep); showToast(`Input field step set to ${inputTempStep}°`, 'success'); // Update the temperature input field if it exists const tempInput = getDocumentRoot().querySelector('#node-temp-input'); if (tempInput) { tempInput.step = inputTempStep.toString(); } // Update button titles const tempUpBtn = getDocumentRoot().querySelector('#temp-up'); const tempDownBtn = getDocumentRoot().querySelector('#temp-down'); if (tempUpBtn) tempUpBtn.title = `+${inputTempStep}°`; if (tempDownBtn) tempDownBtn.title = `-${inputTempStep}°`; }); } // Humidity slider step setting const humidityStepSelect = getDocumentRoot().querySelector('#humidity-step'); if (humidityStepSelect) { humidityStepSelect.value = humidityStep.toString(); humidityStepSelect.addEventListener('change', (e) => { humidityStep = parseFloat(e.target.value); localStorage.setItem('humidityStep', humidityStep); showToast(`Humidity slider step set to ${humidityStep}%`, 'success'); }); } // Min/Max temperature inputs - auto-save on change const minTempInput = getDocumentRoot().querySelector('#min-temp'); const maxTempInput = getDocumentRoot().querySelector('#max-temp'); if (minTempInput) { minTempInput.addEventListener('change', async (e) => { // Simple client-side validation: ensure numeric if (e.target.value !== '') { const v = parseFloat(e.target.value); if (Number.isNaN(v)) { alert('Minimum temperature must be a number'); return; } } await saveSettings(); }); } if (maxTempInput) { maxTempInput.addEventListener('change', async (e) => { if (e.target.value !== '') { const v = parseFloat(e.target.value); if (Number.isNaN(v)) { alert('Maximum temperature must be a number'); return; } } await saveSettings(); }); } // Reset button const resetBtn = getDocumentRoot().querySelector('#reset-defaults'); if (resetBtn) { resetBtn.addEventListener('click', async () => { if (confirm('Reset to default schedule settings?')) { defaultScheduleSettings = []; if (defaultScheduleGraph) { defaultScheduleGraph.keyframes = []; } const success = await saveSettings(); if (success) { resetBtn.textContent = '✓ Reset!'; setTimeout(() => { resetBtn.textContent = 'Reset to Defaults'; }, 2000); } } }); } // Workday integration checkbox const workdayCheckbox = getDocumentRoot().querySelector('#use-workday-integration'); if (workdayCheckbox) { workdayCheckbox.addEventListener('change', async () => { // Update visibility of workday selector updateWorkdaySelectorVisibility(workdayCheckbox.checked); await saveSettings(); }); } // Workday day checkboxes const workdayDayCheckboxes = getDocumentRoot().querySelectorAll('.workday-checkbox'); workdayDayCheckboxes.forEach(checkbox => { checkbox.addEventListener('change', async () => { await saveSettings(); }); }); // Clear default schedule button const clearDefaultScheduleBtn = getDocumentRoot().querySelector('#clear-default-schedule-btn'); if (clearDefaultScheduleBtn) { clearDefaultScheduleBtn.addEventListener('click', async () => { if (confirm('Clear the default schedule? All nodes will be removed.')) { defaultScheduleSettings = []; if (defaultScheduleGraph) { defaultScheduleGraph.keyframes = []; } const success = await saveSettings(); if (success) { clearDefaultScheduleBtn.textContent = '✓ Cleared!'; setTimeout(() => { clearDefaultScheduleBtn.textContent = 'Clear Schedule'; }, 2000); } } }); } // Derivative sensor checkbox - auto-save on change const derivativeCheckbox = getDocumentRoot().querySelector('#create-derivative-sensors'); if (derivativeCheckbox) { derivativeCheckbox.addEventListener('change', async () => { await saveSettings(); }); } // Cleanup derivative sensors button // Run diagnostics button const runDiagnosticsBtn = getDocumentRoot().querySelector('#run-diagnostics-btn'); if (runDiagnosticsBtn) { runDiagnosticsBtn.addEventListener('click', async () => { try { runDiagnosticsBtn.textContent = '🔍 Running diagnostics...'; runDiagnosticsBtn.disabled = true; const result = await haAPI.runDiagnostics(); // Create a downloadable JSON file const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `climate-scheduler-diagnostics-${timestamp}.json`; const jsonString = JSON.stringify(result, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); // Create a temporary download link and trigger it const downloadLink = document.createElement('a'); downloadLink.href = url; downloadLink.download = filename; downloadLink.style.display = 'none'; document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); // Clean up the URL object setTimeout(() => URL.revokeObjectURL(url), 100); showToast(`Diagnostics saved to ${filename}`, 'success', 4000); runDiagnosticsBtn.textContent = '✓ Diagnostics Complete!'; setTimeout(() => { runDiagnosticsBtn.textContent = 'Run Diagnostics'; runDiagnosticsBtn.disabled = false; }, 3000); } catch (error) { console.error('Failed to run diagnostics:', error); showToast('Failed to run diagnostics: ' + error.message, 'error'); runDiagnosticsBtn.textContent = 'Run Diagnostics'; runDiagnosticsBtn.disabled = false; } }); } const cleanupBtn = getDocumentRoot().querySelector('#cleanup-derivative-sensors-btn'); if (cleanupBtn) { cleanupBtn.addEventListener('click', async () => { try { // First check if auto-creation is disabled const settings = await haAPI.getSettings(); const autoCreationEnabled = settings?.create_derivative_sensors !== false; let confirmDeleteAll = false; if (!autoCreationEnabled) { // Ask for confirmation to delete all const confirmed = confirm( 'Auto-creation of derivative sensors is disabled.\\n\\n' + 'This will DELETE ALL climate_scheduler derivative sensors.\\n\\n' + 'Are you sure you want to continue?' ); if (!confirmed) return; confirmDeleteAll = true; } else { // Just cleanup orphaned sensors const confirmed = confirm( 'This will remove derivative sensors for thermostats that no longer exist.\\n\\n' + 'Continue?' ); if (!confirmed) return; } cleanupBtn.textContent = '🧹 Cleaning up...'; cleanupBtn.disabled = true; const result = await haAPI.cleanupDerivativeSensors(confirmDeleteAll); if (result.requires_confirmation) { showToast(result.message, 'warning', 6000); } else { showToast(result.message, 'success', 4000); } if (result.errors && result.errors.length > 0) { console.error('Cleanup errors:', result.errors); showToast(`Deleted ${result.deleted_count} sensors with ${result.errors.length} errors`, 'warning', 5000); } cleanupBtn.textContent = '✓ Cleanup Complete!'; setTimeout(() => { cleanupBtn.textContent = '🧹 Cleanup Derivative Sensors'; cleanupBtn.disabled = false; }, 3000); } catch (error) { console.error('Failed to cleanup derivative sensors:', error); showToast('Failed to cleanup derivative sensors', 'error'); cleanupBtn.textContent = '🧹 Cleanup Derivative Sensors'; cleanupBtn.disabled = false; } }); } const cleanupClimateBtn = getDocumentRoot().querySelector('#cleanup-orphaned-climate-btn'); if (cleanupClimateBtn) { cleanupClimateBtn.addEventListener('click', async () => { try { // First do a dry run to see what would be deleted cleanupClimateBtn.textContent = '🔍 Scanning...'; cleanupClimateBtn.disabled = true; const dryRunResult = await haAPI.cleanupOrphanedClimateEntities(false); if (!dryRunResult.orphaned_entities || dryRunResult.orphaned_entities.length === 0) { showToast('No orphaned entities found', 'success'); cleanupClimateBtn.textContent = 'Cleanup Orphaned Entities'; cleanupClimateBtn.disabled = false; return; } // Ask for confirmation with list of entities const entityList = dryRunResult.orphaned_entities.join('\n• '); const confirmed = confirm( `Found ${dryRunResult.orphaned_entities.length} orphaned entities:\n\n` + `• ${entityList}\n\n` + 'These entities no longer have matching groups or climate entities in storage.\n\n' + 'Delete these entities?' ); if (!confirmed) { cleanupClimateBtn.textContent = 'Cleanup Orphaned Entities'; cleanupClimateBtn.disabled = false; return; } cleanupClimateBtn.textContent = '🗑️ Deleting...'; const deleteResult = await haAPI.cleanupOrphanedClimateEntities(true); if (deleteResult.removed && deleteResult.removed.length > 0) { showToast(`Removed ${deleteResult.removed.length} orphaned entities`, 'success', 4000); } else { showToast('No entities were removed', 'warning'); } cleanupClimateBtn.textContent = '✓ Cleanup Complete!'; setTimeout(() => { cleanupClimateBtn.textContent = 'Cleanup Orphaned Entities'; cleanupClimateBtn.disabled = false; }, 3000); } catch (error) { console.error('Failed to cleanup orphaned entities:', error); showToast('Failed to cleanup orphaned entities', 'error'); cleanupClimateBtn.textContent = 'Cleanup Orphaned Entities'; cleanupClimateBtn.disabled = false; } }); } const cleanupStorageBtn = getDocumentRoot().querySelector('#cleanup-storage-btn'); if (cleanupStorageBtn) { cleanupStorageBtn.addEventListener('click', async () => { try { cleanupStorageBtn.textContent = '🔍 Dry run...'; cleanupStorageBtn.disabled = true; const preview = await haAPI.cleanupUnmonitoredStorage(true); const wouldRemoveGroups = preview?.would_remove_groups || []; const wouldRemoveEntityRefs = preview?.would_remove_entity_references || []; const wouldRemoveProfiles = preview?.would_remove_profiles || []; const wouldRemoveAdvanceHistory = preview?.would_remove_advance_history || []; const totalPlanned = wouldRemoveGroups.length + wouldRemoveEntityRefs.length + wouldRemoveProfiles.length + wouldRemoveAdvanceHistory.length; if (!preview?.would_change || totalPlanned === 0) { showToast('Dry run complete: no stale storage entries to remove.', 'success', 5000); cleanupStorageBtn.textContent = 'Cleanup Unmonitored Storage'; cleanupStorageBtn.disabled = false; return; } if (cleanupAutoDownloadReports) { const previewTimestamp = new Date().toISOString().replace(/[:.]/g, '-'); const previewFilename = `climate-scheduler-cleanup-preview-${previewTimestamp}.json`; const previewBlob = new Blob([JSON.stringify(preview, null, 2)], { type: 'application/json' }); const previewUrl = URL.createObjectURL(previewBlob); const previewDownloadLink = document.createElement('a'); previewDownloadLink.href = previewUrl; previewDownloadLink.download = previewFilename; previewDownloadLink.style.display = 'none'; document.body.appendChild(previewDownloadLink); previewDownloadLink.click(); document.body.removeChild(previewDownloadLink); setTimeout(() => URL.revokeObjectURL(previewUrl), 100); showToast(`Dry-run preview saved to ${previewFilename}`, 'info', 5000); } const summaryParts = []; if (wouldRemoveGroups.length > 0) summaryParts.push(`${wouldRemoveGroups.length} groups`); if (wouldRemoveEntityRefs.length > 0) summaryParts.push(`${wouldRemoveEntityRefs.length} entity references`); if (wouldRemoveProfiles.length > 0) summaryParts.push(`${wouldRemoveProfiles.length} profiles`); if (wouldRemoveAdvanceHistory.length > 0) summaryParts.push(`${wouldRemoveAdvanceHistory.length} advance-history entries`); const groupPreview = wouldRemoveGroups .slice(0, 5) .map(item => item?.group) .filter(Boolean) .join(', '); const confirmMessage = `Dry run found changes:\n\n` + `Will remove: ${summaryParts.join(', ')}.\n` + (groupPreview ? `\nGroups: ${groupPreview}${wouldRemoveGroups.length > 5 ? ', ...' : ''}\n` : '\n') + `\nProceed with cleanup?`; const confirmed = confirm(confirmMessage); if (!confirmed) { cleanupStorageBtn.textContent = 'Cleanup Unmonitored Storage'; cleanupStorageBtn.disabled = false; return; } cleanupStorageBtn.textContent = '🧹 Cleaning storage...'; const result = await haAPI.cleanupUnmonitoredStorage(false); if (cleanupAutoDownloadReports) { const resultTimestamp = new Date().toISOString().replace(/[:.]/g, '-'); const resultFilename = `climate-scheduler-cleanup-result-${resultTimestamp}.json`; const resultBlob = new Blob([JSON.stringify(result, null, 2)], { type: 'application/json' }); const resultUrl = URL.createObjectURL(resultBlob); const resultDownloadLink = document.createElement('a'); resultDownloadLink.href = resultUrl; resultDownloadLink.download = resultFilename; resultDownloadLink.style.display = 'none'; document.body.appendChild(resultDownloadLink); resultDownloadLink.click(); document.body.removeChild(resultDownloadLink); setTimeout(() => URL.revokeObjectURL(resultUrl), 100); showToast(`Cleanup result saved to ${resultFilename}`, 'info', 5000); } if (result?.message) { showToast(result.message, 'success', 5000); } else { showToast('Storage cleanup complete', 'success'); } await loadGroups(); await renderEntityList(); cleanupStorageBtn.textContent = '✓ Cleanup Complete!'; setTimeout(() => { cleanupStorageBtn.textContent = 'Cleanup Unmonitored Storage'; cleanupStorageBtn.disabled = false; }, 3000); } catch (error) { console.error('Failed to cleanup storage:', error); showToast('Failed to cleanup storage', 'error'); cleanupStorageBtn.textContent = 'Cleanup Unmonitored Storage'; cleanupStorageBtn.disabled = false; } }); } } // Handle node settings for default schedule function handleDefaultNodeSettings(event) { const { nodeIndex, node } = event.detail; // Node clicked // Check if default node settings panel exists const panel = getDocumentRoot().querySelector('#default-node-settings-panel'); if (!panel) { // Node settings panel not available return; } // Updating panel // Aggregate all possible modes from all climate entities const hvacModesSet = new Set(); const fanModesSet = new Set(); const swingModesSet = new Set(); const presetModesSet = new Set(); climateEntities.forEach(entity => { if (entity.attributes.hvac_modes) { entity.attributes.hvac_modes.forEach(mode => hvacModesSet.add(mode)); } if (entity.attributes.fan_modes) { entity.attributes.fan_modes.forEach(mode => fanModesSet.add(mode)); } if (entity.attributes.swing_modes) { entity.attributes.swing_modes.forEach(mode => swingModesSet.add(mode)); } if (entity.attributes.preset_modes) { entity.attributes.preset_modes.forEach(mode => presetModesSet.add(mode)); } }); const allHvacModes = Array.from(hvacModesSet); const allFanModes = Array.from(fanModesSet); const allSwingModes = Array.from(swingModesSet); const allPresetModes = Array.from(presetModesSet); // Update panel content - get fresh references const nodeTimeEl = getDocumentRoot().querySelector('#default-node-time'); const nodeTempEl = getDocumentRoot().querySelector('#default-node-temp'); if (!nodeTimeEl || !nodeTempEl) return; nodeTimeEl.textContent = node.time; nodeTempEl.textContent = `${node.temp}${temperatureUnit}`; // Displays updated // Get fresh references to all elements const hvacModeSelect = getDocumentRoot().querySelector('#default-node-hvac-mode'); const hvacModeItem = getDocumentRoot().querySelector('#default-hvac-mode-item'); const fanModeSelect = getDocumentRoot().querySelector('#default-node-fan-mode'); const fanModeItem = getDocumentRoot().querySelector('#default-fan-mode-item'); const swingModeSelect = getDocumentRoot().querySelector('#default-node-swing-mode'); const swingModeItem = getDocumentRoot().querySelector('#default-swing-mode-item'); const presetModeSelect = getDocumentRoot().querySelector('#default-node-preset-mode'); const presetModeItem = getDocumentRoot().querySelector('#default-preset-mode-item'); // Populate HVAC mode dropdown if (hvacModeSelect && hvacModeItem) { if (allHvacModes.length > 0) { hvacModeSelect.innerHTML = ''; allHvacModes.forEach(mode => { const option = document.createElement('option'); option.value = mode; option.textContent = mode.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); if (node.hvac_mode === mode) option.selected = true; hvacModeSelect.appendChild(option); }); hvacModeItem.style.display = ''; } else { hvacModeItem.style.display = 'none'; } } // Populate fan mode dropdown if (fanModeSelect && fanModeItem) { if (allFanModes.length > 0) { fanModeSelect.innerHTML = ''; allFanModes.forEach(mode => { const option = document.createElement('option'); option.value = mode; option.textContent = mode.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); if (node.fan_mode === mode) option.selected = true; fanModeSelect.appendChild(option); }); fanModeItem.style.display = ''; } else { fanModeItem.style.display = 'none'; } } // Populate swing mode dropdown if (swingModeSelect && swingModeItem) { if (allSwingModes.length > 0) { swingModeSelect.innerHTML = ''; allSwingModes.forEach(mode => { const option = document.createElement('option'); option.value = mode; option.textContent = mode.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); if (node.swing_mode === mode) option.selected = true; swingModeSelect.appendChild(option); }); swingModeItem.style.display = ''; } else { swingModeItem.style.display = 'none'; } } // Populate preset mode dropdown if (presetModeSelect && presetModeItem) { if (allPresetModes.length > 0) { presetModeSelect.innerHTML = ''; allPresetModes.forEach(mode => { const option = document.createElement('option'); option.value = mode; option.textContent = mode.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); if (node.preset_mode === mode) option.selected = true; presetModeSelect.appendChild(option); }); presetModeItem.style.display = ''; } else { presetModeItem.style.display = 'none'; } } // Setup auto-save for default schedule const autoSaveDefaultNodeSettings = async () => { if (!panel) return; const nodeIdx = parseInt(panel.dataset.nodeIndex); if (isNaN(nodeIdx) || !defaultScheduleGraph) return; // Get the actual node from the graph const targetNode = defaultScheduleGraph.keyframes[nodeIdx]; if (!targetNode) return; // Get fresh references const hvacSelect = getDocumentRoot().querySelector('#default-node-hvac-mode'); const hvacItem = getDocumentRoot().querySelector('#default-hvac-mode-item'); const fanSelect = getDocumentRoot().querySelector('#default-node-fan-mode'); const fanItem = getDocumentRoot().querySelector('#default-fan-mode-item'); const swingSelect = getDocumentRoot().querySelector('#default-node-swing-mode'); const swingItem = getDocumentRoot().querySelector('#default-swing-mode-item'); const presetSelect = getDocumentRoot().querySelector('#default-node-preset-mode'); const presetItem = getDocumentRoot().querySelector('#default-preset-mode-item'); // Update or delete properties based on dropdown values if (hvacSelect && hvacItem && hvacItem.style.display !== 'none') { const hvacMode = hvacSelect.value; if (hvacMode) { targetNode.hvac_mode = hvacMode; } else { delete targetNode.hvac_mode; } } if (fanSelect && fanItem && fanItem.style.display !== 'none') { const fanMode = fanSelect.value; if (fanMode) { targetNode.fan_mode = fanMode; } else { delete targetNode.fan_mode; } } if (swingSelect && swingItem && swingItem.style.display !== 'none') { const swingMode = swingSelect.value; if (swingMode) { targetNode.swing_mode = swingMode; } else { delete targetNode.swing_mode; } } if (presetSelect && presetItem && presetItem.style.display !== 'none') { const presetMode = presetSelect.value; if (presetMode) { targetNode.preset_mode = presetMode; } else { delete targetNode.preset_mode; } } // Update the settings array (convert keyframes back to nodes) defaultScheduleSettings = keyframesToScheduleNodes(defaultScheduleGraph.keyframes); // Auto-save to server await saveSettings(); }; // Attach change listeners to the freshly populated dropdowns const finalHvacSelect = getDocumentRoot().querySelector('#default-node-hvac-mode'); const finalFanSelect = getDocumentRoot().querySelector('#default-node-fan-mode'); const finalSwingSelect = getDocumentRoot().querySelector('#default-node-swing-mode'); const finalPresetSelect = getDocumentRoot().querySelector('#default-node-preset-mode'); if (finalHvacSelect) { finalHvacSelect.addEventListener('change', autoSaveDefaultNodeSettings); } if (finalFanSelect) { finalFanSelect.addEventListener('change', autoSaveDefaultNodeSettings); } if (finalSwingSelect) { finalSwingSelect.addEventListener('change', autoSaveDefaultNodeSettings); } if (finalPresetSelect) { finalPresetSelect.addEventListener('change', autoSaveDefaultNodeSettings); } // Panel ready // Setup delete button const deleteBtn = getDocumentRoot().querySelector('#default-delete-node-btn'); if (deleteBtn) { const newDeleteBtn = deleteBtn.cloneNode(true); deleteBtn.parentNode.replaceChild(newDeleteBtn, deleteBtn); newDeleteBtn.addEventListener('click', () => { if (defaultScheduleGraph.keyframes.length <= 1) { alert('Cannot delete the last node. A schedule must have at least one node.'); return; } if (confirm('Delete this node?')) { // Remove keyframe at index defaultScheduleGraph.keyframes = defaultScheduleGraph.keyframes.filter((_, i) => i !== nodeIndex); defaultScheduleSettings = keyframesToScheduleNodes(defaultScheduleGraph.keyframes); panel.style.display = 'none'; } }); } // Show panel panel.style.display = 'block'; panel.dataset.nodeIndex = nodeIndex; // Scroll to panel panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // Load advance history for an entity async function loadAdvanceHistory(entityId) { try { const status = await haAPI.getAdvanceStatus(entityId); if (status && status.history && graph) { graph.advanceHistory = status.history; } } catch (error) { console.error('Failed to load advance history:', error); } } // Export initialization function for custom panel window.initClimateSchedulerApp = function(hass) { // Create API instance first if needed if (!haAPI) { haAPI = new HomeAssistantAPI(); } // Set hass object FIRST if provided (custom panel mode) if (hass) { haAPI.setHassObject(hass); } // Initialize app - connect() will use hass object if available if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initApp); } else { initApp(); } }; // Export function to update hass connection (when panel receives new hass object) window.updateHassConnection = function(hass) { if (hass && haAPI) { haAPI.setHassObject(hass); } }; // Auto-initialize for backward compatibility (iframe/standalone mode) // Guard initialization so we only run in documents that contain the expected // UI container (prevents errors when `index.html` is removed and app.js is // loaded in a different context). const _shouldAutoInit = () => { // If the panel custom element is present, let panel.js call init explicitly try { if (customElements && customElements.get && customElements.get('climate-scheduler-panel')) { return false; } } catch (e) { // ignore } // Check for an existing container or entity list; only auto-init if present if (document.querySelector('#entity-list') || document.querySelector('.container')) { return true; } return false; }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { if (_shouldAutoInit()) initApp(); }); } else { if (_shouldAutoInit()) initApp(); }