mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Draw a schedule that runs past midnight
10pm to 4am is an ordinary signage schedule and the playback engine has always understood it — schedule-eval treats an end before a start as a wrap. The calendar did not. It computed four minus twenty-two, got negative eighteen hours, and drew an eighteen-pixel sliver at 10pm with nothing at all after midnight. The schedule played correctly while appearing broken. An overnight window is now split into the pieces a week grid can draw: the part before midnight on its own day, the part after it on the next, squared off where they meet so they read as one window rather than two schedules. The tooltip names the whole span, since neither half shows it alone. A Saturday night spill is simply not drawn rather than wrapped round to Sunday, where it would appear to have played six days early. Dragging one is refused. A drag describes a window inside a single day, so applying it to a wrap would clamp it into that day and silently destroy the schedule — the same reason a recurring schedule's day cannot be dragged. Verified in a browser against a real 22:00 to 04:00 schedule: 88px on Tuesday night, 176px on Wednesday morning, alongside an ordinary daytime block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
a62396c2dd
commit
832a9c9bb2
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue