From d2d7911efbb18f2cea471a0795b2a9570d10f12a Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 17:47:58 -0500 Subject: [PATCH] Make the calendar's blocks easy to grab and move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- frontend/js/lib/schedule-grid.js | 15 +++++++++- frontend/js/views/schedule.js | 41 ++++++++++++++++++-------- server/test/schedule-grid-math.test.js | 22 ++++++++++++++ 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/frontend/js/lib/schedule-grid.js b/frontend/js/lib/schedule-grid.js index b9634ba..5d6cb9f 100644 --- a/frontend/js/lib/schedule-grid.js +++ b/frontend/js/lib/schedule-grid.js @@ -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 — diff --git a/frontend/js/views/schedule.js b/frontend/js/views/schedule.js index 309ab70..4e25553 100644 --- a/frontend/js/views/schedule.js +++ b/frontend/js/views/schedule.js @@ -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 += `
${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')}
`; for (let d = 0; d < 7; d++) { - html += `
`; + html += `
`; } } @@ -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) => { diff --git a/server/test/schedule-grid-math.test.js b/server/test/schedule-grid-math.test.js index ae8e19b..cdee415 100644 --- a/server/test/schedule-grid-math.test.js +++ b/server/test/schedule-grid-math.test.js @@ -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');