screentinker/frontend/js/lib/schedule-grid.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

104 lines
5 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)}`;
}
// 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);
}