screentinker/server/test/schedule-grid-math.test.js
ScreenTinker d2d7911efb Make the calendar's blocks easy to grab and move
Direct manipulation existed but was awkward, and one part of it was outright
broken. A drag was recognised on ANY pointer movement, so the pixel or two of
travel in an ordinary click counted as a drag and suppressed click-to-edit —
the most common interaction on the calendar would have felt broken. A press now
has to travel a few pixels before it becomes a drag.

At 28px per hour a fifteen-minute block was seven pixels tall. Legible, but not
something a pointer can reliably hit, and its resize grip would have covered the
whole block. Rows are 44px, which makes the smallest block an 11px target while
still fitting a full day on a laptop screen; a test pins both halves of that
trade so neither can be tuned away silently. That height had been written as a
bare 28 in five places in the view that all had to agree with the module — it is
now one constant.

The rest is feedback. A block shows a grab cursor, dims while it is being moved
so it is clear what is travelling, and its grip is taller with a visible edge.
While dragging, the grid switches to a grabbing cursor and suppresses touch
scrolling, so the gesture works on a touchscreen instead of panning the page.
Pointer capture is released and the chrome reset on every exit path, including
a cancelled drag.

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

135 lines
5.9 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 CLICK TRAP: a jiggle is not a drag', async () => {
// Every click carries a pixel or two of movement. Treating that as a drag would suppress
// click-to-edit — the most-used interaction on the calendar — and read as "clicking is broken".
assert.equal(G.isDrag(0, 0), false, 'a still click');
assert.equal(G.isDrag(1, 1), false, 'ordinary hand tremor');
assert.equal(G.isDrag(2, 2), false, 'still inside the threshold');
assert.equal(G.isDrag(0, 6), true, 'a deliberate pull IS a drag');
assert.equal(G.isDrag(-6, 0), true, 'in any direction');
});
test('a 15-minute block is big enough to actually grab', async () => {
// At the old 28px/hour it was 7px tall — legible but not a usable pointer target, and its
// resize grip would have covered the entire block.
assert.ok(G.minutesToPx(G.MIN_DURATION_MIN) >= 10,
`smallest block is ${G.minutesToPx(G.MIN_DURATION_MIN)}px`);
});
test('a whole day still fits a laptop screen', async () => {
// The other half of the trade: taller rows must not turn the week view into a scrolling chore.
assert.ok(24 * G.HOUR_PX <= 1100, `full day is ${24 * G.HOUR_PX}px`);
});
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');
});