screentinker/server/test/schedule-instance-format.test.js
screentinker 65a24682f0
Stop schedules landing on the wrong date (#284)
Three separate defects all showed up as "I saved a schedule and it moved".

1. expandSchedule emitted two different wire formats. A one-off returned
   start_time untouched - a naive wall-clock string, which the browser parses
   in its own zone - while a recurring instance returned cursor.toISOString(),
   an absolute instant derived by reading that same string in the SERVER's
   zone. The browser converted it back and the two conversions do not cancel:
   an operator in Tokyo saving Wednesday 20:00 got Thursday 10:00 from a
   US-Central server. Only recurring schedules were affected, which is why it
   looked intermittent.

   The calendar was also the odd one out. services/scheduler.js compares
   start_time as a string and never builds a Date from it, so expandSchedule
   was the only place in the codebase treating a wall-clock time as an
   instant - the drawing disagreed with playback as well as with the browser.

2. Saving an edit moved the schedule to today. The save handler rebuilds
   start_time from `pendingCreateDate || new Date()`, and editSchedule()
   restored only HH:MM - it never recorded the date being edited. Changing a
   colour on a block dated 5 Aug rewrote it to this week. No timezone
   mismatch required; this one hit everybody and silently altered stored data.

3. A cancelled drag-create leaked its date into the next schedule.
   pendingCreateDate was cleared only on a successful save, and the modal's
   two dismissers are inline onclick="...display='none'" attributes that
   cannot reach that scope. The date is now assigned by every path that OPENS
   the modal, so no dismissal path can leave a stale value behind.

Tests assert the property rather than a literal: that the wire value is
wall-clock and that the day survives a server/browser zone mismatch in BOTH
directions. A literal-string assertion would pass just as happily with the
bug present on a differently-configured CI box. Both new files fail on the
parent commit and pass here; the schedule, calendar and timezone suites are
green at 85 tests.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:14:23 -05:00

117 lines
5.5 KiB
JavaScript

'use strict';
// THE BUG THIS PINS: "I add a schedule and it shows up on a different day."
//
// expandSchedule had two emit paths that disagreed about the wire format. A one-off passed
// schedule.start_time through untouched - a naive wall-clock string - while a recurring instance
// emitted cursor.toISOString(), an absolute instant. The browser parses the first in its own zone
// (correct) and converts the second out of the server's zone (wrong by the offset between them).
// For an operator in Tokyo against a US-Central server that is 14 hours: a Wednesday 20:00 event
// came back as Thursday 10:00.
//
// The calendar was also the only component doing this. services/scheduler.js compares start_time
// as a STRING and never builds a Date from it, so the drawing disagreed with playback as well as
// with the browser.
//
// These tests assert the PROPERTY the browser depends on - the wire value is wall-clock, and it
// means the same thing regardless of which zone reads it - rather than asserting a literal string,
// which would pass just as happily with the bug present in a differently-configured CI box.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-fmt-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-instance-format';
const { expandSchedule } = require('../routes/schedules');
// Wednesday 19 Aug 2026, 20:00 - late enough in the day that a westward server offset pushes it
// over midnight, which is exactly the case that was breaking.
const START = '2026-08-19T20:00:00';
const END = '2026-08-19T21:00:00';
const WALL_CLOCK = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
const rangeStart = new Date(2026, 7, 16, 0, 0, 0, 0); // Sun 16 Aug, local
const rangeEnd = new Date(2026, 7, 23, 0, 0, 0, 0); // Sun 23 Aug, local
const schedule = (recurrence) => ({
id: 1, start_time: START, end_time: END, recurrence, recurrence_end: null,
});
/* Run fn with the process in a given zone, restoring exactly what was there before. */
function inTimezone(tz, fn) {
const had = Object.prototype.hasOwnProperty.call(process.env, 'TZ');
const original = process.env.TZ;
process.env.TZ = tz;
try {
return fn();
} finally {
// ⚠️ Assigning `undefined` here would write the STRING "undefined", which Node cannot parse and
// silently resolves to UTC - quietly changing the zone for every test that runs afterwards in
// this process. Delete the key instead when it was not set to begin with.
if (had) process.env.TZ = original;
else delete process.env.TZ;
}
}
test('a recurring instance is emitted as wall-clock, not as an absolute instant', () => {
const events = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
assert.ok(events.length > 0, 'the rule should draw at least one event');
for (const ev of events) {
assert.match(ev.instance_start, WALL_CLOCK,
`instance_start must be a naive wall-clock string; got ${ev.instance_start}`);
assert.match(ev.instance_end, WALL_CLOCK,
`instance_end must be a naive wall-clock string; got ${ev.instance_end}`);
}
});
test('one-off and recurring agree on the wire format', () => {
// They are read by the same line of frontend code. If they disagree, one of them is wrong on
// every client whose zone differs from the server's - and which one is invisible from here.
const [oneOff] = expandSchedule(schedule(null), rangeStart, rangeEnd);
const [recurring] = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
const shape = (v) => (WALL_CLOCK.test(v) ? 'wall-clock' : 'absolute');
assert.equal(shape(recurring.instance_start), shape(oneOff.instance_start),
'the two emit paths disagree about the wire format');
});
test('the emitted time is the time the operator chose', () => {
const [ev] = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
assert.equal(ev.instance_start.slice(11, 16), '20:00',
'a 20:00 schedule must draw at 20:00');
assert.equal(new Date(ev.instance_start).getDay(), 3, 'Wednesday stays Wednesday');
});
test('THE REPORTED BUG: the day survives a server and browser in different zones', () => {
// Generate as a US-Central server would, then read it as a Tokyo browser would. With the bug,
// the recurring instance arrives as ...T01:00:00.000Z and Tokyo renders Thursday 10:00.
const wire = inTimezone('America/Chicago', () => {
const [ev] = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
return ev.instance_start;
});
inTimezone('Asia/Tokyo', () => {
const seen = new Date(wire);
assert.equal(seen.getDay(), 3, `Tokyo should still see Wednesday, saw ${seen.toString()}`);
assert.equal(seen.getHours(), 20, `Tokyo should still see 20:00, saw ${seen.getHours()}:00`);
});
});
test('and the same holds when the server is EAST of the browser', () => {
// The mirror case, so a fix that merely shifts the offset in one direction cannot pass.
const wire = inTimezone('Asia/Tokyo', () => {
const [ev] = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
return ev.instance_start;
});
inTimezone('America/Chicago', () => {
const seen = new Date(wire);
assert.equal(seen.getDay(), 3, `Chicago should still see Wednesday, saw ${seen.toString()}`);
assert.equal(seen.getHours(), 20, `Chicago should still see 20:00, saw ${seen.getHours()}:00`);
});
});