This commit is contained in:
Mateusz Gruszczyński
2026-09-02 09:17:56 +02:00
parent 43441d2fb7
commit 87b68187e7
13 changed files with 119 additions and 58 deletions
+26 -22
View File
@@ -88,7 +88,7 @@
<div class="dashboard-global-divider"></div>
<div class="house-climate dashboard-house-climate" id="houseClimate"></div>
</div>
<section class="panel dashboard-section-panel">
<section class="panel dashboard-section-panel" id="controlPlanSection">
<div class="dashboard-section-head compact-head">
<div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span>
<h2 data-i18n="plan.title">What happens next</h2>
@@ -693,7 +693,7 @@
<section id="flowEditor" class="flow-editor" hidden aria-label="Flow editor">
<header class="flow-editor-bar">
<div class="flow-editor-title"><button type="button" class="icon-button" data-action="close-flow-editor" aria-label="Back"></button><div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span><input id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder" placeholder="My automation"></div></div>
<div class="flow-editor-title"><button type="button" class="icon-button" data-action="close-flow-editor" aria-label="Back"></button><div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span><input id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder" data-i18n-title="flow.nameAliasHint" placeholder="My automation"></div></div>
<div class="flow-editor-actions"><button class="secondary" type="button" data-action="flow-templates" data-i18n="flow.templates">Templates</button><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import</button><button class="secondary" type="button" data-action="export-flow" data-i18n="flow.export">Export</button><button class="secondary" type="button" data-action="flow-dry-run" data-i18n="flow.dryRun">Dry-run</button><button class="secondary" type="button" data-action="flow-logs" data-i18n="flow.logs">Logs</button><label class="flow-enabled"><input type="checkbox" id="flowEnabled" checked><span data-i18n="common.enabled">Enabled</span></label><span id="flowCompileStatus" class="badge"></span><button class="primary" type="button" data-action="save-flow" data-i18n="actions.save">Save</button></div>
</header>
<div class="flow-editor-body">
@@ -721,7 +721,7 @@
<input id="flowImportFile" type="file" accept="application/json,.json,.flow.json" hidden>
<dialog id="flowSharedInputDialog" class="flow-tool-dialog">
<dialog id="flowSharedInputDialog">
<form id="flowSharedInputForm" class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.sharedInputEyebrow">Wspólne dane</span><h2 id="flowSharedInputDialogTitle" data-i18n="flow.sharedInputTitle">Wspólne wejście Flow</h2></div><button type="button" data-close>×</button></div>
<input type="hidden" name="id">
@@ -733,32 +733,36 @@
<button type="button" class="secondary" id="flowSharedInputTest" data-i18n="flow.sharedInputTest">Testuj wejście HA</button>
<div id="flowSharedInputTestResult" class="flow-shared-test-result" hidden></div>
</div>
<div class="dialog-actions flow-shared-dialog-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div>
</form>
</dialog>
<dialog id="flowTemplateDialog" class="flow-tool-dialog">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
<div class="flow-template-toolbar"><label><span data-i18n="flow.templateSearchLabel">Szukaj presetów</span><input id="flowTemplateSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.templateSearch" placeholder="Szukaj presetów…"></label></div>
<div class="flow-template-browser">
<div class="flow-template-catalog">
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div>
<div id="flowTemplateList" class="flow-template-list"></div>
<dialog id="flowTemplateDialog" class="dialog-wide">
<div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
<div class="flow-template-toolbar"><label><span data-i18n="flow.templateSearchLabel">Szukaj presetów</span><input id="flowTemplateSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.templateSearch" placeholder="Szukaj presetów…"></label></div>
<div class="flow-template-browser">
<div class="flow-template-catalog">
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div>
<div id="flowTemplateList" class="flow-template-list"></div>
</div>
<aside id="flowTemplatePreview" class="flow-template-preview"><div class="empty compact"><strong data-i18n="flow.templatePreview">Podgląd presetu</strong><span data-i18n="flow.templateSelectPreview">Wybierz preset, aby zobaczyć podgląd.</span></div></aside>
</div>
<aside id="flowTemplatePreview" class="flow-template-preview"><div class="empty compact"><strong data-i18n="flow.templatePreview">Podgląd presetu</strong><span data-i18n="flow.templateSelectPreview">Wybierz preset, aby zobaczyć podgląd.</span></div></aside>
</div>
</dialog>
<dialog id="flowTestDialog" class="flow-tool-dialog">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.testMode">Flow diagnostics</span><h2 id="flowTestTitle" data-i18n="flow.dryRun">Dry-run</h2></div><button type="button" data-close>×</button></div>
<div id="flowSimulationControls" class="flow-simulation-controls">
<p class="field-note" data-i18n="flow.dryRunHint">Dry-run does not change thermostat, device, group, schedule or automation state.</p>
<label><span data-i18n="flow.simulationTime">Simulation date and time</span><input id="flowSimulationAt" type="datetime-local"></label>
<div><strong data-i18n="flow.simulationOverrides">Sensor overrides</strong><p class="field-note" data-i18n="flow.simulationOverridesHint">Leave blank to use the current application or Home Assistant state.</p><div id="flowSimulationOverrides" class="flow-simulation-overrides"></div></div>
<div class="dialog-actions"><button class="primary" type="button" data-action="run-flow-dry-run" data-i18n="flow.runSimulation">Run simulation</button></div>
<dialog id="flowTestDialog" class="dialog-wide">
<div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.testMode">Flow diagnostics</span><h2 id="flowTestTitle" data-i18n="flow.dryRun">Dry-run</h2></div><button type="button" data-close>×</button></div>
<div id="flowSimulationControls" class="flow-simulation-controls">
<p class="field-note" data-i18n="flow.dryRunHint">Dry-run does not change thermostat, device, group, schedule or automation state.</p>
<label><span data-i18n="flow.simulationTime">Simulation date and time</span><input id="flowSimulationAt" type="datetime-local"></label>
<div><strong data-i18n="flow.simulationOverrides">Sensor overrides</strong><p class="field-note" data-i18n="flow.simulationOverridesHint">Leave blank to use the current application or Home Assistant state.</p><div id="flowSimulationOverrides" class="flow-simulation-overrides"></div></div>
<div class="form-actions"><button class="primary" type="button" data-action="run-flow-dry-run" data-i18n="flow.runSimulation">Run simulation</button></div>
</div>
<div id="flowTestResults" class="flow-test-results"></div>
</div>
<div id="flowTestResults" class="flow-test-results"></div>
</dialog>
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
@@ -906,7 +910,7 @@
data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5"
name="heat_away_setpoint" value="17"></label></div>
</details>
<div class="two">
<div class="two zone-hysteresis-row">
<div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis
°C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis"
value="0.6"></label><label class="check" data-i18n-title="zones.separateHysteresisHint"
+18 -5
View File
@@ -78,15 +78,17 @@ function planZoneEffectiveEnabled(zone) {
function renderControlPlan() {
const host = $('#controlPlan'); if (!host) return;
const section = $('#controlPlanSection');
const plan = app.controlPlan;
if (!plan) {
if (section) section.hidden = false;
host.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
return;
}
const allZones = plan.zones || [];
const houseEvents = (plan.next_events || []).slice(0, 3);
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', { zones: allZones.filter(planZoneEffectiveEnabled).length, demand: allZones.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length }))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
const house = houseEvents.length ? `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', { zones: allZones.filter(planZoneEffectiveEnabled).length, demand: allZones.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length }))}</p><ul class="plan-events">${houseEvents.map(planEventMarkup).join('')}</ul></article>` : '';
const groupCards = (app.groups || []).map(group => {
const memberIds = new Set(group.zone_ids || []);
@@ -98,7 +100,8 @@ function renderControlPlan() {
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 `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', { zones: members.length, demand }))}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}</li>`}</ul></article>`;
if (!events.length) return '';
return `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', { zones: members.length, demand }))}</p><ul class="plan-events">${events.map(planEventMarkup).join('')}</ul></article>`;
}).join('');
const zoneGroups = new Map();
@@ -109,16 +112,26 @@ function renderControlPlan() {
}));
const zones = allZones.map(zone => {
const events = (zone.next_events || []).slice(0, 2);
if (!events.length) return '';
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 `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (planZoneEffectiveEnabled(zone) && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
return `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (planZoneEffectiveEnabled(zone) && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.map(planEventMarkup).join('')}</ul></article>`;
}).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled);
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => `<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)}${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}</span></li>`).join('')}</ul></article>` : '';
host.innerHTML = house + groupCards + zones + ruleCard;
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => {
const automation = (app.automations || []).find(item => item.id === rule.id);
const flow = automation?.flow_id ? (app.flows || []).find(item => item.id === automation.flow_id) : null;
const displayName = flow?.name || rule.name;
const target = rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || '');
return `<li><time>${esc(automationTriggerLabel(rule))}</time><span class="plan-rule-summary"><strong>${esc(displayName)}</strong>${target ? `<small>→ ${esc(target)}</small>` : ''}</span></li>`;
}).join('')}</ul></article>` : '';
const content = house + groupCards + zones + ruleCard;
host.innerHTML = content;
if (section) section.hidden = !content;
}
async function loadControlPlan() {
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
+1 -1
View File
@@ -477,7 +477,7 @@ function renderFlowPresetPreview(preset) {
<div class="flow-template-preview-stats"><span>${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}</span><span>${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}</span></div>
${flowPresetPreviewGraph(preset)}
<div class="flow-template-requirements"><strong>${esc(tr('flow.templateRequirements'))}</strong><div>${reqMarkup}</div>${req.missing.length ? `<p class="field-note warning-note">${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}</p>` : ''}</div>
<div class="dialog-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`;
<div class="form-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`;
}
function flowPresetCategoryLabel(key) { return tr(`flow.templateCategory.${key}`) === `flow.templateCategory.${key}` ? key : tr(`flow.templateCategory.${key}`); }
+47 -9
View File
@@ -1195,6 +1195,10 @@ dialog::backdrop {
backdrop-filter: blur(4px);
}
.dialog-wide {
width: min(860px, calc(100% - 24px));
}
.dialog-form {
display: grid;
gap: 14px;
@@ -4869,6 +4873,45 @@ body.simulation-standalone [data-view="simulation"] {
white-space: nowrap;
}
#controlPlan .plan-rules {
gap: 10px;
}
#controlPlan .plan-rules .plan-card-head {
padding-bottom: 2px;
}
#controlPlan .plan-rules .plan-events {
gap: 8px;
padding-top: 9px;
}
#controlPlan .plan-rules .plan-events li {
align-items: start;
gap: 8px;
}
#controlPlan .plan-events .plan-rule-summary {
display: grid;
gap: 2px;
overflow: visible;
text-overflow: clip;
white-space: normal;
}
#controlPlan .plan-rule-summary strong {
color: var(--text);
font-size: 10px;
font-weight: 650;
line-height: 1.35;
}
#controlPlan .plan-rule-summary small {
color: var(--muted);
font-size: 9px;
line-height: 1.35;
}
#controlPlan .plan-events .muted {
white-space: normal;
}
@@ -6194,9 +6237,6 @@ body.flow-editor-open { overflow:hidden; }
}
/* Flow diagnostics, templates and portable Flow files */
.flow-tool-dialog { width:min(860px,calc(100vw - 32px)); max-height:min(820px,calc(100vh - 32px)); overflow:auto; border:1px solid var(--line); border-radius:16px; background:var(--surface); color:var(--text); padding:18px; box-shadow:0 24px 70px rgba(0,0,0,.32); }
.flow-tool-dialog::backdrop { background:rgba(0,0,0,.48); }
.flow-template-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; margin-top:16px; }
.flow-template-card { display:grid; gap:7px; text-align:left; align-content:start; min-height:118px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); color:var(--text); padding:14px; }
.flow-template-card:hover { border-color:var(--accent); }
.flow-template-card span { color:var(--muted); font-size:.86rem; line-height:1.4; }
@@ -6246,8 +6286,6 @@ body.flow-editor-open { overflow:hidden; }
.flow-shared-input-fields { display:grid; gap:12px; }
.flow-shared-input-fields .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.flow-template-list { display:grid; grid-template-columns:1fr; gap:22px; }
.flow-template-category { display:grid; gap:10px; }
.flow-template-category + .flow-template-category { padding-top:18px; border-top:1px solid var(--line); }
.flow-template-category-head { display:grid; gap:3px; }
.flow-template-category-head span { color:var(--muted); font-size:.84rem; }
.flow-template-category-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; }
@@ -6263,8 +6301,6 @@ body.flow-editor-open { overflow:hidden; }
.flow-shared-settings-head { justify-content:space-between; align-items:flex-start; }
.flow-shared-settings-head > div { min-width:0; }
.flow-shared-add-button { flex:0 0 auto; align-self:flex-start; margin-left:auto; }
.flow-shared-dialog-actions { display:flex; align-items:center; justify-content:space-between; gap:24px; padding-top:14px; margin-top:4px; border-top:1px solid var(--line); }
.flow-shared-dialog-actions > button { min-width:112px; }
.flow-shared-settings-link { margin-top:12px; width:max-content; max-width:100%; }
.flow-shared-settings.is-linked-target { outline:2px solid var(--accent); outline-offset:3px; }
.flow-template-tabs { display:flex; gap:8px; overflow-x:auto; padding:4px 0 10px; margin-top:14px; border-bottom:1px solid var(--line); scrollbar-width:thin; }
@@ -6276,7 +6312,6 @@ body.flow-editor-open { overflow:hidden; }
@media (max-width:760px) {
.flow-shared-settings-head { gap:12px; }
.flow-shared-add-button { width:auto; }
.flow-shared-dialog-actions { gap:16px; }
}
/* v0.9.10: Flow preset library preview, favorites/search and shared-input diagnostics */
@@ -6328,7 +6363,7 @@ body.flow-editor-open { overflow:hidden; }
.flow-template-favorite { width:34px; height:34px; border:0; border-radius:50%; background:transparent; color:var(--muted); cursor:pointer; font-size:1.15rem; line-height:1; }
.flow-template-card > .flow-template-favorite { position:absolute; right:7px; top:7px; }
.flow-template-favorite.active { color:var(--accent); background:var(--accent-soft); }
.flow-template-preview .dialog-actions { margin-top:0; padding-top:10px; border-top:1px solid var(--line); justify-content:flex-end; }
.flow-template-preview .form-actions { margin-top:0; padding-top:10px; border-top:1px solid var(--line); }
@media (max-width:900px) {
.flow-template-browser { grid-template-columns:1fr; }
@@ -6341,3 +6376,6 @@ body.flow-editor-open { overflow:hidden; }
.flow-shared-test-result dl > div { grid-template-columns:1fr; gap:2px; }
.flow-template-preview-canvas { height:220px; }
}
/* Keep zone hysteresis/source controls aligned without stretching selects. */
.zone-hysteresis-row { align-items:start; }