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
This commit is contained in:
ScreenTinker 2026-07-28 17:47:58 -05:00
parent 98bde220ff
commit d2d7911efb
3 changed files with 65 additions and 13 deletions

View file

@ -6,7 +6,15 @@
// 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.
export const HOUR_PX = 28;
// 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;
@ -74,6 +82,11 @@ export function formatRange(startMin, endMin) {
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 —

View file

@ -3,7 +3,7 @@ import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import {
HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange,
toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries,
toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries, isDrag,
} from '../lib/schedule-grid.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -225,7 +225,7 @@ export async function render(container) {
for (const h of HOURS) {
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? t('schedule.hour_12am') : h < 12 ? h + t('schedule.hour_am') : h === 12 ? t('schedule.hour_12pm') : (h - 12) + t('schedule.hour_pm')}</div>`;
for (let d = 0; d < 7; d++) {
html += `<div style="position:relative;min-height:28px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
html += `<div style="position:relative;min-height:${HOUR_PX}px;height:${HOUR_PX}px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
}
}
@ -247,14 +247,14 @@ export async function render(container) {
const target = targetOf(ev);
seenTargets.set(target.key, target);
const block = document.createElement('div');
const topOffset = (startHour - Math.floor(startHour)) * 28;
const topOffset = (startHour - Math.floor(startHour)) * HOUR_PX;
// In all-screens mode colour identifies WHO the block is for, so several targets share
// one grid and stay tellable apart. On a single screen the schedule's own colour is
// kept — there is only one target, so colour is free to mean something else.
const bg = allScreens ? colorForTarget(target.key) : (ev.color || '#3B82F6');
const tall = duration * 28 >= 34;
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
background:${bg};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.9;
const tall = duration * HOUR_PX >= 34;
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(18, duration * HOUR_PX)}px;
background:${bg};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:grab;z-index:1;opacity:0.92;
line-height:1.25;${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.65);' : ''}`;
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
@ -276,10 +276,11 @@ export async function render(container) {
block.onclick = (e) => { if (dragState && dragState.moved) return; editSchedule(ev); };
// Bottom grip: the affordance that makes a block resizable rather than only movable.
// Hidden on very short blocks, where a grip would cover the whole thing.
if (duration * 28 >= 24) {
if (duration * HOUR_PX >= 22) {
const grip = document.createElement('div');
grip.className = 'sched-resize-grip';
grip.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:6px;cursor:ns-resize;';
grip.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:10px;cursor:ns-resize;'
+ 'background:linear-gradient(to bottom,transparent,rgba(0,0,0,.28));';
block.appendChild(grip);
}
cell.appendChild(block);
@ -353,6 +354,7 @@ export async function render(container) {
const startMin = gridMinutesFromEvent(e, cal);
if (startMin == null) return;
const origin = { x: e.clientX, y: e.clientY };
if (block && block._ev) {
const ev = block._ev;
const s = new Date(ev.instance_start || ev.start_time);
@ -361,21 +363,29 @@ export async function render(container) {
const evEnd = en.getHours() * 60 + en.getMinutes();
dragState = {
kind: e.target.classList.contains('sched-resize-grip') ? 'resize' : 'move',
ev, block, refCell: cell, moved: false,
ev, block, refCell: cell, moved: false, origin,
grabOffset: startMin - evStart,
evStart, evEnd, dayIdx: dayColumnOf(cell),
};
} else {
dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, dayIdx: dayColumnOf(cell) };
dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, origin, dayIdx: dayColumnOf(cell) };
}
cal.setPointerCapture?.(e.pointerId);
});
cal.addEventListener('pointermove', (e) => {
if (!dragState) return;
// Ignore the jitter of an ordinary click. Until the pointer has actually travelled, this
// is still a click and must stay one, or click-to-edit never fires.
if (!dragState.moved) {
if (!isDrag(e.clientX - dragState.origin.x, e.clientY - dragState.origin.y)) return;
dragState.moved = true;
cal.style.touchAction = 'none'; // stop a touch drag scrolling the page
cal.style.cursor = dragState.kind === 'resize' ? 'ns-resize' : 'grabbing';
if (dragState.block) dragState.block.style.opacity = '0.35'; // show what is being moved
}
const now = gridMinutesFromEvent(e, cal);
if (now == null) return;
dragState.moved = true;
let range, day = dragState.dayIdx;
if (dragState.kind === 'create') {
range = rangeFromDrag(dragState.anchorMin, now);
@ -393,10 +403,17 @@ export async function render(container) {
showGhost(cal, day, range.startMin, range.endMin, formatRange(range.startMin, range.endMin));
});
const resetDragChrome = (st) => {
cal.style.touchAction = '';
cal.style.cursor = '';
if (st && st.block) st.block.style.opacity = '';
};
const finish = async (e) => {
const st = dragState;
dragState = null;
clearGhost();
resetDragChrome(st);
try { cal.releasePointerCapture?.(e.pointerId); } catch (_) { /* already released */ }
if (!st || !st.pending || !st.moved) { setTimeout(() => { if (!dragState) { /* let click through */ } }, 0); return; }
const { range, day } = st.pending;
const dayDate = new Date(currentWeekStart);
@ -426,7 +443,7 @@ export async function render(container) {
loadCalendar();
};
cal.addEventListener('pointerup', finish);
cal.addEventListener('pointercancel', () => { dragState = null; clearGhost(); });
cal.addEventListener('pointercancel', () => { const st = dragState; dragState = null; clearGhost(); resetDragChrome(st); });
// Right-click: act on what is under the pointer, like every calendar people already use.
cal.addEventListener('contextmenu', (e) => {

View file

@ -105,6 +105,28 @@ test('editing a repeating schedule is flagged as editing the series', async () =
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');