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');