Files
HomeAssistantVS/custom_components/climate_scheduler/frontend/app.js
T
2026-07-08 10:43:39 -04:00

7580 lines
306 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `<span class="debug-timestamp">${timestamp}</span>${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 = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${message}</span>
`;
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 = `
<div style="padding: 20px; text-align: center;">
<h2>❌ Connection Failed</h2>
<p style="color: #666; margin: 20px 0;">Could not connect to Home Assistant</p>
<details style="text-align: left; max-width: 600px; margin: 20px auto; padding: 10px; background: #f5f5f5; border-radius: 8px;">
<summary style="cursor: pointer; font-weight: bold;">Technical Details</summary>
<pre style="margin-top: 10px; overflow-x: auto;">${error.message}\n\n${error.stack}</pre>
</details>
<p style="margin-top: 20px;">
<strong>Troubleshooting:</strong><br>
• Try refreshing the page (pull down on mobile)<br>
• Check if you're logged into Home Assistant<br>
• Restart the Home Assistant app<br>
• Check Settings → Companion App → Debugging
</p>
</div>
`;
}
}
}
// 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 = '<p style="color: var(--secondary-text-color); padding: 16px; text-align: center;">No groups created yet</p>';
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 = '<p style="color: var(--secondary-text-color); padding: 16px; text-align: center;">No unmonitored entities</p>';
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 = `
<span class="toggle-icon" style="transform: rotate(-90deg);">▼</span>
<span class="toggle-text">Schedule Settings</span>
`;
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 = `
<div class="editor-controls">
<button id="undo-btn" class="btn-secondary-outline schedule-btn" title="Undo last change (Ctrl+Z)" disabled>Undo</button>
<button id="copy-schedule-btn" class="btn-secondary-outline schedule-btn" title="Copy current schedule">Copy Schedule</button>
<button id="paste-schedule-btn" class="btn-secondary-outline schedule-btn" title="Paste copied schedule" disabled>Paste Schedule</button>
<button id="clear-advance-history-btn" class="btn-secondary-outline schedule-btn" title="Clear advance history markers">Clear Advance History</button>`;
// Only show unmonitor button for single-entity groups
if (isSingleEntityGroup) {
controlsHTML += `<button id="ignore-entity-btn" class="btn-secondary-outline schedule-btn" title="Stop monitoring this thermostat">Unmonitor</button>`;
}
controlsHTML += `
<button id="clear-schedule-btn" class="btn-danger-outline schedule-btn" title="Clear entire schedule">Clear Schedule</button>
<label class="toggle-switch">
<input type="checkbox" id="schedule-enabled">
<span class="slider"></span>
<span class="toggle-label">Enabled</span>
</label>
</div>
`;
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 = `
<span>Editing Profile: ${editingProfile}</span>
<button id="close-editing-profile" style="padding: 4px 12px; cursor: pointer; background: var(--primary-color); color: var(--text-primary-color); border: none; border-radius: 4px; font-weight: normal;">Done</button>
`;
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 = `
<strong>⚠️ Preset-Only ${count === 1 ? 'Entity' : 'Entities'} Detected</strong><br>
${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 = `
<div style="padding: 16px; text-align: center; color: var(--secondary-text-color); font-style: italic;">
Virtual Schedule (No Entities) - Events Only
</div>
`;
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 = `
<span class="toggle-icon">▶</span>
<span class="toggle-text">Member Entities (${entityIds.length})</span>
`;
// 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 = '<span>Name</span><span>Current</span><span>Target</span><span>Scheduled</span><span style="text-align: center;">Actions</span>';
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 = '<option value="">Select a group...</option>';
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 = '<option value="">Select a group...</option>';
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 = `
<option value="all_days">24hr</option>
<option value="5/2">Weekday</option>
<option value="individual">7 Day</option>
`;
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 = `
<div class="editor-header-inline">
<div class="graph-top-controls">
<div class="day-period-selector" id="day-period-selector" style="display: none;">
<div class="day-period-buttons" id="day-period-buttons">
<!-- Buttons will be populated based on schedule mode -->
</div>
</div>
<div class="graph-quick-actions">
<select id="schedule-mode-dropdown" class="mode-dropdown" title="Schedule mode">
<option value="all_days">24hr</option>
<option value="5/2">Weekday</option>
<option value="individual">7 Day</option>
</select>
<button id="graph-copy-btn" class="btn-quick-action" title="Copy schedule">Copy</button>
<button id="graph-paste-btn" class="btn-quick-action" title="Paste schedule">Paste</button>
<button id="graph-undo-btn" class="btn-quick-action" title="Undo last change">Undo</button>
<button id="advance-schedule-btn" class="btn-quick-action" title="Advance to next scheduled node">Advance</button>
<button id="save-schedule-btn" class="btn-quick-action btn-primary" title="Save schedule">Save</button>
</div>
</div>
</div>
<div class="entity-status">
<div class="status-item">
<span class="status-label">Current Temp:</span>
<span id="current-temp" class="status-value">--°C</span>
</div>
<div class="status-item">
<span class="status-label">Target Temp:</span>
<span id="target-temp" class="status-value">--°C</span>
</div>
<div class="status-item">
<span class="status-label">Scheduled Temp:</span>
<span id="scheduled-temp" class="status-value">--°C</span>
</div>
<div class="status-item" id="current-hvac-mode-item" style="display: none;">
<span class="status-label">HVAC Mode:</span>
<span id="current-hvac-mode" class="status-value">--</span>
</div>
<div class="status-item" id="current-fan-mode-item" style="display: none;">
<span class="status-label">Fan Mode:</span>
<span id="current-fan-mode" class="status-value">--</span>
</div>
<div class="status-item" id="current-swing-mode-item" style="display: none;">
<span class="status-label">Swing Mode:</span>
<span id="current-swing-mode" class="status-value">--</span>
</div>
<div class="status-item" id="current-preset-mode-item" style="display: none;">
<span class="status-label">Preset Mode:</span>
<span id="current-preset-mode" class="status-value">--</span>
</div>
</div>
<div class="graph-container">
<!-- Canvas timeline will be inserted here -->
</div>
<!-- Node Settings Panel (below timeline) -->
<div id="node-settings-panel" class="node-settings-panel" style="display: none;">
<div class="settings-header">
<div style="display: flex; gap: 8px; align-items: center;">
<button id="prev-node" class="btn-nav-node" title="Previous node">◀</button>
<div class="setting-item" style="margin: 0;">
<select id="node-time-input" class="mode-select" style="min-width: 100px;">
</select>
</div>
<button id="next-node" class="btn-nav-node" title="Next node">▶</button>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<button id="test-fire-event-btn" class="btn-secondary-outline" title="Test fire event with current active node" style="padding: 4px 10px; font-size: 0.8rem;">Test Event</button>
<button id="delete-node" class="btn-danger-outline" title="Delete selected node" style="padding: 4px 10px; font-size: 0.8rem;">Delete Node</button>
<button id="close-settings" class="btn-close-settings">✕</button>
</div>
</div>
<div id="climate-dialog-container"></div>
</div>
`;
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=<unserializable>`, '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. <a href="https://www.home-assistant.io/integrations/workday/" target="_blank" style="color: var(--primary);">Install Workday integration</a>';
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 = '<option value="">-- No Change --</option>';
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 = '<option value="">-- No Change --</option>';
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 = '<option value="">-- No Change --</option>';
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 = '<option value="">-- No Change --</option>';
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();
}