feat(schedule): right-click menu + drag-to-resize on EPG event blocks

Right-click any event block to open a context menu (Edit, Cancel,
Copy schedule ID, Delete) — actions per status mirror the List view so
the two surfaces stay in lockstep. Menu is viewport-clamped and
dismisses on outside click / scroll, same pattern as the asset menu in
the Library.

Drag-to-resize works for pending schedules only (the schedules PUT
rejects edits to running rows, and terminal statuses are read-only):
- Drag the left edge to move the start time
- Drag the right edge to move the end time
- Drag the body to shift the whole block in time
All gestures snap to 15-minute increments to match the new-schedule
click snap. Minimum duration is clamped to 5 minutes; the block clamps
to the visible day on both edges. While dragging the title shows the
preview range ("Start time → end time") and the block lifts with a
project-tinted shadow.

A short pointer click (< 4px travel) still opens the edit modal — the
click and drag share the same pointerdown so the operator never has
to know which gesture they made first.

Implementation: replaces the <button> block with a <div> hosting three
zones (left handle / body / right handle). Pointer events with
setPointerCapture so drags survive losing the cursor over the block,
and pointerup demotes back to click if travel was below threshold.
Optimistic local update on resize, PUT /schedules/:id with just the
two changed time fields, refetch to reconcile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-23 16:33:57 -04:00
parent 97f08b32de
commit 3ffffd5b32
2 changed files with 261 additions and 31 deletions

View file

@ -1018,55 +1018,158 @@ function _EpgRuler({ pph }) {
); );
} }
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick }) { // Minimum schedule length the UI permits while dragging. Anything shorter
const s = new Date(event.start_at); // rarely reflects a real plan and would let the operator accidentally
const e = new Date(event.end_at); // dismiss a block to zero width.
const startMin = _minutesIntoDay(s, dayStart); const _EPG_MIN_MS = 5 * 60 * 1000;
const endMin = _minutesIntoDay(e, dayStart); // Drag snap quantum. Mirrors the new-schedule click snap so a resized
// block lines up to the same grid an operator just placed a new one on.
const _EPG_SNAP_MIN = 15;
// Pointer travel (in px) before we treat the gesture as a drag rather
// than a click. Below this, pointerup fires the click handler.
const _EPG_DRAG_THRESHOLD = 4;
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick, onContextMenu, onResize }) {
// Drag state: null when idle, otherwise the in-flight resize/move
// describing the original times and the current (snapped) preview times.
// We render from this preview while dragging so the block follows the
// cursor without round-tripping through props.
const [drag, setDrag] = React.useState(null);
const blockRef = React.useRef(null);
const eventStartMs = new Date(event.start_at).getTime();
const eventEndMs = new Date(event.end_at).getTime();
const isLive = event.status === 'running' || (event.status === 'pending' && eventStartMs <= now.getTime() && now.getTime() < eventEndMs);
const isFailed = event.status === 'failed';
const isPast = (event.status === 'completed' || event.status === 'cancelled') || eventEndMs < now.getTime();
const color = _projectColor(recorder?.project_id, projects);
// Only pending schedules can be resized. The API rejects PUTs against
// running schedules outright; cancelling them is what an operator
// actually wants there. Terminal statuses are read-only.
const canDrag = event.status === 'pending';
const startDrag = (e, type) => {
if (!canDrag) return;
if (e.button !== 0) return; // ignore right-click
e.stopPropagation();
try { blockRef.current.setPointerCapture(e.pointerId); } catch (_) {}
setDrag({
type, pointerId: e.pointerId,
startX: e.clientX,
origStart: eventStartMs, origEnd: eventEndMs,
currStart: eventStartMs, currEnd: eventEndMs,
moved: false,
});
};
const onPointerMove = (ev) => {
if (!drag) return;
const dx = ev.clientX - drag.startX;
if (!drag.moved && Math.abs(dx) < _EPG_DRAG_THRESHOLD) return;
const snapMs = _EPG_SNAP_MIN * 60 * 1000;
const dMs = Math.round((dx / pph) * 3600 * 1000 / snapMs) * snapMs;
const dayStartMs = dayStart.getTime();
const dayEndMs = dayEnd.getTime();
let cs = drag.origStart, ce = drag.origEnd;
if (drag.type === 'left') {
cs = Math.max(dayStartMs, Math.min(drag.origEnd - _EPG_MIN_MS, drag.origStart + dMs));
} else if (drag.type === 'right') {
ce = Math.min(dayEndMs, Math.max(drag.origStart + _EPG_MIN_MS, drag.origEnd + dMs));
} else if (drag.type === 'body') {
cs = drag.origStart + dMs;
ce = drag.origEnd + dMs;
// Clamp to the day; preserve duration when bumping against an edge.
if (cs < dayStartMs) { ce += (dayStartMs - cs); cs = dayStartMs; }
if (ce > dayEndMs) { cs -= (ce - dayEndMs); ce = dayEndMs; }
}
setDrag({ ...drag, currStart: cs, currEnd: ce, moved: true });
};
const endDrag = (ev) => {
if (!drag) return;
try { blockRef.current.releasePointerCapture(drag.pointerId); } catch (_) {}
const d = drag;
setDrag(null);
if (!d.moved) {
// Treat as a click open the edit modal.
onClick(event);
return;
}
if (d.currStart === d.origStart && d.currEnd === d.origEnd) return;
onResize(event, new Date(d.currStart).toISOString(), new Date(d.currEnd).toISOString());
};
// Render from drag preview while a gesture is in flight so the block
// tracks the pointer; otherwise from the canonical event prop.
const dispStartMs = drag ? drag.currStart : eventStartMs;
const dispEndMs = drag ? drag.currEnd : eventEndMs;
const dispStart = new Date(dispStartMs);
const dispEnd = new Date(dispEndMs);
const startMin = _minutesIntoDay(dispStart, dayStart);
const endMin = _minutesIntoDay(dispEnd, dayStart);
const left = (startMin / 60) * pph; const left = (startMin / 60) * pph;
const width = Math.max(40, ((endMin - startMin) / 60) * pph); const width = Math.max(40, ((endMin - startMin) / 60) * pph);
const isLive = event.status === 'running' || (event.status === 'pending' && s <= now && now < e);
const isFailed = event.status === 'failed';
const isPast = (event.status === 'completed' || event.status === 'cancelled') || e < now;
const color = _projectColor(recorder?.project_id, projects);
const classes = ['epg-block']; const classes = ['epg-block'];
if (isLive) classes.push('live'); if (isLive) classes.push('live');
if (isFailed) classes.push('failed'); if (isFailed) classes.push('failed');
else if (isPast) classes.push('past'); else if (isPast) classes.push('past');
if (drag && drag.moved) classes.push('dragging');
if (canDrag) classes.push('resizable');
return ( return (
<button <div
ref={blockRef}
className={classes.join(' ')} className={classes.join(' ')}
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }} style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
onClick={(ev) => { ev.stopPropagation(); onClick(event); }} onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); onContextMenu(event, ev); }}
title={event.name + ' · ' + _fmtTime(s) + ' → ' + _fmtTime(e) + (event.error_message ? ' · ' + event.error_message : '')}> onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
title={event.name + ' · ' + _fmtTime(dispStart) + ' → ' + _fmtTime(dispEnd) + (event.error_message ? ' · ' + event.error_message : '')}>
<span className="epg-block-bar" /> <span className="epg-block-bar" />
<span className="epg-block-name">{event.name}</span> {/* Body click edit, body drag move. We hang the click on pointerup
<span className="epg-block-time mono">{_fmtTime(s)}</span> so the threshold check above can demote a drag back to a click. */}
{isLive && <span className="epg-block-glyph live" title="on air"></span>} <div className="epg-block-body" onPointerDown={(ev) => startDrag(ev, 'body')}>
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>} <span className="epg-block-name">{event.name}</span>
</button> <span className="epg-block-time mono">{_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''}</span>
{isLive && <span className="epg-block-glyph live" title="on air"></span>}
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
</div>
{canDrag && (
<>
<span className="epg-block-handle left"
onPointerDown={(ev) => startDrag(ev, 'left')}
title="Drag to change start time" />
<span className="epg-block-handle right"
onPointerDown={(ev) => startDrag(ev, 'right')}
title="Drag to change end time" />
</>
)}
</div>
); );
} }
function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick }) { function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick, onEventContextMenu, onEventResize }) {
const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd)); const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd));
const handleRowClick = (e) => { const handleRowPointerUp = (e) => {
// Translate clicked x to a Date in this row's day. Snap to 15-minute // Open the new-schedule modal only on a real click in the empty
// increments so the resulting modal pre-fill looks intentional. // gutter. Clicks on event blocks stopPropagation themselves; we also
// guard against the tail of a block-drag bubbling up here.
if (e.target !== e.currentTarget) return;
if (e.button !== 0) return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / 15) * 15)); const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / _EPG_SNAP_MIN) * _EPG_SNAP_MIN));
const start = new Date(dayStart); const start = new Date(dayStart);
start.setMinutes(minutes); start.setMinutes(minutes);
onEmptyClick(recorder, start); onEmptyClick(recorder, start);
}; };
return ( return (
<div className="epg-row" style={{ width: 24 * pph }} onClick={handleRowClick}> <div className="epg-row" style={{ width: 24 * pph }} onPointerUp={handleRowPointerUp}>
{dayEvents.map(s => ( {dayEvents.map(s => (
<_EventBlock <_EventBlock
key={s.id} key={s.id}
@ -1077,12 +1180,49 @@ function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, on
pph={pph} pph={pph}
now={now} now={now}
projects={projects} projects={projects}
onClick={onEventClick} /> onClick={onEventClick}
onContextMenu={onEventContextMenu}
onResize={onEventResize} />
))} ))}
</div> </div>
); );
} }
// Right-click menu for an EPG event block
// Same pattern as AssetContextMenu (screens-library.jsx): viewport-clamped,
// dismissed on outside click. Per-status action filtering mirrors the
// buttons rendered in the List view so the two surfaces stay consistent.
function _ScheduleContextMenu({ schedule, x, y, onClose, onEdit, onCancel, onDelete, onCopyId }) {
const ref = React.useRef(null);
const [pos, setPos] = React.useState({ left: x, top: y });
React.useLayoutEffect(() => {
if (!ref.current) return;
const r = ref.current.getBoundingClientRect();
const margin = 8;
let nx = x, ny = y;
if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin;
if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
}, [x, y]);
const canEdit = schedule.status === 'pending' || schedule.status === 'failed';
const canCancel = schedule.status === 'pending' || schedule.status === 'running';
const canDelete = schedule.status !== 'running';
return (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{schedule.name}</div>
{canEdit && <button onClick={onEdit}><Icon name="edit" size={11} />Edit</button>}
{canCancel && <button onClick={onCancel}><Icon name="x" size={11} />Cancel run</button>}
<button onClick={onCopyId}><Icon name="library" size={11} />Copy schedule ID</button>
{canDelete && <div className="ctx-divider" />}
{canDelete && <button className="danger" onClick={onDelete}><Icon name="trash" size={11} />Delete schedule</button>}
</div>
);
}
function _NowLine({ now, dayStart, pph }) { function _NowLine({ now, dayStart, pph }) {
if (!_sameDay(now, dayStart)) return null; if (!_sameDay(now, dayStart)) return null;
const min = _minutesIntoDay(now, dayStart); const min = _minutesIntoDay(now, dayStart);
@ -1121,6 +1261,7 @@ function Schedule({ navigate }) {
const [showNew, setShowNew] = React.useState(false); const [showNew, setShowNew] = React.useState(false);
const [newDefaults, setNewDefaults] = React.useState(null); const [newDefaults, setNewDefaults] = React.useState(null);
const [editing, setEditing] = React.useState(null); const [editing, setEditing] = React.useState(null);
const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y }
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list' const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
const [day, setDay] = React.useState(() => _dayStart(new Date())); const [day, setDay] = React.useState(() => _dayStart(new Date()));
const [listFilter, setListFilter] = React.useState('upcoming'); const [listFilter, setListFilter] = React.useState('upcoming');
@ -1206,6 +1347,39 @@ function Schedule({ navigate }) {
window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message)); window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message));
}; };
// Drag-resize commit: optimistically patch the in-memory schedule so the
// block stays put after the user lets go, then PUT the new times. The
// refetch reconciles in case the server adjusted anything (or rejected).
const handleResize = (s, newStart, newEnd) => {
setSchedules(prev => prev ? prev.map(x => x.id === s.id ? { ...x, start_at: newStart, end_at: newEnd } : x) : prev);
window.ZAMPP_API.fetch('/schedules/' + s.id, {
method: 'PUT',
body: JSON.stringify({ start_at: newStart, end_at: newEnd }),
})
.then(load)
.catch(e => { alert('Resize failed: ' + e.message); load(); });
};
const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
// Dismiss the context menu on any outside click capture phase so a
// click on a menu item still fires before the menu unmounts.
React.useEffect(() => {
if (!ctxMenu) return;
const close = () => setCtxMenu(null);
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('scroll', close, true);
};
}, [ctxMenu]);
const copyId = (id) => {
if (navigator.clipboard) navigator.clipboard.writeText(id).catch(() => {});
};
// Days for Week view: the 7-day window starting at the Sunday of `day`. // Days for Week view: the 7-day window starting at the Sunday of `day`.
const weekDays = React.useMemo(() => { const weekDays = React.useMemo(() => {
const sun = _addDays(dayStart, -dayStart.getDay()); const sun = _addDays(dayStart, -dayStart.getDay());
@ -1276,6 +1450,8 @@ function Schedule({ navigate }) {
now={now} now={now}
projects={projects} projects={projects}
onEventClick={(s) => setEditing(s)} onEventClick={(s) => setEditing(s)}
onEventContextMenu={openCtx}
onEventResize={handleResize}
onEmptyClick={openNewAt} /> onEmptyClick={openNewAt} />
))} ))}
</div> </div>
@ -1308,6 +1484,8 @@ function Schedule({ navigate }) {
now={now} now={now}
projects={projects} projects={projects}
onEventClick={(s) => setEditing(s)} onEventClick={(s) => setEditing(s)}
onEventContextMenu={openCtx}
onEventResize={handleResize}
onEmptyClick={openNewAt} /> onEmptyClick={openNewAt} />
))} ))}
</div> </div>
@ -1368,6 +1546,17 @@ function Schedule({ navigate }) {
onClose={() => { setShowNew(false); setNewDefaults(null); }} onClose={() => { setShowNew(false); setNewDefaults(null); }}
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />} onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />} {editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
{ctxMenu && (
<_ScheduleContextMenu
schedule={ctxMenu.schedule}
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
onEdit={() => { const s = ctxMenu.schedule; setCtxMenu(null); setEditing(s); }}
onCancel={() => { const s = ctxMenu.schedule; setCtxMenu(null); cancel(s); }}
onDelete={() => { const s = ctxMenu.schedule; setCtxMenu(null); remove(s); }}
onCopyId={() => { const id = ctxMenu.schedule.id; setCtxMenu(null); copyId(id); }} />
)}
</div> </div>
); );
} }

View file

@ -833,17 +833,17 @@
position: absolute; position: absolute;
top: 8px; top: 8px;
height: calc(var(--epg-row-h) - 16px); height: calc(var(--epg-row-h) - 16px);
display: flex; align-items: center; gap: 8px; display: flex; align-items: stretch;
padding: 0 10px 0 14px;
background: var(--bg-2); background: var(--bg-2);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 5px; border-radius: 5px;
font-size: 11.5px; font-size: 11.5px;
text-align: left; text-align: left;
color: var(--text-1); color: var(--text-1);
cursor: pointer;
overflow: hidden; overflow: hidden;
transition: background 80ms, border-color 80ms, transform 80ms; transition: background 80ms, border-color 80ms, transform 80ms, box-shadow 80ms;
/* Don't paint text selection while the operator drags the block. */
user-select: none; -webkit-user-select: none;
} }
.epg-block:hover { .epg-block:hover {
background: var(--bg-3); background: var(--bg-3);
@ -851,12 +851,28 @@
transform: translateY(-1px); transform: translateY(-1px);
z-index: 1; z-index: 1;
} }
.epg-block.dragging {
transform: translateY(-1px);
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--epg-block-color, var(--accent));
transition: none; /* follow cursor 1:1, no easing during drag */
}
.epg-block-bar { .epg-block-bar {
position: absolute; position: absolute;
left: 0; top: 0; bottom: 0; left: 0; top: 0; bottom: 0;
width: 4px; width: 4px;
background: var(--epg-block-color, var(--accent)); background: var(--epg-block-color, var(--accent));
pointer-events: none; /* let the resize handle behind catch pointerdown */
} }
.epg-block-body {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 8px;
padding: 0 10px 0 14px;
cursor: pointer;
overflow: hidden;
}
.epg-block.resizable .epg-block-body { cursor: grab; }
.epg-block.dragging .epg-block-body { cursor: grabbing; }
.epg-block-name { .epg-block-name {
flex: 1; min-width: 0; flex: 1; min-width: 0;
font-weight: 600; font-weight: 600;
@ -866,6 +882,31 @@
font-size: 10px; color: var(--text-3); font-size: 10px; color: var(--text-3);
flex-shrink: 0; flex-shrink: 0;
} }
/* Resize handles. 8px-wide invisible-by-default hit zones at each end;
a 2px tinted bar fades in on hover so the operator sees where the
resize affordance is. Hides for non-pending blocks (no .resizable). */
.epg-block-handle {
position: absolute;
top: 0; bottom: 0;
width: 8px;
cursor: ew-resize;
z-index: 3;
}
.epg-block-handle.left { left: 0; }
.epg-block-handle.right { right: 0; }
.epg-block-handle::after {
content: '';
position: absolute; top: 6px; bottom: 6px;
width: 2px;
background: var(--epg-block-color, var(--accent));
opacity: 0;
transition: opacity 80ms;
}
.epg-block-handle.left::after { left: 2px; }
.epg-block-handle.right::after { right: 2px; }
.epg-block-handle:hover::after,
.epg-block.dragging .epg-block-handle::after { opacity: 0.85; }
.epg-block-glyph { .epg-block-glyph {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;