mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
The drag gestures did nothing on a phone. touch-action was set to none only once the pointer had already travelled far enough to count as a drag, and by then it is too late: a browser decides at touch-START whether a gesture scrolls the page, so the page scrolled, the pointer stream was cancelled, and the block never moved. The rule that works for a mouse cannot work for a finger. Touch now arms by HOLDING. A press that stays put for a moment takes the gesture over — at which point scrolling is suppressed and the block dims — while a press that moves first is left alone as the scroll it plainly is. Everything that is not a drag still scrolls exactly as a phone user expects. A mouse or pen is unchanged and arms as soon as it has travelled. Tapping empty space now creates a default one-hour slot at that time. On a phone that is the only practical way to create, since drawing a range with a finger is awkward, and on a desktop it is a shortcut worth having anyway. The arming rule is a function rather than a pointerType check at each site, so the touch and mouse paths cannot drift apart, and it is tested — including that the hold is long enough to mean intent without feeling stuck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
163 lines
7.3 KiB
JavaScript
163 lines
7.3 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('MOBILE: touch arms by HOLDING, a mouse arms by moving', async () => {
|
||
// The two cannot share a rule. A browser decides at touch-start whether a gesture scrolls the
|
||
// page, so a touch drag that only declares itself after the finger moves has already lost — the
|
||
// page scrolls and the pointer stream is cancelled. That is exactly why the first version did
|
||
// nothing on a phone: it set touch-action only AFTER the move threshold.
|
||
assert.equal(G.dragArmMode('touch'), 'longpress');
|
||
assert.equal(G.dragArmMode('mouse'), 'immediate');
|
||
assert.equal(G.dragArmMode('pen'), 'immediate');
|
||
assert.equal(G.dragArmMode(undefined), 'immediate', 'unknown input behaves like a mouse');
|
||
});
|
||
|
||
test('the hold is long enough to mean intent, short enough not to feel stuck', async () => {
|
||
assert.ok(G.LONG_PRESS_MS >= 250 && G.LONG_PRESS_MS <= 600, `${G.LONG_PRESS_MS}ms`);
|
||
});
|
||
|
||
test('a tap with no drag still yields a sensible slot', async () => {
|
||
// On a phone this is the only create gesture — dragging a range with a finger is awkward.
|
||
assert.equal(G.DEFAULT_NEW_MIN, 60);
|
||
const r = G.clampRange(9 * 60, 9 * 60 + G.DEFAULT_NEW_MIN);
|
||
assert.equal(r.endMin - r.startMin, 60);
|
||
});
|
||
|
||
test('a tap late in the day does not produce an invalid slot', async () => {
|
||
const start = 23 * 60 + 30;
|
||
const r = G.clampRange(start, Math.min(start + G.DEFAULT_NEW_MIN, G.DAY_MIN));
|
||
assert.ok(r.endMin <= G.DAY_MIN && r.endMin > r.startMin);
|
||
});
|
||
|
||
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');
|
||
});
|