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