Store a schedule in the timezone its screen runs in

Creation and playback disagreed about which clock a schedule's hours are on.
The player evaluated blocks in the device's zone — an operator override, else
whatever the player's OS reported. Creation defaulted to a bare 'UTC', because
the dialog never asked for a zone and the server filled the silence with one.

So hours typed as "09:00 to 17:00" were stored as UTC and evaluated somewhere
else. For anyone outside UTC the schedule was correct and appeared to do
nothing, opening hours later than intended, with nothing on screen to explain
why. A user in Asia/Tokyo hit exactly this and reported it as "I added
something and it didn't appear".

Both sides now resolve through lib/device-timezone, so they cannot drift: an
explicit device override wins, then the OS-reported zone, then null. A legacy
'UTC' override counts as unset, since that was the old default rather than a
deliberate choice and a genuine UTC deployment is indistinguishable from an
unconfigured one.

A new schedule inherits its target's zone — the device's, or for a group its
leader's, falling back to the oldest member that reports one. A zone named
explicitly by the caller still wins; this only fills the silence. A target that
has never reported one still lands on UTC, which is the previous behaviour made
explicit rather than assumed.

The dialog now states which clock the hours are on, and says so differently when
that clock is not the operator's own. Stating it is the other half of the fix:
the server can pick the right zone, but the user still has to be able to see it.

Tests pin both directions and, most importantly, that creation and playback
resolve identically from the same device row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-07-28 09:41:03 -05:00
parent 93019fdde4
commit 0030acc526
6 changed files with 220 additions and 5 deletions

View file

@ -1295,6 +1295,9 @@ export default {
'schedule.content_none': '— None —',
'schedule.title_label': 'Title (optional)',
'schedule.title_placeholder': 'e.g., Morning Playlist',
'schedule.tz_device': 'Times are in {zone} — the screen\u2019s timezone, not your {local}.',
'schedule.tz_same': 'Times are in {zone}.',
'schedule.tz_unknown': 'Times use the screen\u2019s own timezone once it reports one.',
'schedule.start_time': 'Start Time',
'schedule.end_time': 'End Time',
'schedule.repeat': 'Repeat',

View file

@ -95,6 +95,11 @@ export async function render(container) {
<div class="form-group" style="flex:1"><label>${t('schedule.start_time')}</label><input type="time" id="schedStart" class="input" value="09:00"></div>
<div class="form-group" style="flex:1"><label>${t('schedule.end_time')}</label><input type="time" id="schedEnd" class="input" value="17:00"></div>
</div>
<!-- Which clock these hours are on. The server resolves the target's zone and the
player evaluates in it, but the user had no way to SEE that: hours typed as
"9 to 5" silently became UTC, so a schedule could sit closed while its owner
watched the screen. Stating the zone is the whole fix from the UI side. -->
<div id="schedTzNote" style="font-size:12px;color:var(--text-muted);margin:-4px 0 12px"></div>
<div class="form-group"><label>${t('schedule.repeat')}</label>
<select id="schedRepeat" class="input" style="background:var(--bg-input)">
<option value="">${t('schedule.repeat_none')}</option>
@ -139,6 +144,30 @@ export async function render(container) {
deviceRadio.addEventListener('change', updateTargetVisibility);
groupRadio.addEventListener('change', updateTargetVisibility);
// State which clock the hours above are on. The server stores a new schedule in the
// TARGET's zone (lib/device-timezone) and the player evaluates in that same zone — but
// the dialog never said so. A user typing "09:00" reasonably assumes their own clock;
// when the target sits in another zone the schedule is correct and still appears to do
// nothing, because it opens hours later. Naming the zone is the fix from the UI side.
const tzNote = document.getElementById('schedTzNote');
function updateTzNote() {
if (!tzNote) return;
const local = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
let zone = null;
if (!groupRadio.checked) {
const d = devices.find(x => x.id === deviceSelect.value);
zone = (d && d.timezone && d.timezone !== 'UTC' ? d.timezone : null) || (d && d.reported_timezone) || null;
}
if (!zone) { tzNote.textContent = t('schedule.tz_unknown'); return; }
tzNote.textContent = (zone === local)
? t('schedule.tz_same').replace('{zone}', zone)
: t('schedule.tz_device').replace('{zone}', zone).replace('{local}', local || '—');
}
deviceRadio.addEventListener('change', updateTzNote);
groupRadio.addEventListener('change', updateTzNote);
deviceSelect.addEventListener('change', updateTzNote);
updateTzNote();
function updateWeekLabel() {
const end = new Date(currentWeekStart);
end.setDate(end.getDate() + 6);

View file

@ -0,0 +1,27 @@
'use strict';
// The IANA zone a device's schedule blocks are evaluated in.
//
// This is the SINGLE definition, shared by the two places that must agree:
// - ws/deviceSocket.js — evaluating which schedule block is active right now
// - routes/schedules.js — choosing the zone a NEW schedule is stored in
//
// They previously disagreed. Playback resolved the device's zone, while creation
// defaulted to a bare 'UTC' because the dialog never asked. A user in any non-UTC
// zone typed wall-clock hours, got UTC, and watched a screen that was correctly
// showing nothing — with no visible cue that the hours meant something else.
// Observed in the wild: a schedule set 09:00-17:00 by a user in Asia/Tokyo, stored
// as UTC, which would not open until 18:00 their time.
//
// Precedence: an explicit operator override wins, then whatever the player's OS
// last reported, then null. 'UTC' as an override is treated as "unset" because
// that is the historical default value, not a deliberate choice — a real
// UTC deployment is indistinguishable from an unconfigured one, and defaulting to
// the reported zone is the safer of the two readings.
function effectiveDeviceTz(device) {
if (!device) return null;
const override = device.timezone && device.timezone !== 'UTC' ? device.timezone : null;
return override || device.reported_timezone || null;
}
module.exports = { effectiveDeviceTz };

View file

@ -8,6 +8,7 @@ const { db } = require('../db/database');
// the target. This closes a long-standing leak where POST accepted those
// payload refs with no ownership check at all (only the target was checked).
const { accessContext } = require('../lib/tenancy');
const { effectiveDeviceTz } = require('../lib/device-timezone');
// Helper: build the expanded schedule query for a device (device-level + group-level)
function getDeviceSchedulesQuery() {
@ -160,17 +161,33 @@ router.post('/', (req, res) => {
// Resolve target's workspace_id and verify caller has write access there.
let targetWorkspaceId = null;
let targetTz = null;
if (device_id) {
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
const device = db.prepare('SELECT workspace_id, timezone, reported_timezone FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
targetWorkspaceId = device.workspace_id;
targetTz = effectiveDeviceTz(device);
}
if (group_id) {
const group = db.prepare('SELECT workspace_id FROM device_groups WHERE id = ?').get(group_id);
const group = db.prepare('SELECT workspace_id, leader_device_id FROM device_groups WHERE id = ?').get(group_id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (!group.workspace_id) return res.status(403).json({ error: 'Group not assigned to a workspace' });
targetWorkspaceId = group.workspace_id;
// A group can span zones, so there is no single right answer. The leader defines the
// group's wall clock; failing that, the oldest member that reports one. The resolved
// value is stored explicitly so the caller can see which zone it landed on.
const leader = group.leader_device_id
? db.prepare('SELECT timezone, reported_timezone FROM devices WHERE id = ?').get(group.leader_device_id)
: null;
targetTz = effectiveDeviceTz(leader);
if (!targetTz) {
const member = db.prepare(`SELECT d.timezone, d.reported_timezone FROM devices d
JOIN device_group_members m ON m.device_id = d.id
WHERE m.group_id = ? AND COALESCE(d.timezone, d.reported_timezone) IS NOT NULL
ORDER BY d.created_at LIMIT 1`).get(group_id);
targetTz = effectiveDeviceTz(member);
}
}
const ctx = workspaceAccess(req, targetWorkspaceId);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
@ -198,7 +215,7 @@ router.post('/', (req, res) => {
start_time, end_time, timezone, recurrence, recurrence_end, priority, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC',
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || targetTz || 'UTC',
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id);

View file

@ -0,0 +1,137 @@
'use strict';
// A schedule must be stored in the same timezone the player will evaluate it in.
//
// These two disagreed. Playback resolved the device's zone (an explicit operator override,
// else the zone the player's OS reports), while creation defaulted to a bare 'UTC' because
// the dialog never asked for one. So a user typed wall-clock hours, got UTC, and watched a
// screen that was correctly showing nothing — with no visible cue that the hours meant
// something other than what was typed.
//
// Observed with a real user: a schedule set 09:00-17:00 by someone in Asia/Tokyo, stored as
// UTC. Their window would not open until 18:00 local. Reported to us as "I added something
// and it didn't appear on the screen", which is exactly what it looks like from the outside.
//
// The rule pinned here: when the caller does not name a zone, inherit the TARGET's. An
// explicit zone from the caller always wins — this only fills the silence.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const { freePort } = require('./helpers/free-port');
const { effectiveDeviceTz } = require('../lib/device-timezone');
let PORT, BASE, proc, db;
const DATA_DIR = path.join(os.tmpdir(), 'st-schedtz-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-schedtz-' + crypto.randomBytes(4).toString('hex') + '.log');
const S = {};
const jfetch = async (p, opts = {}) => {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* */ }
return { status: res.status, body };
};
const auth = () => ({ Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' });
const mkDevice = (name, tz, reported) => {
const id = crypto.randomUUID();
db.prepare(`INSERT INTO devices (id,name,status,workspace_id,timezone,reported_timezone,created_at)
VALUES (?,?,'online',?,?,?,strftime('%s','now'))`).run(id, name, S.wsId, tz, reported);
return id;
};
const createSchedule = (body) => jfetch('/api/schedules', {
method: 'POST', headers: auth(),
body: JSON.stringify({ start_time: '2026-07-28T09:00:00', end_time: '2026-07-28T17:00:00', ...body }),
});
before(async () => {
PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
const reg = await jfetch('/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 's' + crypto.randomBytes(5).toString('hex') + '@x.local', password: 'Passw0rd123' }),
});
S.token = reg.body.token;
const me = await jfetch('/api/auth/me', { headers: auth() });
S.wsId = me.body.accessible_workspaces[0].id;
});
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
test('THE BUG: a schedule inherits the target screen\'s reported timezone, not UTC', async () => {
const tokyo = mkDevice('Tokyo panel', null, 'Asia/Tokyo');
const r = await createSchedule({ device_id: tokyo });
assert.equal(r.status, 201, `created (got ${JSON.stringify(r.body)})`);
assert.equal(r.body.timezone, 'Asia/Tokyo',
'the hours are stored against the clock the screen actually runs on');
});
test('an explicit timezone from the caller always wins', async () => {
const tokyo = mkDevice('Tokyo panel 2', null, 'Asia/Tokyo');
const r = await createSchedule({ device_id: tokyo, timezone: 'Europe/Paris' });
assert.equal(r.body.timezone, 'Europe/Paris', 'inheritance only fills the silence');
});
test('an operator override on the device beats the OS-reported zone', async () => {
const d = mkDevice('Overridden', 'Europe/Berlin', 'Asia/Tokyo');
const r = await createSchedule({ device_id: d });
assert.equal(r.body.timezone, 'Europe/Berlin');
});
test("a device whose override is the legacy 'UTC' still inherits its reported zone", async () => {
// 'UTC' is the historical default value, not a deliberate choice, so it is treated as unset.
const d = mkDevice('Legacy UTC', 'UTC', 'Asia/Tokyo');
const r = await createSchedule({ device_id: d });
assert.equal(r.body.timezone, 'Asia/Tokyo');
});
test('a device that has never reported a zone falls back to UTC', async () => {
const d = mkDevice('Silent', null, null);
const r = await createSchedule({ device_id: d });
assert.equal(r.body.timezone, 'UTC', 'no information -> the previous behaviour, explicitly');
});
test('a group inherits its leader\'s timezone', async () => {
const leader = mkDevice('Group leader', null, 'Asia/Tokyo');
const gid = crypto.randomUUID();
db.prepare('INSERT INTO device_groups (id,user_id,workspace_id,name,leader_device_id) VALUES (?,?,?,?,?)')
.run(gid, S.userId || db.prepare('SELECT id FROM users LIMIT 1').pluck().get(), S.wsId, 'G', leader);
const r = await createSchedule({ group_id: gid });
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.timezone, 'Asia/Tokyo');
});
// The property that actually matters: creation and playback must resolve identically,
// or a schedule runs in a different zone than the one it was written in.
test('creation and playback resolve the same zone from the same row', async () => {
for (const [tz, reported, expected] of [
[null, 'Asia/Tokyo', 'Asia/Tokyo'],
['UTC', 'Asia/Tokyo', 'Asia/Tokyo'],
['Europe/Berlin', 'Asia/Tokyo', 'Europe/Berlin'],
[null, null, null],
]) {
const d = mkDevice('cmp-' + crypto.randomBytes(3).toString('hex'), tz, reported);
const row = db.prepare('SELECT timezone, reported_timezone FROM devices WHERE id = ?').get(d);
const playback = effectiveDeviceTz(row); // ws/deviceSocket.js path
const created = (await createSchedule({ device_id: d })).body.timezone; // routes/schedules.js path
assert.equal(playback, expected, `playback resolves ${expected}`);
assert.equal(created, expected || 'UTC', 'creation agrees with playback');
}
});

View file

@ -3,6 +3,7 @@ const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
const { effectiveDeviceTz } = require('../lib/device-timezone');
const config = require('../config');
const heartbeat = require('../services/heartbeat');
const liveness = require('../lib/liveness'); // v4 core pass: pure ack/liveness/identity helpers
@ -268,8 +269,9 @@ function buildPlaylistPayload(deviceId) {
// #74/#75: the effective IANA timezone the player evaluates schedule blocks in.
// An explicit (non-default) devices.timezone override wins; otherwise the player's
// last OS-reported zone; otherwise null = the player trusts its own OS clock.
const tzOverride = (device?.timezone && device.timezone !== 'UTC') ? device.timezone : null;
const timezone = tzOverride || device?.reported_timezone || null;
// Shared with routes/schedules.js via lib/device-timezone — creation and evaluation
// MUST resolve the same zone, or a schedule runs in a different one than it was written in.
const timezone = effectiveDeviceTz(device);
// #group-sync: synchronized group playback (wall takes precedence — a wall member is never
// also group-synced). Null unless the device is on a sync-enabled group's matching playlist.
const group_sync = wall_config ? null : resolveGroupSync(device, deviceId);