screentinker/frontend/js/lib/schedule-grid.js
ScreenTinker ce7d8642fa Make the calendar's gestures work on a touchscreen
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
2026-07-28 17:56:48 -05:00

120 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.

// Geometry and time maths for the week calendar's direct manipulation (drag to create, drag to
// move, drag to resize). Kept apart from the view so the arithmetic — the part that silently
// produces a schedule an hour off, or one that ends before it starts — is testable without a
// browser.
//
// The grid is 24 rows of HOUR_PX pixels, one column per weekday. A block's vertical position is
// therefore a pure function of minutes-since-midnight, and vice versa.
// 28px/hour made a 15-minute block SEVEN pixels tall — legible, but not something you can
// reliably grab, and its resize grip would have covered the whole block. 44 keeps a full day on
// screen on a laptop while making the smallest schedule an 11px target.
export const HOUR_PX = 44;
// A pointer must travel this far before a press counts as a drag. Without it, the 1px of movement
// in an ordinary click turns every click into a drag and swallows click-to-edit — so the calendar
// would feel broken in the most common interaction of all.
export const DRAG_THRESHOLD_PX = 4;
export const SNAP_MIN = 15; // what a drag rounds to; matches how people actually schedule
export const MIN_DURATION_MIN = 15; // a zero-height block is invisible and unselectable
export const DAY_MIN = 24 * 60;
export const minutesToPx = (min) => (min / 60) * HOUR_PX;
export const pxToMinutes = (px) => (px / HOUR_PX) * 60;
// Round to the nearest SNAP_MIN. Nearest rather than floor: dragging to 10:58 should give 11:00,
// not 10:45, because the pointer is a blunt instrument and people aim at the line.
export function snapMinutes(min, snap = SNAP_MIN) {
return Math.round(min / snap) * snap;
}
// Clamp a dragged range into a valid one: inside the day, at least MIN_DURATION_MIN long, and
// never inverted. Returns {startMin, endMin}.
export function clampRange(startMin, endMin) {
let s = Math.max(0, Math.min(DAY_MIN - MIN_DURATION_MIN, Math.round(startMin)));
let e = Math.round(endMin);
if (e < s + MIN_DURATION_MIN) e = s + MIN_DURATION_MIN; // dragging up past the start, or a click
if (e > DAY_MIN) { e = DAY_MIN; s = Math.min(s, e - MIN_DURATION_MIN); }
return { startMin: s, endMin: e };
}
// A drag that started at anchorMin and is currently at pointerMin, in either direction. Outlook
// lets you drag upward from the anchor and treats the anchor as the END; so do we.
export function rangeFromDrag(anchorMin, pointerMin) {
const a = snapMinutes(anchorMin);
const b = snapMinutes(pointerMin);
return clampRange(Math.min(a, b), Math.max(a, b));
}
// Move a block of fixed length so it now STARTS at startMin, without letting it run off the end
// of the day (it slides back instead of being silently truncated — a move must not change length).
export function moveRange(startMin, durationMin) {
const dur = Math.max(MIN_DURATION_MIN, Math.round(durationMin));
let s = snapMinutes(Math.max(0, startMin));
if (s + dur > DAY_MIN) s = DAY_MIN - dur;
return { startMin: Math.max(0, s), endMin: Math.max(0, s) + dur };
}
// Resize by dragging the bottom edge: the start is fixed, the end follows the pointer.
export function resizeRange(startMin, pointerMin) {
return clampRange(startMin, snapMinutes(pointerMin));
}
const pad = (n) => String(n).padStart(2, '0');
// The wire format the API stores: a LOCAL 'YYYY-MM-DDTHH:MM:00'. Deliberately not toISOString(),
// which converts to UTC and would shift every schedule by the browser's offset — the same class of
// bug as storing a schedule in the wrong zone.
export function toLocalStamp(date, minutes) {
const d = new Date(date.getTime());
d.setHours(0, 0, 0, 0);
d.setMinutes(minutes);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
}
export function formatRange(startMin, endMin) {
const fmt = (m) => {
const h24 = Math.floor(m / 60) % 24, mm = m % 60;
const ampm = h24 < 12 ? 'AM' : 'PM';
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h12}:${pad(mm)} ${ampm}`;
};
return `${fmt(startMin)} ${fmt(endMin)}`;
}
// How long a touch must be held before it becomes a drag. A touchscreen cannot use the mouse
// rule: the browser decides at touch-START whether the gesture is a page scroll, so a drag that
// only declares itself after the finger moves has already lost — the page scrolls and the pointer
// stream is cancelled. Holding still first is the signal that this is a drag and not a scroll.
export const LONG_PRESS_MS = 350;
// Touch arms by holding; a mouse or pen arms as soon as it has travelled. Returning the mode
// rather than branching on pointerType at each site keeps the two paths from drifting apart.
export function dragArmMode(pointerType) {
return pointerType === 'touch' ? 'longpress' : 'immediate';
}
// A default slot for "I tapped a time" rather than dragging one out — the whole gesture on a
// phone, where dragging out a range is awkward.
export const DEFAULT_NEW_MIN = 60;
// Has the pointer moved far enough to mean "drag" rather than "click"?
export function isDrag(dx, dy, threshold = DRAG_THRESHOLD_PX) {
return Math.hypot(dx, dy) >= threshold;
}
// Which day a schedule occupies is NOT always its date. A one-off sits on the date in start_time,
// so dragging it sideways is a real date change. A RECURRING one appears on whatever days its rule
// expands to, so dragging an instance sideways is a change to the RULE (BYDAY), not to a time —
// a different operation with different consequences for every other instance. Refuse it here and
// send the user to the dialog rather than silently rewriting a recurrence from a mouse gesture.
export function canMoveAcrossDays(ev) {
return !(ev && ev.recurrence);
}
// Dragging a recurring event's TIME still edits the whole series, since the series has one
// time-of-day. That is worth confirming out loud rather than assuming.
export function editsWholeSeries(ev) {
return !!(ev && ev.recurrence);
}