screentinker/server/test/schedule-grid-math.test.js
ScreenTinker 98bde220ff Make the week calendar directly manipulable
The calendar rendered schedules but could not be used to change them. Creating
or moving anything meant opening a dialog and typing times, which is the wrong
instrument on a week grid: the grid already shows exactly where a thing goes, so
the grid should be where it is put. My previous change made the grid easier to
READ — all screens at once, a colour and a name per target — and left the
interaction untouched, which was only half of what was asked for.

Three gestures now share one pointer loop. Dragging empty space draws a slot and
opens the dialog prefilled with the time drawn, so the gesture supplies the
times and the dialog supplies only what it alone knows. Dragging a block moves
it. Dragging its bottom grip resizes the end. A live ghost shows the range as a
readable time while dragging, and nothing is committed until release, so an
accidental nudge costs nothing. Right-click acts on what is under the pointer:
new here, or edit, duplicate and delete on a block.

Dragging a repeating schedule sideways is refused. A one-off's day IS its date,
but a repeating one's day comes from its rule, so moving an instance across
columns would rewrite the recurrence for every other occurrence — a different
operation, and not one a mouse gesture should perform silently. Changing a
repeating schedule's TIME does still edit the whole series, since a series has
one time of day, so that is confirmed out loud rather than assumed.

The arithmetic is a separate module of pure functions, because it is the part
that fails quietly: a block that ends before it starts, a move near midnight
truncated instead of slid back, or a stamp built with toISOString() putting
anyone west of Greenwich on the previous day. Tests pin each of those. That last
one was already present in the create path and is fixed here too.

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

113 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// The week calendar now supports direct manipulation — drag empty space to create, drag a block to
// move it, drag its grip to resize. The gestures are only as good as the arithmetic underneath,
// and that arithmetic fails quietly: an off-by-one hour, a block that ends before it starts, or a
// UTC conversion that moves a schedule to the previous day all LOOK fine on screen and only show
// up as a screen playing at the wrong time.
//
// So the maths lives in frontend/js/lib/schedule-grid.js as pure functions and is pinned here.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const MOD = pathToFileURL(path.join(__dirname, '..', '..', 'frontend', 'js', 'lib', 'schedule-grid.js')).href;
let G;
test('load the module', async () => { G = await import(MOD); assert.ok(G.HOUR_PX > 0); });
test('pixels and minutes round-trip', async () => {
G = G || await import(MOD);
for (const min of [0, 15, 90, 447, 1439]) {
assert.ok(Math.abs(G.pxToMinutes(G.minutesToPx(min)) - min) < 0.001, `${min} survives`);
}
});
test('snapping goes to the NEAREST quarter, not the one below', async () => {
// Dragging to 10:58 means 11:00. Flooring would silently give 10:45 and read as a broken drag.
assert.equal(G.snapMinutes(658), 660);
assert.equal(G.snapMinutes(652), 645);
assert.equal(G.snapMinutes(0), 0);
});
test('a drag upward is still a valid range', async () => {
// Anchor at 14:00, drag up to 12:00 — Outlook treats the anchor as the end. Without this the
// range inverts and the schedule is nonsense.
const r = G.rangeFromDrag(840, 720);
assert.equal(r.startMin, 720);
assert.equal(r.endMin, 840);
});
test('a click without movement still yields a usable block, not a zero-height one', async () => {
const r = G.rangeFromDrag(600, 600);
assert.equal(r.endMin - r.startMin, G.MIN_DURATION_MIN, 'a minimum length is enforced');
});
test('a range cannot escape the day', async () => {
const late = G.rangeFromDrag(1430, 1600);
assert.ok(late.endMin <= G.DAY_MIN, 'clamped to midnight');
assert.ok(late.startMin < late.endMin, 'and still valid');
const early = G.rangeFromDrag(-120, 30);
assert.ok(early.startMin >= 0);
});
test('MOVING a block keeps its length — that is what makes it a move', async () => {
const r = G.moveRange(9 * 60, 90);
assert.equal(r.startMin, 540);
assert.equal(r.endMin, 630);
assert.equal(r.endMin - r.startMin, 90);
});
test('a move near midnight slides back instead of being truncated', async () => {
// Truncating would quietly shorten a 2h schedule to 10 minutes.
const r = G.moveRange(23 * 60 + 50, 120);
assert.equal(r.endMin, G.DAY_MIN);
assert.equal(r.endMin - r.startMin, 120, 'length preserved');
});
test('RESIZING moves only the end', async () => {
const r = G.resizeRange(540, 700);
assert.equal(r.startMin, 540);
assert.equal(r.endMin, 705, 'snapped');
});
test('resizing above the start does not invert the block', async () => {
const r = G.resizeRange(600, 300);
assert.equal(r.startMin, 600);
assert.equal(r.endMin, 600 + G.MIN_DURATION_MIN);
});
test('THE TIMEZONE TRAP: the stamp is LOCAL, not UTC', async () => {
// toISOString() would render 00:30 local as the PREVIOUS day for anyone west of Greenwich —
// the same class of bug as storing a schedule in the wrong zone.
const d = new Date(2026, 6, 28, 12, 0, 0); // 28 Jul 2026, local
assert.equal(G.toLocalStamp(d, 30), '2026-07-28T00:30:00', 'early morning stays on the 28th');
assert.equal(G.toLocalStamp(d, 23 * 60 + 45), '2026-07-28T23:45:00', 'late evening too');
});
test('the stamp is minute-accurate across the day', async () => {
const d = new Date(2026, 0, 5, 8, 0, 0);
assert.equal(G.toLocalStamp(d, 0), '2026-01-05T00:00:00');
assert.equal(G.toLocalStamp(d, 13 * 60 + 15), '2026-01-05T13:15:00');
});
test('a one-off may be dragged to another DAY; a repeating one may not', async () => {
// A one-off's day IS its date. A repeating schedule's day comes from its rule, so dragging an
// instance sideways would rewrite the recurrence for every other occurrence too — that belongs
// in the dialog, not in a mouse gesture.
assert.equal(G.canMoveAcrossDays({ id: 1 }), true);
assert.equal(G.canMoveAcrossDays({ id: 2, recurrence: 'FREQ=WEEKLY' }), false);
});
test('editing a repeating schedule is flagged as editing the series', async () => {
assert.equal(G.editsWholeSeries({ recurrence: 'FREQ=DAILY' }), true);
assert.equal(G.editsWholeSeries({}), false);
});
test('the drag readout is human, not 24h minutes', async () => {
assert.equal(G.formatRange(540, 630), '9:00 AM 10:30 AM');
assert.equal(G.formatRange(0, 45), '12:00 AM 12:45 AM');
assert.equal(G.formatRange(720, 780), '12:00 PM 1:00 PM');
});