diff --git a/frontend/js/lib/schedule-grid.js b/frontend/js/lib/schedule-grid.js new file mode 100644 index 0000000..b9634ba --- /dev/null +++ b/frontend/js/lib/schedule-grid.js @@ -0,0 +1,90 @@ +// Geometry and time maths for the week calendar's direct manipulation (drag to create, drag to +// move, drag to resize). Kept apart from the view so the arithmetic — the part that silently +// produces a schedule an hour off, or one that ends before it starts — is testable without a +// browser. +// +// 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; +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; + +export const minutesToPx = (min) => (min / 60) * HOUR_PX; +export const pxToMinutes = (px) => (px / HOUR_PX) * 60; + +// Round to the nearest SNAP_MIN. Nearest rather than floor: dragging to 10:58 should give 11:00, +// not 10:45, because the pointer is a blunt instrument and people aim at the line. +export function snapMinutes(min, snap = SNAP_MIN) { + return Math.round(min / snap) * snap; +} + +// Clamp a dragged range into a valid one: inside the day, at least MIN_DURATION_MIN long, and +// never inverted. Returns {startMin, endMin}. +export function clampRange(startMin, endMin) { + let s = Math.max(0, Math.min(DAY_MIN - MIN_DURATION_MIN, Math.round(startMin))); + let e = Math.round(endMin); + if (e < s + MIN_DURATION_MIN) e = s + MIN_DURATION_MIN; // dragging up past the start, or a click + if (e > DAY_MIN) { e = DAY_MIN; s = Math.min(s, e - MIN_DURATION_MIN); } + return { startMin: s, endMin: e }; +} + +// A drag that started at anchorMin and is currently at pointerMin, in either direction. Outlook +// lets you drag upward from the anchor and treats the anchor as the END; so do we. +export function rangeFromDrag(anchorMin, pointerMin) { + const a = snapMinutes(anchorMin); + const b = snapMinutes(pointerMin); + return clampRange(Math.min(a, b), Math.max(a, b)); +} + +// Move a block of fixed length so it now STARTS at startMin, without letting it run off the end +// of the day (it slides back instead of being silently truncated — a move must not change length). +export function moveRange(startMin, durationMin) { + const dur = Math.max(MIN_DURATION_MIN, Math.round(durationMin)); + let s = snapMinutes(Math.max(0, startMin)); + if (s + dur > DAY_MIN) s = DAY_MIN - dur; + return { startMin: Math.max(0, s), endMin: Math.max(0, s) + dur }; +} + +// Resize by dragging the bottom edge: the start is fixed, the end follows the pointer. +export function resizeRange(startMin, pointerMin) { + return clampRange(startMin, snapMinutes(pointerMin)); +} + +const pad = (n) => String(n).padStart(2, '0'); + +// The wire format the API stores: a LOCAL 'YYYY-MM-DDTHH:MM:00'. Deliberately not toISOString(), +// which converts to UTC and would shift every schedule by the browser's offset — the same class of +// bug as storing a schedule in the wrong zone. +export function toLocalStamp(date, minutes) { + const d = new Date(date.getTime()); + d.setHours(0, 0, 0, 0); + d.setMinutes(minutes); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`; +} + +export function formatRange(startMin, endMin) { + const fmt = (m) => { + const h24 = Math.floor(m / 60) % 24, mm = m % 60; + const ampm = h24 < 12 ? 'AM' : 'PM'; + const h12 = h24 % 12 === 0 ? 12 : h24 % 12; + return `${h12}:${pad(mm)} ${ampm}`; + }; + return `${fmt(startMin)} – ${fmt(endMin)}`; +} + +// 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 — +// a different operation with different consequences for every other instance. Refuse it here and +// send the user to the dialog rather than silently rewriting a recurrence from a mouse gesture. +export function canMoveAcrossDays(ev) { + return !(ev && ev.recurrence); +} + +// Dragging a recurring event's TIME still edits the whole series, since the series has one +// time-of-day. That is worth confirming out loud rather than assuming. +export function editsWholeSeries(ev) { + return !!(ev && ev.recurrence); +} diff --git a/frontend/js/views/schedule.js b/frontend/js/views/schedule.js index 89c62b2..309ab70 100644 --- a/frontend/js/views/schedule.js +++ b/frontend/js/views/schedule.js @@ -1,6 +1,10 @@ import { api } from '../api.js'; import { showToast } from '../components/toast.js'; import { t } from '../i18n.js'; +import { + HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange, + toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries, +} 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()); @@ -267,10 +271,22 @@ export async function render(container) { block.title = `${kind}: ${target.name}\n${label}\n${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}` + `\n${t('schedule.tooltip_priority', { n: ev.priority })}` + (ev.timezone ? `\n${t('schedule.tz_same').replace('{zone}', ev.timezone)}` : ''); - block.onclick = () => editSchedule(ev); + block.dataset.schedId = ev.id; + 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. + // Hidden on very short blocks, where a grip would cover the whole thing. + if (duration * 28 >= 24) { + 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;'; + block.appendChild(grip); + } cell.appendChild(block); }); + attachGridInteractions(cal); + // Legend — only in all-screens mode, where the grid mixes targets. Sorted so the order // is stable between reloads rather than following whatever the query happened to return. const legend = document.getElementById('schedLegend'); @@ -285,6 +301,205 @@ export async function render(container) { } } + // ==================== Direct manipulation (Outlook-style) ==================== + // The calendar was read-only: the only way to create or move anything was the dialog. On a week + // grid that is the wrong instrument — the grid already shows exactly where a thing goes, so the + // grid should be where you put it. Three gestures, all sharing one pointer loop: + // drag empty space -> create (opens the dialog PREFILLED with the time you drew) + // drag a block -> move + // drag a block grip -> resize the end + // + // A drag is committed on pointerup, never mid-move, so an accidental nudge costs nothing. The + // click-to-edit handler is suppressed when a drag actually moved, or every drag would also open + // the dialog on release. + let dragState = null; + let ghostEl = null; + + const dayColumnOf = (el) => { const c = el.closest('[data-day]'); return c ? Number(c.dataset.day) : null; }; + + function gridMinutesFromEvent(e, cal) { + // Absolute minutes-since-midnight from the pointer, using the hour cell under it as the datum + // rather than the grid top — the header row and any borders would otherwise skew every value. + const cell = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-hour]'); + const ref = cell || (dragState && dragState.refCell); + if (!ref) return null; + const r = ref.getBoundingClientRect(); + return Number(ref.dataset.hour) * 60 + pxToMinutes(e.clientY - r.top); + } + + function showGhost(cal, dayIdx, startMin, endMin, label) { + const host = cal.querySelector(`[data-hour="${Math.floor(startMin / 60)}"][data-day="${dayIdx}"]`); + if (!host) return; + if (!ghostEl) { + ghostEl = document.createElement('div'); + ghostEl.className = 'sched-ghost'; + ghostEl.style.cssText = 'position:absolute;left:2px;right:2px;border-radius:3px;z-index:5;pointer-events:none;' + + 'background:var(--accent,#3B82F6);opacity:.55;color:#fff;font-size:10px;padding:2px 4px;line-height:1.2;' + + 'border:1px solid rgba(255,255,255,.8);overflow:hidden'; + } + ghostEl.style.top = `${minutesToPx(startMin - Math.floor(startMin / 60) * 60)}px`; + ghostEl.style.height = `${Math.max(14, minutesToPx(endMin - startMin))}px`; + ghostEl.textContent = label; + host.appendChild(ghostEl); + } + function clearGhost() { if (ghostEl && ghostEl.parentNode) ghostEl.parentNode.removeChild(ghostEl); } + + function attachGridInteractions(cal) { + cal.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; // left button only; right opens the menu + const block = e.target.closest('[data-sched-id]'); + const cell = e.target.closest('[data-hour][data-day]'); + if (!cell) return; + const startMin = gridMinutesFromEvent(e, cal); + if (startMin == null) return; + + if (block && block._ev) { + const ev = block._ev; + 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(); + const evEnd = en.getHours() * 60 + en.getMinutes(); + dragState = { + kind: e.target.classList.contains('sched-resize-grip') ? 'resize' : 'move', + ev, block, refCell: cell, moved: false, + grabOffset: startMin - evStart, + evStart, evEnd, dayIdx: dayColumnOf(cell), + }; + } else { + dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, dayIdx: dayColumnOf(cell) }; + } + cal.setPointerCapture?.(e.pointerId); + }); + + cal.addEventListener('pointermove', (e) => { + if (!dragState) return; + 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); + } else if (dragState.kind === 'resize') { + range = resizeRange(dragState.evStart, now); + } else { + range = moveRange(now - dragState.grabOffset, dragState.evEnd - dragState.evStart); + // Sideways only where the day is a real date. A recurring instance's day comes from its + // rule, so dragging it across columns would silently rewrite the recurrence. + const overDay = dayColumnOf(document.elementFromPoint(e.clientX, e.clientY) || dragState.refCell); + if (overDay != null && canMoveAcrossDays(dragState.ev)) day = overDay; + } + dragState.pending = { range, day }; + clearGhost(); + showGhost(cal, day, range.startMin, range.endMin, formatRange(range.startMin, range.endMin)); + }); + + const finish = async (e) => { + const st = dragState; + dragState = null; + clearGhost(); + if (!st || !st.pending || !st.moved) { setTimeout(() => { if (!dragState) { /* let click through */ } }, 0); return; } + const { range, day } = st.pending; + const dayDate = new Date(currentWeekStart); + dayDate.setDate(dayDate.getDate() + day); + + if (st.kind === 'create') { + openCreateAt(dayDate, range.startMin, range.endMin); + return; + } + if (editsWholeSeries(st.ev) + && !confirm(t('schedule.confirm_series') || 'This schedule repeats. Changing it here updates every occurrence. Continue?')) { + loadCalendar(); + return; + } + try { + await API(`/schedules/${st.ev.id}`, { + method: 'PUT', + body: JSON.stringify({ + start_time: toLocalStamp(dayDate, range.startMin), + end_time: toLocalStamp(dayDate, range.endMin), + }), + }); + showToast(t('schedule.toast.saved'), 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + loadCalendar(); + }; + cal.addEventListener('pointerup', finish); + cal.addEventListener('pointercancel', () => { dragState = null; clearGhost(); }); + + // Right-click: act on what is under the pointer, like every calendar people already use. + cal.addEventListener('contextmenu', (e) => { + const cell = e.target.closest('[data-hour][data-day]'); + if (!cell) return; + e.preventDefault(); + const block = e.target.closest('[data-sched-id]'); + const minutes = gridMinutesFromEvent(e, cal) ?? Number(cell.dataset.hour) * 60; + const dayDate = new Date(currentWeekStart); + dayDate.setDate(dayDate.getDate() + (dayColumnOf(cell) || 0)); + showContextMenu(e.clientX, e.clientY, block && block._ev, dayDate, minutes); + }); + } + + function showContextMenu(x, y, ev, dayDate, minutes) { + document.querySelectorAll('.sched-ctx').forEach(n => n.remove()); + const menu = document.createElement('div'); + menu.className = 'sched-ctx'; + menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:2000;min-width:170px;background:var(--bg-secondary,#1f2530);` + + 'border:1px solid var(--border,#333);border-radius:6px;padding:4px;box-shadow:0 6px 24px rgba(0,0,0,.4);font-size:13px'; + const items = ev + ? [[t('schedule.ctx_edit') || 'Edit…', () => editSchedule(ev)], + [t('schedule.ctx_duplicate') || 'Duplicate', () => duplicateSchedule(ev)], + [t('schedule.ctx_delete') || 'Delete', () => deleteSchedule(ev)]] + : [[t('schedule.ctx_new') || 'New schedule here…', () => openCreateAt(dayDate, Math.floor(minutes / 15) * 15, Math.floor(minutes / 15) * 15 + 60)]]; + items.forEach(([label, fn]) => { + const b = document.createElement('div'); + b.textContent = label; + b.style.cssText = 'padding:7px 10px;border-radius:4px;cursor:pointer;color:var(--text-primary,#e6edf7)'; + b.onmouseenter = () => { b.style.background = 'var(--bg-primary,#151b2b)'; }; + b.onmouseleave = () => { b.style.background = ''; }; + b.onclick = () => { menu.remove(); fn(); }; + menu.appendChild(b); + }); + document.body.appendChild(menu); + const close = (evt) => { if (!menu.contains(evt.target)) { menu.remove(); document.removeEventListener('pointerdown', close, true); } }; + setTimeout(() => document.addEventListener('pointerdown', close, true), 0); + } + + async function duplicateSchedule(ev) { + try { + await API('/schedules', { method: 'POST', body: JSON.stringify({ + device_id: ev.device_id || null, group_id: ev.group_id || null, + content_id: ev.content_id || null, playlist_id: ev.playlist_id || null, layout_id: ev.layout_id || null, + title: ev.title ? `${ev.title} (copy)` : null, + start_time: ev.start_time, end_time: ev.end_time, + recurrence: ev.recurrence || null, priority: ev.priority || 0, color: ev.color || '#3B82F6', + }) }); + showToast(t('schedule.toast.saved'), 'success'); + } catch (err) { showToast(err.message, 'error'); } + loadCalendar(); + } + + async function deleteSchedule(ev) { + if (!confirm(t('schedule.confirm_delete') || 'Delete this schedule?')) return; + try { + await API(`/schedules/${ev.id}`, { method: 'DELETE' }); + showToast(t('schedule.toast.deleted') || 'Deleted', 'success'); + } catch (err) { showToast(err.message, 'error'); } + loadCalendar(); + } + + // Open the dialog already filled in with the slot that was drawn, so the gesture supplies the + // times and the dialog only has to supply what it alone knows (which playlist, which target). + function openCreateAt(dayDate, startMin, endMin) { + document.getElementById('addScheduleBtn').onclick(); + const hhmm = (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`; + document.getElementById('schedStart').value = hhmm(startMin); + document.getElementById('schedEnd').value = hhmm(endMin); + pendingCreateDate = dayDate; + } + let pendingCreateDate = null; + function editSchedule(ev) { editingId = ev.id; document.getElementById('schedModalTitle').textContent = t('schedule.edit_schedule'); @@ -354,7 +569,12 @@ export async function render(container) { const playlistId = document.getElementById('schedPlaylist').value; const layoutId = document.getElementById('schedLayout').value; - const today = new Date().toISOString().split('T')[0]; + // The date a new schedule is stamped with. A drag supplies the day it was drawn on; + // otherwise it is today. Built from LOCAL parts, not toISOString(), which is UTC and puts + // anyone west of Greenwich on the previous day for part of their evening. + const dref = pendingCreateDate || new Date(); + const today = `${dref.getFullYear()}-${String(dref.getMonth() + 1).padStart(2, '0')}-${String(dref.getDate()).padStart(2, '0')}`; + pendingCreateDate = null; const data = { content_id: contentId || null, playlist_id: playlistId || null, diff --git a/server/test/schedule-grid-math.test.js b/server/test/schedule-grid-math.test.js new file mode 100644 index 0000000..ae8e19b --- /dev/null +++ b/server/test/schedule-grid-math.test.js @@ -0,0 +1,112 @@ +'use strict'; + +// The week calendar now supports direct manipulation — drag empty space to create, drag a block to +// move it, drag its grip to resize. The gestures are only as good as the arithmetic underneath, +// and that arithmetic fails quietly: an off-by-one hour, a block that ends before it starts, or a +// UTC conversion that moves a schedule to the previous day all LOOK fine on screen and only show +// up as a screen playing at the wrong time. +// +// So the maths lives in frontend/js/lib/schedule-grid.js as pure functions and is pinned here. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const MOD = pathToFileURL(path.join(__dirname, '..', '..', 'frontend', 'js', 'lib', 'schedule-grid.js')).href; +let G; +test('load the module', async () => { G = await import(MOD); assert.ok(G.HOUR_PX > 0); }); + +test('pixels and minutes round-trip', async () => { + G = G || await import(MOD); + for (const min of [0, 15, 90, 447, 1439]) { + assert.ok(Math.abs(G.pxToMinutes(G.minutesToPx(min)) - min) < 0.001, `${min} survives`); + } +}); + +test('snapping goes to the NEAREST quarter, not the one below', async () => { + // Dragging to 10:58 means 11:00. Flooring would silently give 10:45 and read as a broken drag. + assert.equal(G.snapMinutes(658), 660); + assert.equal(G.snapMinutes(652), 645); + assert.equal(G.snapMinutes(0), 0); +}); + +test('a drag upward is still a valid range', async () => { + // Anchor at 14:00, drag up to 12:00 — Outlook treats the anchor as the end. Without this the + // range inverts and the schedule is nonsense. + const r = G.rangeFromDrag(840, 720); + assert.equal(r.startMin, 720); + assert.equal(r.endMin, 840); +}); + +test('a click without movement still yields a usable block, not a zero-height one', async () => { + const r = G.rangeFromDrag(600, 600); + assert.equal(r.endMin - r.startMin, G.MIN_DURATION_MIN, 'a minimum length is enforced'); +}); + +test('a range cannot escape the day', async () => { + const late = G.rangeFromDrag(1430, 1600); + assert.ok(late.endMin <= G.DAY_MIN, 'clamped to midnight'); + assert.ok(late.startMin < late.endMin, 'and still valid'); + const early = G.rangeFromDrag(-120, 30); + assert.ok(early.startMin >= 0); +}); + +test('MOVING a block keeps its length — that is what makes it a move', async () => { + const r = G.moveRange(9 * 60, 90); + assert.equal(r.startMin, 540); + assert.equal(r.endMin, 630); + assert.equal(r.endMin - r.startMin, 90); +}); + +test('a move near midnight slides back instead of being truncated', async () => { + // Truncating would quietly shorten a 2h schedule to 10 minutes. + const r = G.moveRange(23 * 60 + 50, 120); + assert.equal(r.endMin, G.DAY_MIN); + assert.equal(r.endMin - r.startMin, 120, 'length preserved'); +}); + +test('RESIZING moves only the end', async () => { + const r = G.resizeRange(540, 700); + assert.equal(r.startMin, 540); + assert.equal(r.endMin, 705, 'snapped'); +}); + +test('resizing above the start does not invert the block', async () => { + const r = G.resizeRange(600, 300); + assert.equal(r.startMin, 600); + assert.equal(r.endMin, 600 + G.MIN_DURATION_MIN); +}); + +test('THE TIMEZONE TRAP: the stamp is LOCAL, not UTC', async () => { + // toISOString() would render 00:30 local as the PREVIOUS day for anyone west of Greenwich — + // the same class of bug as storing a schedule in the wrong zone. + const d = new Date(2026, 6, 28, 12, 0, 0); // 28 Jul 2026, local + assert.equal(G.toLocalStamp(d, 30), '2026-07-28T00:30:00', 'early morning stays on the 28th'); + assert.equal(G.toLocalStamp(d, 23 * 60 + 45), '2026-07-28T23:45:00', 'late evening too'); +}); + +test('the stamp is minute-accurate across the day', async () => { + const d = new Date(2026, 0, 5, 8, 0, 0); + assert.equal(G.toLocalStamp(d, 0), '2026-01-05T00:00:00'); + assert.equal(G.toLocalStamp(d, 13 * 60 + 15), '2026-01-05T13:15:00'); +}); + +test('a one-off may be dragged to another DAY; a repeating one may not', async () => { + // A one-off's day IS its date. A repeating schedule's day comes from its rule, so dragging an + // instance sideways would rewrite the recurrence for every other occurrence too — that belongs + // in the dialog, not in a mouse gesture. + assert.equal(G.canMoveAcrossDays({ id: 1 }), true); + assert.equal(G.canMoveAcrossDays({ id: 2, recurrence: 'FREQ=WEEKLY' }), false); +}); + +test('editing a repeating schedule is flagged as editing the series', async () => { + assert.equal(G.editsWholeSeries({ recurrence: 'FREQ=DAILY' }), true); + assert.equal(G.editsWholeSeries({}), false); +}); + +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'); + assert.equal(G.formatRange(720, 780), '12:00 PM – 1:00 PM'); +});