function renderAll() {
renderSummary();
renderHouseClimate();
renderGroups();
renderControlPlan();
renderSimulationPage();
renderDevices();
renderZones();
renderSchedules();
renderAutomations();
renderAccessTokens();
fillSelects();
renderSettings();
renderNightSettings();
renderHomeAssistantSettings();
renderSimulationModeBanner();
renderDebugOverlay();
}
function renderSummary() {
const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite);
const average = temperatures.length ? temperatures.reduce((a,b) => a+b, 0) / temperatures.length : null;
const online = app.devices.filter(d => d.online).length;
const active = app.devices.filter(d => d.power).length;
const demand = app.zones.filter(z => z.enabled && z.demand).length;
const toolbarStatus = $('#toolbarRuntimeStatus');
if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', {active, total:app.devices.length});
$('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}°C`;
const activeSuffix = active ? tr('dashboard.summaryActive', {count: active}) : '';
$('#summaryText').textContent = app.devices.length
? tr('dashboard.summary', {online, total: app.devices.length, active: activeSuffix})
: tr('dashboard.empty');
$('#metrics').innerHTML = [
[tr('dashboard.metricOnline'), `${online}/${app.devices.length}`],
[tr('dashboard.metricActive'), active],
[tr('dashboard.metricDemand'), demand],
].map(([label,value]) => `
${esc(label)}${esc(value)}
`).join('');
renderSystemInfo();
}
function renderHouseClimate() {
const node = $('#houseClimate'); if (!node || !app.settings) return;
const mode = app.settings.house_mode || 'cool';
const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto');
const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null;
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff');
// Global power buttons are one-shot actions, not a persistent ON/OFF mode.
if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); }
if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); }
node.innerHTML = `${esc(tr('house.seasonMode'))}${esc(tr('house.smartThermostat'))}
${esc(tr('house.setpointStrategy'))}
${esc(tr('house.outdoor'))}${esc(outdoor)}
${['auto','comfort','sleep','away'].map(p=>``).join('')}
`;
}
function planEventMarkup(event) {
const when = event?.at ? new Date(event.at).toLocaleString(locale(), {weekday:'short', hour:'2-digit', minute:'2-digit'}) : '—';
const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`;
return `${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}`;
}
function automationTriggerLabel(item) {
if (item.trigger_kind === 'time') return tr('automations.triggerAt', {time: item.at_time || '—'});
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
return tr(key, {temperature: fmtTemp(item.threshold)});
}
function planZoneEffectiveEnabled(zone) {
if (!zone) return undefined;
return zone.effective_enabled ?? zone.enabled ?? false;
}
function renderControlPlan() {
const host = $('#controlPlan'); if (!host) return;
const plan = app.controlPlan;
if (!plan) {
host.innerHTML = `${esc(tr('plan.loading'))}
`;
return;
}
const allZones = plan.zones || [];
const houseEvents = (plan.next_events || []).slice(0, 3);
const house = `${esc(tr('plan.house'))}
${esc(houseModeLabel(plan.house_mode || 'off'))}
${esc((plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint'))}${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}
${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `- ${esc(tr('plan.noEvents'))}
`}
`;
const groupCards = (app.groups || []).map(group => {
const memberIds = new Set(group.zone_ids || []);
const members = allZones.filter(zone => memberIds.has(zone.zone_id));
const state = groupState(group);
const powerEnabled = group.power_enabled !== false;
const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length;
const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode);
const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset);
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`})))
.sort((a,b) => new Date(a.at) - new Date(b.at)).slice(0, 2);
return `${esc(tr('groups.group'))}
${esc(group.name)}
${esc(powerEnabled ? tr('common.on') : tr('common.off'))}${esc(mode)}·${esc(preset)}
${esc(tr('plan.groupSummary', {zones:members.length, demand}))}
${events.length ? events.map(planEventMarkup).join('') : `- ${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}
`}
`;
}).join('');
const zoneGroups = new Map();
(app.groups || []).forEach(group => (group.zone_ids || []).forEach(zoneId => {
const names = zoneGroups.get(zoneId) || [];
names.push(group.name);
zoneGroups.set(zoneId, names);
}));
const zones = allZones.map(zone => {
const events = (zone.next_events || []).slice(0, 2);
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
const groupNames = zoneGroups.get(zone.zone_id) || [];
const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice'));
const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off');
return `${esc(scope)}
${esc(zone.zone_name)}
${esc(planState)}${fmtTemp(zone.current_temperature)}→${esc(target)}°C
${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}
${events.length ? events.map(planEventMarkup).join('') : `- ${esc(tr('plan.noEvents'))}
`}
`;
}).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled);
const ruleCard = rules.length ? `${esc(tr('plan.rules'))}
${esc(tr('plan.ruleCount', {count:rules.length}))}
${rules.slice(0,3).map(rule=>`- ${esc(rule.name)} → ${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}
`).join('')}
` : '';
host.innerHTML = house + groupCards + zones + ruleCard;
}
async function loadControlPlan() {
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
catch (error) { console.warn('Unable to load control plan:', error); }
}
function simulationTime(value, options = {}) {
if (!value) return '—';
const date = new Date(value);
return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', '');
}
function simulationNumeric(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function simulationOutdoorAssist(mode, outdoor, room, target) {
if (outdoor == null || room == null || target == null) return 0;
const roomError = Math.abs(room - target);
const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1);
return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1);
}
function simulationRoundDeviceSetpoint(mode, demand, value) {
const safe = clamp(Number(value) || 0, 16, 30);
if (mode === 'heat' && demand) return Math.ceil(safe);
if (mode === 'heat' && !demand) return Math.floor(safe);
if (mode !== 'heat' && demand) return Math.floor(safe);
return Math.ceil(safe);
}
function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
if (!demand) return 1;
const error = Math.abs(room - target);
const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32;
if (error >= 2 || extremeWeather) return 3;
if (error >= 1) return 2;
return 0;
}
function simulationStatusInfo(status) {
const map = {
disabled: {label: tr('common.disabled'), badge: ''},
off: {label: tr('house.noControl'), badge: ''},
waiting: {label: tr('simulation.waitingForData'), badge: ''},
demand: {label: tr('simulation.stateDemand'), badge: 'active'},
satisfied: {label: tr('simulation.stateSatisfied'), badge: ''},
};
return map[status] || {label: status, badge: ''};
}
function buildZoneSimulation(zone, planZone) {
const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id));
const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off';
const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature);
const target = mode === 'off' ? null : simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint);
const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature);
const hysteresis = Math.max(simulationNumeric(zoneHysteresisForMode(zone, mode)) || 0.4, 0.1);
const half = hysteresis / 2;
let status = 'waiting';
let demand = false;
if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === false) status = 'disabled';
else if (mode === 'off') status = 'off';
else if (current == null || target == null) status = 'waiting';
else {
if (mode === 'heat') {
if (current <= target - half) demand = true;
else if (current >= target + half) demand = false;
else demand = !!zone?.demand;
} else {
if (current >= target + half) demand = true;
else if (current <= target - half) demand = false;
else demand = !!zone?.demand;
}
status = demand ? 'demand' : 'satisfied';
}
const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5);
const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target);
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
const nightActive = !!app.controlPlan?.night_mode_active;
const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5);
let fanSpeed = simulationNumeric(device?.fan_speed);
if (mode !== 'off') {
fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed;
if (nightActive) {
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan);
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan;
}
}
const quiet = mode === 'off'
? tr('simulation.manual')
: nightActive && app.settings?.night_mode?.force_quiet
? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false
? tr('simulation.reasonDisabled')
: mode === 'off'
? tr('simulation.reasonHouseOff')
: current == null || target == null
? tr('simulation.reasonWaiting')
: demand
? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)})
: tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)});
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning};
}
function simulationRuleAction(rule) {
const bits = [];
if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`);
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`);
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`);
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
}
function simulationGroupsForZone(zoneId) {
return app.groups.filter(group => (group.zone_ids || []).includes(zoneId));
}
function renderSimulationScopeControls(plan) {
const scope = $('#simulationScope');
const target = $('#simulationTarget');
if (!scope || !target) return;
if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units';
scope.value = app.simulationScope;
const zones = plan?.zones || [];
const options = app.simulationScope === 'groups'
? [{id:'all', name:tr('simulation.allGroups')}, ...app.groups.map(group => ({id:group.id, name:group.name}))]
: [{id:'all', name:tr('simulation.allUnits')}, ...zones.map(zone => ({id:zone.zone_id, name:`${zone.zone_name} · ${zone.device_name || tr('common.device')}`}))];
if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all';
target.innerHTML = options.map(option => ``).join('');
target.value = app.simulationTarget;
}
function simulationFilteredZones(plan) {
const zones = plan?.zones || [];
if (app.simulationScope === 'groups') {
if (app.simulationTarget === 'all') {
const grouped = new Set(app.groups.flatMap(group => group.zone_ids || []));
return zones.filter(zone => grouped.has(zone.zone_id));
}
const group = app.groups.find(item => item.id === app.simulationTarget);
const wanted = new Set(group?.zone_ids || []);
return zones.filter(zone => wanted.has(zone.zone_id));
}
return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget);
}
function simulationRuleVisible(rule, zones) {
if (app.simulationTarget === 'all') return true;
const zoneIds = new Set(zones.map(zone => zone.zone_id));
const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean));
if (app.simulationScope === 'groups') {
if (rule.action_group_id === app.simulationTarget) return true;
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
}
const directZone = [...zoneIds][0];
if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true;
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
}
function simulationLaneContext(planZone) {
if (app.simulationScope !== 'groups') return planZone.device_name || '';
const groups = simulationGroupsForZone(planZone.zone_id);
return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || '');
}
function updateSimulationUrl() {
if (app.currentView !== 'simulation') return;
const params = new URLSearchParams();
if (app.standaloneSimulation) params.set('standalone', '1');
if (app.simulationScope !== 'units') params.set('scope', app.simulationScope);
if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget);
const query = params.toString();
updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true);
}
function renderSimulationPage() {
const summaryHost = $('#simulationSummary');
const timelineHost = $('#simulationTimeline');
const boardHost = $('#simulationFlowBoard');
const rulesHost = $('#simulationRules');
if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return;
const plan = app.controlPlan;
if (!plan) {
summaryHost.innerHTML = `${esc(tr('plan.loading'))}
`;
timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = '';
return;
}
renderSimulationScopeControls(plan);
const zones = simulationFilteredZones(plan);
const enabledZones = zones.filter(zone => zone.enabled);
const demandingZones = enabledZones.filter(zone => zone.demand);
const nightOn = !!plan.night_mode_active;
summaryHost.innerHTML = [
{label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')})},
{label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`},
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}`},
].map(card => `${esc(card.label)}${esc(card.value)}${esc(card.note)}`).join('');
const nodeW = 190, nodeH = 106, rowH = 190, topY = 155;
const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085};
const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35);
const nodes = [];
const links = [];
const pathBetween = (ax, ay, bx, by, cls='') => {
const bend = Math.max(36, (bx - ax) * .45);
return ``;
};
const verticalLink = (ax, ay, bx, by, cls='bus') => ``;
const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `${esc(eyebrow)}${badge ? `${esc(badge)}` : ''}
${esc(title)}
${esc(value)}${meta ? `${esc(meta)}
` : ''}`;
const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28;
nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')}),badge:tr('simulation.house')}));
nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''}));
zones.forEach((planZone, index) => {
const zone = app.zones.find(item => item.id === planZone.zone_id);
const sim = buildZoneSimulation(zone, planZone);
const status = simulationStatusInfo(sim.status);
const y = topY + index * rowH;
const mid = y + nodeH / 2;
const next = (planZone.next_events || [])[0];
const source = zoneControlSourceLabel(planZone.control_source);
const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined'
? haSensorLabel(zone?.ha_entity_id || source)
: (planZone.device_name || tr('common.device'));
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
const quietText = sim.quiet;
const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`;
const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort');
const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents');
nodes.push(`${esc(planZone.zone_name)}${esc(simulationLaneContext(planZone))}
`);
nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'}));
nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)}));
nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge}));
const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : '';
nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?tr('simulation.running'):tr('simulation.idle')),badgeClass:sim.demand?'active':''}));
nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'}));
links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link'));
links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link'));
links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link'));
links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link'));
links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus'));
if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus'));
});
boardHost.style.width = `${boardW}px`;
boardHost.style.height = `${boardH}px`;
boardHost.innerHTML = `${nodes.join('')}`;
const timelineItems = [];
if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
const unique = [], seen = new Set();
for (const item of timelineItems) {
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break;
}
timelineHost.innerHTML = unique.length ? unique.map(item => {
const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`;
return `${esc(item.scope)}${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}
`;
}).join('') : `${esc(tr('plan.noEvents'))}
`;
const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones));
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `${esc(tr('common.automation'))}
${esc(rule.name)}
${esc(automationTriggerLabel(rule))}${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}
${esc(simulationRuleAction(rule))}
${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
`).join('') : `${esc(tr('simulation.noRules'))}
`;
}
function scheduleControlPlanLoad() {
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
}