screentinker/server/services/scheduler.js
Claude 3c1b2f62ee Make a recurring schedule respect its start and end dates
A recurring schedule ran forever. The engine compared weekday and HH:MM and dropped the date
component entirely, so recurrence_end was never read: a campaign set to finish on the 1st was still
switching screens weeks later. The same omission made a recurring schedule live before its start
date.

The calendar does read recurrence_end, so it drew the campaign as finished while the screens kept
obeying it — the two views disagreeing is what made this hard to see from the dashboard. The end
date is offered on the form, so it has to mean something.

The date window is inclusive at both ends: an end date of the 5th means the 5th runs to its normal
end time, which is what someone choosing that date means. An open-ended recurring schedule is
untouched and still runs indefinitely.

NOTE, because this one really does change live screens: any recurring schedule that has been running
past its end date will now stop. That is the intended behaviour and was confirmed before making the
change, but it is the difference between this commit and the calendar fix alongside it, which
changes only what is drawn.

6 tests: stops after the end date, the final day still runs in full, does not run before the start
date, unchanged inside the window, open-ended schedules unaffected, one-offs unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:22:59 -05:00

192 lines
8.4 KiB
JavaScript

const { db } = require('../db/database');
const { _localParts } = require('../lib/schedule-eval');
let io = null;
function startScheduler(socketIo) {
io = socketIo;
// Check schedules every 60 seconds
setInterval(evaluateSchedules, 60000);
console.log('Scheduler service started');
}
// Track which devices have a schedule override active so we can revert
const activeOverrides = new Map(); // deviceId -> { playlist_id, layout_id }
function evaluateSchedules() {
const deviceNs = io?.of('/device');
if (!deviceNs) return;
const now = new Date();
const onlineDevices = db.prepare("SELECT * FROM devices WHERE status = 'online'").all();
for (const device of onlineDevices) {
// #12 scheduled reboot — evaluated independently of playlist/layout overrides.
maybeRebootDevice(device, now, deviceNs);
const schedules = db.prepare(`
SELECT s.*
FROM schedules s
WHERE s.enabled = 1
AND (
s.device_id = ?
OR s.group_id IN (
SELECT group_id FROM device_group_members WHERE device_id = ?
)
)
ORDER BY
CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC,
s.priority DESC,
s.created_at ASC
`).all(device.id, device.id);
const active = schedules.find(s => isScheduleActiveNow(s, now, deviceTz(device)));
const override = activeOverrides.get(device.id);
let changed = false;
if (active) {
// Apply layout override if schedule has one
if (active.layout_id && active.layout_id !== device.layout_id) {
if (!override) activeOverrides.set(device.id, { layout_id: device.layout_id, playlist_id: device.playlist_id });
db.prepare("UPDATE devices SET layout_id = ? WHERE id = ?").run(active.layout_id, device.id);
changed = true;
}
// Apply playlist override if schedule has one
if (active.playlist_id && active.playlist_id !== device.playlist_id) {
if (!override) activeOverrides.set(device.id, { layout_id: device.layout_id, playlist_id: device.playlist_id });
db.prepare("UPDATE devices SET playlist_id = ? WHERE id = ?").run(active.playlist_id, device.id);
changed = true;
}
} else if (override) {
// No active schedule — revert to original playlist/layout
db.prepare("UPDATE devices SET playlist_id = ?, layout_id = ? WHERE id = ?")
.run(override.playlist_id, override.layout_id, device.id);
activeOverrides.delete(device.id);
changed = true;
}
if (changed) pushPlaylistToDevice(device.id, deviceNs);
}
}
// #74/#75 Part B: device-level schedules are evaluated in the DEVICE's effective
// timezone, not the server's. We reuse the canonical UTC->local conversion
// (_localParts from schedule-eval.js) - no second conversion path. start_time/end_time
// are stored as device-local wall-clock datetimes, so we compare them to a device-local
// "now". tz === null (no override AND no reported zone) falls back to the server clock,
// preserving the pre-existing behaviour for un-migrated / non-reporting devices.
function deviceTz(device) {
const override = (device.timezone && device.timezone !== 'UTC') ? device.timezone : null;
return override || device.reported_timezone || null;
}
// #12 scheduled reboot: resolve the device's effective nightly-reboot time. A device's
// own reboot_schedule wins; otherwise the first group it belongs to that sets one. null = off.
function effectiveRebootSchedule(device) {
if (device.reboot_schedule) return device.reboot_schedule;
const row = db.prepare(`
SELECT g.reboot_schedule
FROM device_groups g
JOIN device_group_members m ON m.group_id = g.id
WHERE m.device_id = ? AND g.reboot_schedule IS NOT NULL AND g.reboot_schedule != ''
ORDER BY g.created_at ASC
LIMIT 1
`).get(device.id);
return row?.reboot_schedule || null;
}
// Fires at most once per device-local day. The 5-minute catch window makes the fire
// robust to 60s-tick drift (a tick that lands at :59 then :59 next minute never skips the
// target minute). reboot_last_date (device-local YYYY-MM-DD) is the once-per-day guard.
const REBOOT_WINDOW_MIN = 5;
// Pure decision: given a "HH:MM" schedule, the device-local `now`, the device's tz, and the
// last fired date, return { due, today }. `today` is the device-local YYYY-MM-DD to stamp on
// fire. Extracted so it's unit-testable with no DB / socket. Invalid/off schedule -> not due.
function rebootDue(schedule, tz, now, lastDate) {
if (!schedule) return { due: false, today: null };
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(schedule).trim());
if (!m) return { due: false, today: null };
const schedMin = parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
if (schedMin > 1439) return { due: false, today: null };
const L = _localParts(now, tz);
const p2 = (n) => (n < 10 ? '0' : '') + n;
const today = `${L.y}-${p2(L.mo)}-${p2(L.day)}`;
const inWindow = L.min >= schedMin && L.min < schedMin + REBOOT_WINDOW_MIN;
return { due: inWindow && lastDate !== today, today };
}
function maybeRebootDevice(device, now, deviceNs) {
const { due, today } = rebootDue(effectiveRebootSchedule(device), deviceTz(device), now, device.reboot_last_date);
if (!due) return;
db.prepare('UPDATE devices SET reboot_last_date = ? WHERE id = ?').run(today, device.id);
deviceNs.to(device.id).emit('device:command', { type: 'reboot', payload: { scheduled: true } });
console.log(`[reboot] scheduled reboot fired for device ${device.id} (${device.name || 'unnamed'}) at local ${today}`);
}
function localStamp(parts) {
const p2 = (n) => (n < 10 ? '0' : '') + n;
const hh = Math.floor(parts.min / 60), mm = parts.min % 60;
return `${parts.y}-${p2(parts.mo)}-${p2(parts.day)}T${p2(hh)}:${p2(mm)}`;
}
function isScheduleActiveNow(schedule, now, tz) {
const L = _localParts(now, tz);
const nowStamp = localStamp(L); // device-local "YYYY-MM-DDTHH:MM"
const startStamp = String(schedule.start_time).slice(0, 16);
const endStamp = String(schedule.end_time).slice(0, 16);
if (!schedule.recurrence) {
return nowStamp >= startStamp && nowStamp <= endStamp;
}
const rule = parseSimpleRRule(schedule.recurrence);
if (!rule) return nowStamp >= startStamp && nowStamp <= endStamp;
// The DATE window. A recurring schedule was previously compared on weekday and HH:MM alone, with
// the date component dropped entirely — so it was live before its start date and, more visibly,
// carried on forever after its end date. A campaign set to finish on the 1st was still switching
// screens weeks later, while the calendar (which does read recurrence_end) showed it as stopped.
// The end date is offered on the form; it has to mean something.
const nowDate = nowStamp.slice(0, 10);
if (nowDate < startStamp.slice(0, 10)) return false; // has not begun yet
if (schedule.recurrence_end) {
// Inclusive: an end date of the 5th means the 5th still runs, to its normal end time.
if (nowDate > String(schedule.recurrence_end).slice(0, 10)) return false;
}
// Day-of-week in the device's local zone.
if (rule.byDay && !rule.byDay.includes(L.dow)) return false;
// Time-of-day window in the device's local zone (HH:MM string compare).
const nowHM = nowStamp.slice(11), startHM = startStamp.slice(11), endHM = endStamp.slice(11);
return nowHM >= startHM && nowHM <= endHM;
}
function parseSimpleRRule(rrule) {
if (!rrule) return null;
const parts = rrule.split(';');
const rule = {};
const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
for (const part of parts) {
const [key, val] = part.split('=');
if (key === 'FREQ') rule.freq = val;
if (key === 'BYDAY') rule.byDay = val.split(',').map(d => dayMap[d]).filter(d => d !== undefined);
if (key === 'INTERVAL') rule.interval = parseInt(val);
}
return rule;
}
function pushPlaylistToDevice(deviceId, deviceNs) {
// Use the single-source buildPlaylistPayload from deviceSocket
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, deviceId, buildPlaylistPayload);
}
module.exports = { startScheduler, pushPlaylistToDevice, rebootDue };
// Exported for testing: whether a schedule is live right now is the single decision this service
// exists to make, and it should be checkable without a ticking timer.
module.exports.isScheduleActiveNow = isScheduleActiveNow;