mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
135 lines
5.9 KiB
JavaScript
135 lines
5.9 KiB
JavaScript
'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');
|
||
});
|