diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 4a25a7b..4d6df1c 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1,6 +1,8 @@ // English translations. This file is the source of truth for keys — // every other locale should mirror its keys (or fall back to en). export default { + 'schedule.overnight_no_drag': 'This schedule runs past midnight — open it to change its times.', + 'schedule.overnight_note': 'Runs past midnight — shown as two blocks.', // Recovered from dead `t(k) || 'default'` fallbacks: t() returns the key when a string is // missing, so those defaults never rendered and users saw the raw key instead. diff --git a/frontend/js/lib/schedule-grid.js b/frontend/js/lib/schedule-grid.js index 3d02c13..f72d26e 100644 --- a/frontend/js/lib/schedule-grid.js +++ b/frontend/js/lib/schedule-grid.js @@ -117,3 +117,36 @@ export function canMoveAcrossDays(ev) { export function editsWholeSeries(ev) { return !!(ev && ev.recurrence); } + +// A window whose end is BEFORE its start crosses midnight — 22:00 to 04:00 is a real and common +// signage schedule (a bar, a hotel lobby, anything running overnight). The playback engine has +// always understood this: schedule-eval treats start > end as a wrap. The calendar did not, and +// computed a negative height, so an overnight schedule appeared as an 18px sliver at 10pm with +// nothing at all after midnight. +export function crossesMidnight(startMin, endMin) { + return endMin <= startMin; +} + +// Split an overnight window into the pieces a week grid can actually draw: the part before +// midnight on its own day, and the part after midnight on the NEXT one. A same-day window is +// returned unchanged as a single piece, so callers have one shape to render. +export function splitAcrossMidnight(dayIdx, startMin, endMin) { + if (!crossesMidnight(startMin, endMin)) { + return [{ dayIdx, startMin, endMin, continues: false, continued: false }]; + } + const out = [{ dayIdx, startMin, endMin: DAY_MIN, continues: true, continued: false }]; + // Sunday-night spill lands on Monday of the SAME grid, which is what a week view shows; a + // Saturday-night spill would run off the end, so it is simply not drawn rather than wrapping + // round to Sunday and appearing to be a week early. + if (dayIdx < 6 && endMin > 0) { + out.push({ dayIdx: dayIdx + 1, startMin: 0, endMin, continues: false, continued: true }); + } + return out; +} + +// A drag can only express a window inside one day. Moving or resizing an overnight schedule with +// the mouse would therefore clamp it into that day and silently destroy the wrap, so it is +// refused and left to the dialog — the same reasoning as a recurring schedule's day. +export function canDragEvent(ev, startMin, endMin) { + return !crossesMidnight(startMin, endMin); +} diff --git a/frontend/js/views/schedule.js b/frontend/js/views/schedule.js index b978df6..db0f724 100644 --- a/frontend/js/views/schedule.js +++ b/frontend/js/views/schedule.js @@ -4,6 +4,7 @@ import { t } from '../i18n.js'; import { HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange, toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries, isDrag, + splitAcrossMidnight, crossesMidnight, canDragEvent, dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN, } from '../lib/schedule-grid.js'; @@ -253,13 +254,21 @@ export async function render(container) { cal.innerHTML = html; const seenTargets = new Map(); - events.forEach(ev => { + // One schedule can need TWO blocks: an overnight window (22:00–04:00) is drawn as the part + // before midnight on its day and the part after it on the next. Flattening to segments first + // means the drawing code below has a single shape to handle. + const segments = []; + events.forEach((ev) => { const start = new Date(ev.instance_start || ev.start_time); const end = new Date(ev.instance_end || ev.end_time); - const dayIdx = start.getDay(); - const startHour = start.getHours() + start.getMinutes() / 60; - const endHour = end.getHours() + end.getMinutes() / 60; - const duration = endHour - startHour; + const sMin = start.getHours() * 60 + start.getMinutes(); + const eMin = end.getHours() * 60 + end.getMinutes(); + for (const seg of splitAcrossMidnight(start.getDay(), sMin, eMin)) segments.push({ ev, ...seg }); + }); + + segments.forEach(({ ev, dayIdx, startMin, endMin, continues, continued }) => { + const startHour = startMin / 60; + const duration = (endMin - startMin) / 60; const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`); if (!cell) return; @@ -289,10 +298,20 @@ export async function render(container) { } const kind = isGroupSchedule ? t('schedule.target_group') : t('schedule.target_device'); - block.title = `${kind}: ${target.name}\n${label}\n${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}` + // The tooltip names the WHOLE window, not the piece being hovered — the point of a tooltip + // on an overnight block is to say it runs 10pm to 4am, which neither half shows alone. + const whole = new Date(ev.instance_start || ev.start_time); + const wholeEnd = new Date(ev.instance_end || ev.end_time); + block.title = `${kind}: ${target.name}\n${label}\n${whole.toLocaleTimeString()} - ${wholeEnd.toLocaleTimeString()}` + + ((continues || continued) ? `\n${t('schedule.overnight_note')}` : '') + `\n${t('schedule.tooltip_priority', { n: ev.priority })}` + (ev.timezone ? `\n${t('schedule.tz_same').replace('{zone}', ev.timezone)}` : ''); + // Visually join the two halves of an overnight schedule: square off the edge each one + // continues across, so it reads as one window split by midnight rather than two schedules. + if (continues) block.style.borderBottomLeftRadius = block.style.borderBottomRightRadius = '0'; + if (continued) block.style.borderTopLeftRadius = block.style.borderTopRightRadius = '0'; block.dataset.schedId = ev.id; + block.dataset.overnight = (continues || continued) ? '1' : ''; block._ev = ev; block.onclick = (e) => { if (dragState && dragState.moved) return; editSchedule(ev); }; // Bottom grip: the affordance that makes a block resizable rather than only movable. @@ -426,6 +445,13 @@ export async function render(container) { const origin = { x: e.clientX, y: e.clientY }; if (block && block._ev) { const ev = block._ev; + // A drag can only describe a window inside one day, so dragging an overnight schedule + // would clamp it into that day and silently destroy the wrap. Refuse, and say why. + if (block.dataset.overnight) { + dragState = null; + showToast(t('schedule.overnight_no_drag'), 'info'); + return; + } const s = new Date(ev.instance_start || ev.start_time); const en = new Date(ev.instance_end || ev.end_time); const evStart = s.getHours() * 60 + s.getMinutes(); diff --git a/server/test/schedule-grid-math.test.js b/server/test/schedule-grid-math.test.js index b3d066d..b50ecc6 100644 --- a/server/test/schedule-grid-math.test.js +++ b/server/test/schedule-grid-math.test.js @@ -160,3 +160,52 @@ test('the drag readout is human, not 24h minutes', async () => { assert.equal(G.formatRange(0, 45), '12:00 AM – 12:45 AM'); assert.equal(G.formatRange(720, 780), '12:00 PM – 1:00 PM'); }); + +// ---------------------------------------------------------------- crossing midnight + +test('THE GAP: a window ending before it starts crosses midnight', async () => { + // 22:00 -> 04:00 is an ordinary signage schedule. The playback engine has always understood + // it (schedule-eval treats start > end as a wrap); the calendar computed 4 - 22 = -18 hours, + // so it drew an 18px sliver at 10pm and nothing at all after midnight. + assert.equal(G.crossesMidnight(22 * 60, 4 * 60), true); + assert.equal(G.crossesMidnight(9 * 60, 17 * 60), false); + assert.equal(G.crossesMidnight(9 * 60, 9 * 60), true, 'equal ends is a full 24h wrap, not zero'); +}); + +test('an overnight window is drawn as two pieces on consecutive days', async () => { + const segs = G.splitAcrossMidnight(2, 22 * 60, 4 * 60); // Tuesday 22:00 -> Wednesday 04:00 + assert.equal(segs.length, 2); + assert.deepEqual( + segs.map(s => [s.dayIdx, s.startMin, s.endMin]), + [[2, 1320, 1440], [3, 0, 240]]); + assert.equal(segs[0].continues, true, 'the first piece runs into the next day'); + assert.equal(segs[1].continued, true, 'and the second is a continuation'); +}); + +test('the two pieces add up to the real duration', async () => { + const segs = G.splitAcrossMidnight(1, 22 * 60 + 30, 6 * 60 + 15); + const total = segs.reduce((n, s) => n + (s.endMin - s.startMin), 0); + assert.equal(total, (24 * 60 - (22 * 60 + 30)) + (6 * 60 + 15), '7h45m'); +}); + +test('a same-day window is still one piece', async () => { + const segs = G.splitAcrossMidnight(4, 9 * 60, 17 * 60); + assert.equal(segs.length, 1); + assert.equal(segs[0].continues, false); + assert.equal(segs[0].endMin, 17 * 60); +}); + +test('a Saturday-night spill is not wrapped round to Sunday', async () => { + // Wrapping would draw the after-midnight part at the START of the same week, making it look + // like it played six days early. + const segs = G.splitAcrossMidnight(6, 23 * 60, 2 * 60); + assert.equal(segs.length, 1, 'only the part that fits this grid is drawn'); + assert.equal(segs[0].endMin, G.DAY_MIN); +}); + +test('an overnight schedule cannot be dragged, because a drag cannot express it', async () => { + // A drag describes a window inside ONE day; applying it to a wrap would clamp it and silently + // destroy the schedule. + assert.equal(G.canDragEvent({}, 22 * 60, 4 * 60), false); + assert.equal(G.canDragEvent({}, 9 * 60, 17 * 60), true); +});