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