diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index c58d912..e951fef 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -1018,55 +1018,158 @@ function _EpgRuler({ pph }) { ); } -function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick }) { - const s = new Date(event.start_at); - const e = new Date(event.end_at); - const startMin = _minutesIntoDay(s, dayStart); - const endMin = _minutesIntoDay(e, dayStart); +// Minimum schedule length the UI permits while dragging. Anything shorter +// rarely reflects a real plan and would let the operator accidentally +// dismiss a block to zero width. +const _EPG_MIN_MS = 5 * 60 * 1000; +// 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 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']; - if (isLive) classes.push('live'); - if (isFailed) classes.push('failed'); + if (isLive) classes.push('live'); + if (isFailed) classes.push('failed'); else if (isPast) classes.push('past'); + if (drag && drag.moved) classes.push('dragging'); + if (canDrag) classes.push('resizable'); return ( - + {/* Body click → edit, body drag → move. We hang the click on pointerup + so the threshold check above can demote a drag back to a click. */} +