This commit is contained in:
Mateusz Gruszczyński
2026-09-01 13:16:10 +02:00
parent 7e0160834d
commit dc20267787
13 changed files with 164 additions and 38 deletions
+12 -2
View File
@@ -87,7 +87,7 @@
<section class="panel dashboard-section-panel">
<div class="dashboard-section-head compact-head">
<div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Thermostats</h2></div>
<div class="dashboard-section-actions"><button type="button" class="secondary" id="cancelAllCompressorTasks" data-compressor-cancel-all data-action="cancel-all-compressor-tasks" data-i18n="zones.cancelAllQueued" hidden>Cancel all waiting</button><span class="badge" id="dashboardZoneCount">0</span></div>
<div class="dashboard-section-actions"><button type="button" class="secondary queue-overview-button" data-action="open-compressor-queue"><span aria-hidden="true"></span><span data-i18n="zones.queueOverview">Queue</span><b class="queue-overview-count" data-compressor-queue-count>0</b></button><span class="badge" id="dashboardZoneCount">0</span></div>
</div>
<div class="list-grid dashboard-zones" id="dashboardZones"></div>
</section>
@@ -113,7 +113,7 @@
</section>
<section class="view" data-view="zones">
<div class="section-heading"><div><span class="eyebrow" data-i18n="zones.automation">Automation</span><h1 data-i18n="nav.zones">Zones</h1></div><div class="section-heading-actions"><button type="button" class="secondary" data-compressor-cancel-all data-action="cancel-all-compressor-tasks" data-i18n="zones.cancelAllQueued" hidden>Cancel all waiting</button><button class="secondary" data-open="zoneDialog" data-i18n="zones.new">New zone</button></div></div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="zones.automation">Automation</span><h1 data-i18n="nav.zones">Zones</h1></div><div class="section-heading-actions"><button type="button" class="secondary queue-overview-button" data-action="open-compressor-queue"><span aria-hidden="true"></span><span data-i18n="zones.queueOverview">Queue</span><b class="queue-overview-count" data-compressor-queue-count>0</b></button><button class="secondary" data-open="zoneDialog" data-i18n="zones.new">New zone</button></div></div>
<p class="lead" data-i18n="zones.description">Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature changes and presets.</p>
<div class="list-grid" id="zoneList"></div>
</section>
@@ -457,6 +457,16 @@
</form>
</dialog>
<dialog id="compressorQueueDialog" class="compressor-queue-dialog">
<div class="dialog-form compressor-queue-dialog-body">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="zones.queueEyebrow">Compressor protection</span><h2 data-i18n="zones.queueTitle">Command queue</h2></div><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="zones.queueHint">Commands waiting for the compressor protection window to end. You can cancel one task or clear the whole queue.</p>
<div class="compressor-queue-summary"><span data-i18n="zones.queuePendingLabel">Waiting</span><strong id="compressorQueueModalCount">0</strong></div>
<div id="compressorQueueList" class="compressor-queue-list"></div>
<div class="form-actions compressor-queue-actions"><button type="button" class="danger" id="compressorQueueCancelAll" data-action="cancel-all-compressor-tasks" data-i18n="zones.cancelAllQueued">Cancel all waiting</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div>
</div>
</dialog>
<dialog id="temporaryThermostatDialog">
<form method="dialog" id="temporaryThermostatForm" class="dialog-form temporary-thermostat-form">
<input type="hidden" name="zone_id">
+65 -4
View File
@@ -126,6 +126,68 @@ function compressorQueuePanel(zone) {
return `<div class="compressor-queue-panel"><div><strong>${esc(tr('zones.compressorQueueTitle'))}</strong><span>${esc(compressorPendingDescription(zone))}</span><small>${esc(tr('zones.compressorQueueUntil', {time:when}))}</small></div><button type="button" class="secondary" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button></div>`;
}
function compressorQueueRemainingText(until) {
const timestamp = until ? new Date(until).getTime() : NaN;
if (!Number.isFinite(timestamp)) return tr('zones.afterProtection');
const seconds = Math.max(0, Math.ceil((timestamp - Date.now()) / 1000));
return seconds > 0 ? tr('zones.queueRemaining', {time:formatExtendedCountdown(seconds)}) : tr('zones.queueReady');
}
function renderCompressorQueueModal() {
const list = $('#compressorQueueList');
const countNode = $('#compressorQueueModalCount');
const cancelAll = $('#compressorQueueCancelAll');
if (!list || !countNode || !cancelAll) return;
const pending = app.zones
.filter(zone => !!zone.compressor_pending_action)
.slice()
.sort((a,b) => {
const at = new Date(a.compressor_pending_until || 0).getTime();
const bt = new Date(b.compressor_pending_until || 0).getTime();
return (Number.isFinite(at) ? at : Number.MAX_SAFE_INTEGER) - (Number.isFinite(bt) ? bt : Number.MAX_SAFE_INTEGER);
});
countNode.textContent = String(pending.length);
cancelAll.disabled = pending.length === 0;
cancelAll.hidden = pending.length === 0;
cancelAll.textContent = pending.length ? `${tr('zones.cancelAllQueued')} (${pending.length})` : tr('zones.cancelAllQueued');
if (!pending.length) {
list.innerHTML = `<div class="compressor-queue-empty"><span aria-hidden="true">✓</span><strong>${esc(tr('zones.queueEmptyTitle'))}</strong><p>${esc(tr('zones.queueEmptyText'))}</p></div>`;
return;
}
list.innerHTML = pending.map((zone,index) => {
const device = app.devices.find(item => item.id === zone.device_id);
const until = zone.compressor_pending_until || zone.lockout_until;
const group = groupControlForZone(zone);
const source = group ? `${zoneControlOwnerLabel(zone)}: ${group.name}` : zoneControlOwnerLabel(zone);
const exact = until ? dateTime(until) : tr('zones.afterProtection');
return `<article class="compressor-queue-item">
<div class="compressor-queue-index">${index + 1}</div>
<div class="compressor-queue-item-main">
<div class="compressor-queue-item-head"><div><span class="eyebrow">${esc(zone.name)}</span><h3>${esc(device?.name || tr('common.noDevice'))}</h3></div><span class="compressor-queue-reason">${esc(tr('zones.queueReasonProtection'))}</span></div>
<p class="compressor-queue-command">${esc(compressorPendingDescription(zone))}</p>
<div class="compressor-queue-meta">
<span>${esc(tr('zones.queueSource', {source}))}</span>
<span>${esc(tr('zones.compressorQueueUntil', {time:exact}))}</span>
<strong data-compressor-queue-countdown="${esc(until || '')}">${esc(compressorQueueRemainingText(until))}</strong>
</div>
</div>
<button type="button" class="secondary compressor-queue-cancel" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button>
</article>`;
}).join('');
}
function updateCompressorQueueCountdowns() {
$$('[data-compressor-queue-countdown]').forEach(node => {
node.textContent = compressorQueueRemainingText(node.dataset.compressorQueueCountdown);
});
}
function openCompressorQueueDialog() {
renderCompressorQueueModal();
openDialog('compressorQueueDialog');
updateCompressorQueueCountdowns();
}
function zoneControlOwnerLabel(zone) {
const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
const source=zone.control_source || '';
@@ -265,10 +327,9 @@ function renderZones() {
const dashboardCount = $('#dashboardZoneCount');
if (dashboardCount) dashboardCount.textContent = String(app.zones.length);
const pendingCount = app.zones.filter(zone => !!zone.compressor_pending_action).length;
$$('[data-compressor-cancel-all]').forEach(cancelAll => {
cancelAll.hidden = pendingCount === 0;
cancelAll.textContent = pendingCount ? `${tr('zones.cancelAllQueued')} (${pendingCount})` : tr('zones.cancelAllQueued');
});
$$('[data-compressor-queue-count]').forEach(node => { node.textContent = String(pendingCount); });
$$('.queue-overview-button').forEach(button => button.classList.toggle('has-items', pendingCount > 0));
renderCompressorQueueModal();
}
function groupZones(group) {
+2 -1
View File
@@ -144,8 +144,9 @@ document.addEventListener('click', async event => {
if(!Number.isFinite(value) || value<8 || value>30) return toast(tr('groups.customTemperatureRange'),true);
return sendGroupControl(button.dataset.id,{preset:'custom',setpoint:Math.round(value*10)/10});
}
if (action === 'open-compressor-queue') return openCompressorQueueDialog();
if (action === 'cancel-compressor-task') return cancelCompressorTask(button.dataset.id);
if (action === 'cancel-all-compressor-tasks') return cancelAllCompressorTasks();
if (action === 'cancel-all-compressor-tasks') { if (!confirm(tr('zones.queueCancelAllConfirm', {count:app.zones.filter(zone => !!zone.compressor_pending_action).length}))) return; return cancelAllCompressorTasks(); }
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
+1
View File
@@ -17,4 +17,5 @@ async function startApplication() {
setInterval(updateLocalThermostatCountdowns, 1000);
setInterval(updateTemporaryThermostatCountdowns, 1000);
setInterval(updateCompressorQueueCountdowns, 1000);
startApplication().catch(error => console.error('Application startup failed:', error));
+29
View File
@@ -1421,3 +1421,32 @@ textarea[aria-invalid="true"] {
.compressor-queue-panel strong { font-size:.88rem; }
.compressor-queue-panel span,.compressor-queue-panel small { overflow-wrap:anywhere; }
.compressor-queue-panel button { flex:0 0 auto; }
/* v0.9.1: global compressor queue overview. */
.queue-overview-button { display:inline-flex; align-items:center; gap:7px; }
.queue-overview-count { display:inline-grid; place-items:center; min-width:23px; height:23px; padding:0 6px; border-radius:999px; background:color-mix(in srgb,var(--text) 9%,transparent); font-size:.78rem; }
.queue-overview-button.has-items { border-color:var(--warning, #b7791f); box-shadow:0 0 0 1px color-mix(in srgb,var(--warning, #b7791f) 22%,transparent); }
.queue-overview-button.has-items .queue-overview-count { background:color-mix(in srgb,var(--warning, #b7791f) 18%,transparent); color:var(--text); }
.compressor-queue-dialog { width:min(780px, calc(100% - 24px)); }
.compressor-queue-dialog-body { gap:16px; }
.compressor-queue-summary { display:flex; align-items:center; justify-content:space-between; padding:10px 13px; border:1px solid var(--line); border-radius:14px; background:var(--surface); }
.compressor-queue-summary span { color:var(--muted); font-size:.86rem; }
.compressor-queue-summary strong { font-size:1.15rem; }
.compressor-queue-list { display:grid; gap:10px; }
.compressor-queue-item { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:12px; padding:14px; border:1px solid var(--line); border-radius:16px; background:linear-gradient(135deg,color-mix(in srgb,var(--warning, #b7791f) 7%,var(--surface)),var(--surface)); }
.compressor-queue-index { display:grid; place-items:center; width:32px; height:32px; border-radius:10px; background:color-mix(in srgb,var(--warning, #b7791f) 15%,transparent); font-weight:800; }
.compressor-queue-item-main { min-width:0; display:grid; gap:8px; }
.compressor-queue-item-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.compressor-queue-item-head h3 { margin:2px 0 0; font-size:1rem; }
.compressor-queue-reason { flex:0 0 auto; padding:5px 8px; border-radius:999px; background:color-mix(in srgb,var(--warning, #b7791f) 14%,transparent); font-size:.73rem; font-weight:700; }
.compressor-queue-command { margin:0; font-weight:700; overflow-wrap:anywhere; }
.compressor-queue-meta { display:flex; flex-wrap:wrap; gap:6px 12px; color:var(--muted); font-size:.78rem; }
.compressor-queue-meta strong { color:var(--text); font-weight:700; }
.compressor-queue-cancel { align-self:center; }
.compressor-queue-empty { display:grid; justify-items:center; gap:7px; padding:30px 18px; text-align:center; border:1px dashed var(--line); border-radius:16px; color:var(--muted); }
.compressor-queue-empty > span { display:grid; place-items:center; width:42px; height:42px; border-radius:999px; background:var(--surface); font-size:1.35rem; color:var(--text); }
.compressor-queue-empty strong { color:var(--text); font-size:1rem; }
.compressor-queue-empty p { margin:0; max-width:440px; }
.compressor-queue-actions { position:sticky; bottom:0; padding-top:10px; background:linear-gradient(transparent,var(--dialog-bg) 28%); }
@media (max-width:620px) { .compressor-queue-item { grid-template-columns:auto minmax(0,1fr); } .compressor-queue-cancel { grid-column:1 / -1; width:100%; } .compressor-queue-item-head { display:grid; } .compressor-queue-reason { justify-self:start; } }