feat: add active time window to countdown rules

UI: Active Window Start/End time inputs on countdown form.
Leave blank = runs any time. End before start = crosses midnight.
Scheduler: checks current time against window before starting timer;
supports cross-midnight windows (e.g. 9:00 AM to 4:00 AM next day).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SRS IT
2026-03-28 22:32:29 -04:00
parent e8b365e5a7
commit 3c155f7cfd
3 changed files with 47 additions and 10 deletions
@@ -394,10 +394,6 @@
</div> </div>
<div id="dwm-countdown-fields" style="display:none"> <div id="dwm-countdown-fields" style="display:none">
<div class="form-group">
<label>Countdown Duration (minutes)</label>
<input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" />
</div>
<div class="form-group"> <div class="form-group">
<label>Condition</label> <label>Condition</label>
<select id="dwm-countdown-action"> <select id="dwm-countdown-action">
@@ -405,6 +401,22 @@
<option value="off_to_on">If device turns OFF → auto-ON after duration</option> <option value="off_to_on">If device turns OFF → auto-ON after duration</option>
</select> </select>
</div> </div>
<div class="form-group">
<label>Countdown Duration (minutes)</label>
<input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" />
</div>
<div class="flex-row">
<div class="form-group" style="flex:1">
<label>Active Window Start</label>
<input type="text" id="dwm-countdown-window-start" placeholder="e.g. 9:00 AM" />
</div>
<div class="form-group" style="flex:1">
<label>Active Window End</label>
<input type="text" id="dwm-countdown-window-end" placeholder="e.g. 4:00 AM" />
<div style="font-size:0.75rem;color:var(--muted);margin-top:3px">End before start = next day</div>
</div>
</div>
<div style="font-size:0.75rem;color:var(--muted);margin-bottom:8px">Leave window blank to run at any time.</div>
</div> </div>
<div id="dwm-alwayson-info" style="display:none;margin-bottom:12px;padding:10px;background:rgba(48,209,88,.1);border:1px solid rgba(48,209,88,.3);border-radius:6px;font-size:0.82rem;color:#4ade80"> <div id="dwm-alwayson-info" style="display:none;margin-bottom:12px;padding:10px;background:rgba(48,209,88,.1);border:1px solid rgba(48,209,88,.3);border-radius:6px;font-size:0.82rem;color:#4ade80">
@@ -197,7 +197,10 @@ function dwmRuleSummary(r) {
if (r.type === 'Countdown') { if (r.type === 'Countdown') {
const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null; const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null;
const cond = r.countdownAction === 'off_to_on' ? 'OFF→ON' : 'ON→OFF'; const cond = r.countdownAction === 'off_to_on' ? 'OFF→ON' : 'ON→OFF';
return mins ? `${mins} min · ${cond}` : '—'; const win = (r.windowStart >= 0 && r.windowEnd >= 0)
? ` · ${secsToHHMM(r.windowStart)}${secsToHHMM(r.windowEnd)}`
: (r.windowStart >= 0 ? ` · from ${secsToHHMM(r.windowStart)}` : '');
return mins ? `${mins} min · ${cond}${win}` : '—';
} }
const days = dayLabel(r.days); const days = dayLabel(r.days);
const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets'; const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets';
@@ -444,6 +447,8 @@ function openDwmEdit(id) {
document.getElementById('dwm-countdown-mins').value = document.getElementById('dwm-countdown-mins').value =
r.countdownTime ? String(Math.round(r.countdownTime / 60)) : ''; r.countdownTime ? String(Math.round(r.countdownTime / 60)) : '';
document.getElementById('dwm-countdown-action').value = r.countdownAction ?? 'on_to_off'; document.getElementById('dwm-countdown-action').value = r.countdownAction ?? 'on_to_off';
document.getElementById('dwm-countdown-window-start').value = r.windowStart >= 0 ? secsToHHMM(r.windowStart) : '';
document.getElementById('dwm-countdown-window-end').value = r.windowEnd >= 0 ? secsToHHMM(r.windowEnd) : '';
_selectedDwmDays = new Set((r.days ?? []).map(Number)); _selectedDwmDays = new Set((r.days ?? []).map(Number));
@@ -478,6 +483,8 @@ function openDwmEdit(id) {
document.getElementById('dwm-end-action').value = '-1'; document.getElementById('dwm-end-action').value = '-1';
document.getElementById('dwm-countdown-mins').value = ''; document.getElementById('dwm-countdown-mins').value = '';
document.getElementById('dwm-countdown-action').value = 'on_to_off'; document.getElementById('dwm-countdown-action').value = 'on_to_off';
document.getElementById('dwm-countdown-window-start').value = '';
document.getElementById('dwm-countdown-window-end').value = '';
document.getElementById('dwm-trigger-src').value = ''; document.getElementById('dwm-trigger-src').value = '';
document.getElementById('dwm-trigger-event').value = 'any'; document.getElementById('dwm-trigger-event').value = 'any';
document.getElementById('dwm-trigger-action').value = 'on'; document.getElementById('dwm-trigger-action').value = 'on';
@@ -609,6 +616,10 @@ document.getElementById('dwm-form-save-btn').addEventListener('click', async ()
if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; } if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; }
rule.countdownTime = mins * 60; rule.countdownTime = mins * 60;
rule.countdownAction = document.getElementById('dwm-countdown-action').value; rule.countdownAction = document.getElementById('dwm-countdown-action').value;
const winStart = hhmmToSecs(document.getElementById('dwm-countdown-window-start').value);
const winEnd = hhmmToSecs(document.getElementById('dwm-countdown-window-end').value);
rule.windowStart = winStart >= 0 ? winStart : -1;
rule.windowEnd = winEnd >= 0 ? winEnd : -1;
} else { } else {
const startType = document.getElementById('dwm-start-type').value; const startType = document.getElementById('dwm-start-type').value;
const startOffset = parseInt(document.getElementById('dwm-start-offset').value ?? '0', 10) || 0; const startOffset = parseInt(document.getElementById('dwm-start-offset').value ?? '0', 10) || 0;
+15 -1
View File
@@ -587,16 +587,30 @@ class DwmScheduler {
} }
} }
// ── Countdown — fire only when state matches configured condition ── // ── Countdown — fire only when state matches condition and within window ──
if (countdownDevMap.has(key)) { if (countdownDevMap.has(key)) {
const prevState = this._countdownStates.get(key); const prevState = this._countdownStates.get(key);
this._countdownStates.set(key, isOn); this._countdownStates.set(key, isOn);
if (prevState !== undefined && prevState !== isOn) { if (prevState !== undefined && prevState !== isOn) {
const nowSecs = secondsFromMidnight(new Date());
for (const { rule, td } of countdownDevMap.get(key)) { for (const { rule, td } of countdownDevMap.get(key)) {
const condition = rule.countdownAction ?? 'on_to_off'; const condition = rule.countdownAction ?? 'on_to_off';
const triggered = condition === 'on_to_off' ? isOn : !isOn; const triggered = condition === 'on_to_off' ? isOn : !isOn;
if (!triggered) continue; // state doesn't match this rule's condition if (!triggered) continue; // state doesn't match this rule's condition
// Check active window (if defined)
const winStart = Number(rule.windowStart ?? -1);
const winEnd = Number(rule.windowEnd ?? -1);
if (winStart >= 0 && winEnd >= 0) {
const crossesMidnight = winEnd < winStart;
const inWindow = crossesMidnight
? (nowSecs >= winStart || nowSecs <= winEnd)
: (nowSecs >= winStart && nowSecs <= winEnd);
if (!inWindow) continue; // outside active window
} else if (winStart >= 0) {
if (nowSecs < winStart) continue;
}
const timerKey = `${key}-${rule.id}`; const timerKey = `${key}-${rule.id}`;
// Cancel any pending timer for this device+rule // Cancel any pending timer for this device+rule
const existing = this._countdownTimers.get(timerKey); const existing = this._countdownTimers.get(timerKey);