screentinker/frontend/js/views/schedule.js
ScreenTinker 5c95070d3a Bind the calendar's pointer handlers once, not once per render
A QA pass over my own changes found a real defect. attachGridInteractions ran
on every calendar render, but #calendar is the same element throughout — only
its children are replaced — so each render stacked another full set of pointer
handlers on it. Five weeks of navigation left five, which meant five ghost
blocks during a drag, five context menus on a right-click, and five PUT
requests on a single drop. Verified by counting listeners through the debugger:
five sets after five renders, one after this change.

Also guards the drag-to-create path. It reuses the Add Schedule button's own
handler so the dialog resets exactly as it does for a normal create, but it
called .onclick() unguarded — and a drag is a user gesture that must never
throw. A missing button now quietly does nothing instead of raising an uncaught
error in the middle of an interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:08:31 -05:00

802 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, isDrag,
splitAcrossMidnight, crossesMidnight, canDragEvent,
dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN,
} 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());
// Teardown registered during render (resize listener, etc). Declared here rather than beside
// cleanup() so it is initialised before any render can push to it.
const cleanupFns = [];
const HOURS = Array.from({ length: 24 }, (_, i) => i);
// Where the week view opens when nothing is scheduled yet — the start of a working day.
const DEFAULT_SCROLL_HOUR = 8;
// Below this the week grid stops being usable: seven columns in a phone-width window leaves
// ~50px each, too narrow to read a name or aim a finger at, and the horizontal scroll it forces
// fights the vertical drag. Narrow screens get ONE day at a time instead.
const NARROW_PX = 700;
const isNarrow = () => window.innerWidth < NARROW_PX;
let focusedDay = new Date().getDay(); // which single day a narrow screen is showing
function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
export async function render(container) {
const [devices, content, groups, playlists, layoutsRaw] = await Promise.all([
api.getDevices(),
api.getContent(),
api.getGroups(),
api.getPlaylists(),
API('/layouts'),
]);
const layouts = (Array.isArray(layoutsRaw) ? layoutsRaw : []).filter(l => !l.is_template);
const today = new Date();
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
weekStart.setHours(0, 0, 0, 0);
const DAYS = [
t('schedule.day.sun'), t('schedule.day.mon'), t('schedule.day.tue'),
t('schedule.day.wed'), t('schedule.day.thu'), t('schedule.day.fri'),
t('schedule.day.sat'),
];
container.innerHTML = `
<div class="page-header">
<div><h1>${t('schedule.title')} <span class="help-tip" data-tip="${t('schedule.help_tip')}">?</span></h1><div class="subtitle">${t('schedule.subtitle')}</div></div>
</div>
<div class="schedule-controls" style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<select id="schedDevice" class="input" style="width:220px;max-width:100%;background:var(--bg-input)">
<option value="*">${t('schedule.all_screens')}</option>
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
</select>
<button class="btn btn-secondary btn-sm" id="prevWeek">${t('schedule.prev_week')}</button>
<span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span>
<button class="btn btn-secondary btn-sm" id="nextWeek">${t('schedule.next_week')}</button>
<button class="btn btn-primary btn-sm" id="addScheduleBtn">${t('schedule.add_schedule')}</button>
</div>
<div id="dayStrip" style="display:none;gap:4px;margin-bottom:10px;flex-wrap:wrap"></div>
<!-- Legend: only meaningful in all-screens mode, where blocks from different targets
share one grid. Hidden for a single screen so that view stays uncluttered. -->
<div id="schedLegend" style="display:none;flex-wrap:wrap;gap:10px;margin:-6px 0 14px;font-size:12px"></div>
<div id="calendarScroll" style="position:relative;overflow:auto;max-height:calc(100vh - 260px);border:1px solid var(--border);border-radius:var(--radius-lg)">
<div id="calendar" style="display:grid;grid-template-columns:60px repeat(7,1fr);min-width:800px"></div>
</div>
<div class="modal-overlay" id="scheduleModal" style="display:none">
<div class="modal" style="width:480px">
<div class="modal-header"><h3 id="schedModalTitle">${t('schedule.add_schedule')}</h3>
<button class="btn-icon" onclick="document.getElementById('scheduleModal').style.display='none'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group"><label>${t('schedule.apply_to')}</label>
<div style="display:flex;gap:16px;margin-bottom:8px">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
<input type="radio" name="schedTarget" value="device" checked id="schedTargetDevice"> ${t('schedule.target_device')}
</label>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
<input type="radio" name="schedTarget" value="group" id="schedTargetGroup"> ${t('schedule.target_group')}
</label>
</div>
<select id="schedDeviceSelect" class="input" style="background:var(--bg-input)">
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
</select>
<select id="schedGroupSelect" class="input" style="background:var(--bg-input);display:none">
${groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)} (${t('schedule.group_devices_count', { n: g.device_count })})</option>`).join('')}
</select>
${groups.length === 0 ? `<div id="schedNoGroups" style="display:none;color:var(--text-muted);font-size:12px;margin-top:4px">${t('schedule.no_groups_msg')}</div>` : ''}
<div id="schedZoneNote" style="display:none;color:var(--text-muted);font-size:11px;margin-top:4px">${t('schedule.zone_note')}</div>
</div>
<div class="form-group"><label>${t('schedule.playlist_override')}</label>
<select id="schedPlaylist" class="input" style="background:var(--bg-input)">
<option value="">${t('schedule.no_playlist_override')}</option>
${playlists.map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' ' + t('schedule.draft_suffix') : ''}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>${t('schedule.layout_override')}</label>
<select id="schedLayout" class="input" style="background:var(--bg-input)">
<option value="">${t('schedule.no_layout_override')}</option>
${layouts.map(l => `<option value="${esc(l.id)}">${esc(l.name)}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>${t('schedule.content_label')} <span style="color:var(--text-muted);font-weight:normal;font-size:11px">${t('schedule.content_hint')}</span></label>
<select id="schedContent" class="input" style="background:var(--bg-input)">
<option value="">${t('schedule.content_none')}</option>
${content.map(c => `<option value="${esc(c.id)}">${esc(c.filename)}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>${t('schedule.title_label')}</label><input type="text" id="schedTitle" class="input" placeholder="${t('schedule.title_placeholder')}"></div>
<div style="display:flex;gap:12px">
<div class="form-group" style="flex:1"><label>${t('schedule.start_time')}</label><input type="time" id="schedStart" class="input" value="09:00"></div>
<div class="form-group" style="flex:1"><label>${t('schedule.end_time')}</label><input type="time" id="schedEnd" class="input" value="17:00"></div>
</div>
<!-- Which clock these hours are on. The server resolves the target's zone and the
player evaluates in it, but the user had no way to SEE that: hours typed as
"9 to 5" silently became UTC, so a schedule could sit closed while its owner
watched the screen. Stating the zone is the whole fix from the UI side. -->
<div id="schedTzNote" style="font-size:12px;color:var(--text-muted);margin:-4px 0 12px"></div>
<div class="form-group"><label>${t('schedule.repeat')}</label>
<select id="schedRepeat" class="input" style="background:var(--bg-input)">
<option value="">${t('schedule.repeat_none')}</option>
<option value="FREQ=DAILY">${t('schedule.repeat_daily')}</option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">${t('schedule.repeat_weekdays')}</option>
<option value="FREQ=WEEKLY;BYDAY=SA,SU">${t('schedule.repeat_weekends')}</option>
<option value="FREQ=WEEKLY">${t('schedule.repeat_weekly')}</option>
</select>
</div>
<div class="form-group"><label>${t('schedule.priority')}</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></div>
<div class="form-group"><label>${t('schedule.color')}</label><input type="color" id="schedColor" value="#3B82F6" style="width:60px;height:32px;border:none;cursor:pointer"></div>
</div>
<div class="modal-footer" style="display:flex;justify-content:space-between;gap:8px">
<button class="btn btn-danger" id="deleteScheduleBtn" style="display:none">${t('common.delete')}</button>
<div style="display:flex;gap:8px;margin-left:auto">
<button class="btn btn-secondary" onclick="document.getElementById('scheduleModal').style.display='none'">${t('common.cancel')}</button>
<button class="btn btn-primary" id="saveScheduleBtn">${t('common.save')}</button>
</div>
</div>
</div>
</div>
`;
let currentWeekStart = new Date(weekStart);
let editingId = null;
const deviceRadio = document.getElementById('schedTargetDevice');
const groupRadio = document.getElementById('schedTargetGroup');
const deviceSelect = document.getElementById('schedDeviceSelect');
const groupSelect = document.getElementById('schedGroupSelect');
const noGroupsMsg = document.getElementById('schedNoGroups');
const zoneNote = document.getElementById('schedZoneNote');
function updateTargetVisibility() {
const isGroup = groupRadio.checked;
deviceSelect.style.display = isGroup ? 'none' : '';
groupSelect.style.display = isGroup ? '' : 'none';
if (noGroupsMsg) noGroupsMsg.style.display = (isGroup && groups.length === 0) ? '' : 'none';
zoneNote.style.display = isGroup ? '' : 'none';
}
deviceRadio.addEventListener('change', updateTargetVisibility);
groupRadio.addEventListener('change', updateTargetVisibility);
// State which clock the hours above are on. The server stores a new schedule in the
// TARGET's zone (lib/device-timezone) and the player evaluates in that same zone — but
// the dialog never said so. A user typing "09:00" reasonably assumes their own clock;
// when the target sits in another zone the schedule is correct and still appears to do
// nothing, because it opens hours later. Naming the zone is the fix from the UI side.
const tzNote = document.getElementById('schedTzNote');
function updateTzNote() {
if (!tzNote) return;
const local = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
let zone = null;
if (!groupRadio.checked) {
const d = devices.find(x => x.id === deviceSelect.value);
zone = (d && d.timezone && d.timezone !== 'UTC' ? d.timezone : null) || (d && d.reported_timezone) || null;
}
if (!zone) { tzNote.textContent = t('schedule.tz_unknown'); return; }
tzNote.textContent = (zone === local)
? t('schedule.tz_same').replace('{zone}', zone)
: t('schedule.tz_device').replace('{zone}', zone).replace('{local}', local || '—');
}
deviceRadio.addEventListener('change', updateTzNote);
groupRadio.addEventListener('change', updateTzNote);
deviceSelect.addEventListener('change', updateTzNote);
updateTzNote();
function updateWeekLabel() {
const end = new Date(currentWeekStart);
end.setDate(end.getDate() + 6);
document.getElementById('weekLabel').textContent =
`${currentWeekStart.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
}
// Stable colour per target, so the same screen is the same colour every week and
// across reloads. Hashing the id beats cycling a palette by index, which reshuffles
// whenever a device is added or removed.
const TARGET_COLORS = ['#3B82F6','#8B5CF6','#EC4899','#F59E0B','#10B981','#06B6D4','#EF4444','#84CC16','#A855F7','#14B8A6'];
function colorForTarget(key) {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
return TARGET_COLORS[h % TARGET_COLORS.length];
}
// What an event is aimed at. Group schedules name the group; device schedules name the
// device. In all-screens mode this is the thing the operator is actually scanning for.
function targetOf(ev) {
if (ev.group_id) return { key: 'g:' + ev.group_id, name: ev.group_name || t('schedule.target_group'), isGroup: true };
return { key: 'd:' + (ev.device_id || '?'), name: ev.device_name || t('schedule.target_device'), isGroup: false };
}
async function loadCalendar() {
const deviceId = document.getElementById('schedDevice').value;
if (!deviceId) return;
const allScreens = deviceId === '*';
updateWeekLabel();
// all=1 rather than a workspace id — the server scopes to the caller's own workspace.
const scope = allScreens ? 'all=1' : `device_id=${encodeURIComponent(deviceId)}`;
const events = await API(`/schedules/week?date=${currentWeekStart.toISOString()}&${scope}`);
const cal = document.getElementById('calendar');
// Narrow screens render ONE day. Seven columns in a phone-width window leaves ~50px each —
// too narrow to read a name or aim a finger at — and the horizontal scroll it forces fights
// the vertical drag gesture.
const narrow = isNarrow();
const visibleDays = narrow ? [focusedDay] : [0, 1, 2, 3, 4, 5, 6];
// The template must match how many columns are actually emitted, or the cells wrap or stretch.
cal.style.gridTemplateColumns = `52px repeat(${visibleDays.length},1fr)`;
cal.style.minWidth = narrow ? '0' : '800px';
let html = '<div style="background:var(--bg-secondary);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:6"></div>';
for (const d of visibleDays) {
const date = new Date(currentWeekStart);
date.setDate(date.getDate() + d);
const isToday = date.toDateString() === new Date().toDateString();
html += `<div style="padding:8px;text-align:center;background:var(--bg-secondary);position:sticky;top:0;z-index:6;border-bottom:1px solid var(--border);border-left:1px solid var(--border);
${isToday ? 'color:var(--accent);font-weight:600' : 'color:var(--text-secondary)'};font-size:12px">
${DAYS[d]}<br>${date.getDate()}
</div>`;
}
for (const h of HOURS) {
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? t('schedule.hour_12am') : h < 12 ? h + t('schedule.hour_am') : h === 12 ? t('schedule.hour_12pm') : (h - 12) + t('schedule.hour_pm')}</div>`;
for (const d of visibleDays) {
html += `<div style="position:relative;min-height:${HOUR_PX}px;height:${HOUR_PX}px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
}
}
cal.innerHTML = html;
const seenTargets = new Map();
// One schedule can need TWO blocks: an overnight window (22:0004: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 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;
const isGroupSchedule = !!ev.group_id;
const target = targetOf(ev);
seenTargets.set(target.key, target);
const block = document.createElement('div');
const topOffset = (startHour - Math.floor(startHour)) * HOUR_PX;
// In all-screens mode colour identifies WHO the block is for, so several targets share
// one grid and stay tellable apart. On a single screen the schedule's own colour is
// kept — there is only one target, so colour is free to mean something else.
const bg = allScreens ? colorForTarget(target.key) : (ev.color || '#3B82F6');
const tall = duration * HOUR_PX >= 34;
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(18, duration * HOUR_PX)}px;
background:${bg};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:grab;z-index:1;opacity:0.92;
line-height:1.25;${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.65);' : ''}`;
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
if (allScreens && tall) {
// Two lines when there is room: who it is for, then what plays. The target reads
// first because that is what the eye is scanning the grid for.
block.innerHTML = `<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(target.name)}</div>`
+ `<div style="opacity:.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(label)}</div>`;
} else {
block.textContent = (allScreens || isGroupSchedule) ? `${target.name} \u00b7 ${label}` : label;
}
const kind = isGroupSchedule ? t('schedule.target_group') : t('schedule.target_device');
// 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.
// Hidden on very short blocks, where a grip would cover the whole thing.
if (duration * HOUR_PX >= 22) {
const grip = document.createElement('div');
grip.className = 'sched-resize-grip';
grip.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:10px;cursor:ns-resize;'
+ 'background:linear-gradient(to bottom,transparent,rgba(0,0,0,.28));';
block.appendChild(grip);
}
cell.appendChild(block);
});
// Day strip: only meaningful when the grid is showing a single day.
const strip = document.getElementById('dayStrip');
if (strip) {
strip.style.display = narrow ? 'flex' : 'none';
if (narrow) {
strip.innerHTML = DAYS.map((d, i) => {
const dt = new Date(currentWeekStart); dt.setDate(dt.getDate() + i);
const on = i === focusedDay;
return `<button class="btn btn-sm" data-day-pick="${i}" style="flex:1;min-width:40px;padding:6px 4px;font-size:11px;
${on ? 'background:var(--accent,#3B82F6);color:#fff;border-color:transparent' : ''}">${d}<br>${dt.getDate()}</button>`;
}).join('');
strip.querySelectorAll('[data-day-pick]').forEach((b) => {
b.addEventListener('click', () => { focusedDay = Number(b.dataset.dayPick); loadCalendar(); });
});
}
}
attachGridInteractions(cal);
// Open on the working day, not on midnight. A 24-hour grid that starts at 12am shows a new
// user four hours of empty night and hides the hours anything is actually scheduled in.
// Only on the first render, so it never yanks the view back while someone is scrolling.
const scroller = document.getElementById('calendarScroll');
if (scroller && (!scroller.dataset.scrolled || scroller.dataset.layout !== (narrow ? 'day' : 'week'))) {
scroller.dataset.layout = narrow ? 'day' : 'week';
scroller.dataset.scrolled = '1';
// Earliest scheduled hour if there is one, else the start of a normal working day.
const earliest = events.reduce((min, ev) => {
const d = new Date(ev.instance_start || ev.start_time);
return Math.min(min, d.getHours());
}, 24);
const target = Math.max(0, (earliest === 24 ? DEFAULT_SCROLL_HOUR : earliest) - 1);
// Straight grid arithmetic rather than offsetTop: the scroll container is not a positioned
// ancestor, so offsetTop is measured from the page body and carries the whole header stack
// with it — which scrolled the view hours past where it was aimed.
scroller.scrollTop = target * HOUR_PX;
}
// An empty grid teaches nothing on its own — it looks like a broken page rather than an
// invitation. Say what to do, without blocking the gesture it is describing.
document.querySelectorAll('.sched-empty-hint').forEach(n => n.remove());
if (!events.length && scroller) {
const hint = document.createElement('div');
hint.className = 'sched-empty-hint';
hint.textContent = t('schedule.drag_hint');
hint.style.cssText = 'margin:0 0 8px;padding:8px 12px;border-radius:8px;'
+ 'background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-muted);font-size:12px';
scroller.parentElement?.insertBefore(hint, scroller);
}
// 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');
if (legend) {
const targets = [...seenTargets.values()].sort((a, b) => a.name.localeCompare(b.name));
legend.style.display = (allScreens && targets.length) ? 'flex' : 'none';
legend.innerHTML = targets.map(tg => `
<span style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary)">
<span style="width:11px;height:11px;border-radius:3px;background:${colorForTarget(tg.key)};
${tg.isGroup ? 'border:1.5px dashed rgba(255,255,255,0.65);' : ''}"></span>${esc(tg.name)}
</span>`).join('');
}
}
// ==================== 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) {
// #calendar is the SAME element on every render — only its children are replaced — so
// binding here per render stacked a full set of pointer handlers each time. Five weeks of
// navigation meant five ghosts on a drag and five PUTs on drop. Bind once.
if (cal.dataset.interactionsBound) return;
cal.dataset.interactionsBound = '1';
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;
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();
const evEnd = en.getHours() * 60 + en.getMinutes();
dragState = {
kind: e.target.classList.contains('sched-resize-grip') ? 'resize' : 'move',
ev, block, refCell: cell, moved: false, origin,
grabOffset: startMin - evStart,
evStart, evEnd, dayIdx: dayColumnOf(cell),
};
} else {
dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, origin, dayIdx: dayColumnOf(cell) };
}
// A touchscreen cannot arm the way a mouse does. The browser decides at touch-START
// whether this gesture scrolls the page; by the time a finger has moved far enough to look
// like a drag, scrolling has already begun and the pointer stream is cancelled. So on touch
// we wait for a HOLD, and only then take the gesture over. Everything else still scrolls
// normally, which is what a phone user expects a calendar to do.
dragState.armMode = dragArmMode(e.pointerType);
if (dragState.armMode === 'longpress') {
dragState.armed = false;
dragState.longPressTimer = setTimeout(() => {
if (!dragState) return;
dragState.armed = true;
cal.style.touchAction = 'none'; // taken over — now the page must NOT scroll
if (dragState.block) dragState.block.style.opacity = '0.35';
if (navigator.vibrate) { try { navigator.vibrate(10); } catch (_) { /* optional */ } }
}, LONG_PRESS_MS);
} else {
dragState.armed = true;
}
cal.setPointerCapture?.(e.pointerId);
});
cal.addEventListener('pointermove', (e) => {
if (!dragState) return;
// Ignore the jitter of an ordinary click. Until the pointer has actually travelled, this
// is still a click and must stay one, or click-to-edit never fires.
const travelled = isDrag(e.clientX - dragState.origin.x, e.clientY - dragState.origin.y);
// Touch that moves BEFORE the hold completes is the user scrolling. Let go of it entirely
// rather than fighting the browser for the gesture.
if (dragState.armMode === 'longpress' && !dragState.armed) {
if (travelled) { clearTimeout(dragState.longPressTimer); dragState = null; clearGhost(); }
return;
}
if (!dragState.moved) {
if (!travelled) return;
dragState.moved = true;
cal.style.touchAction = 'none'; // stop a touch drag scrolling the page
cal.style.cursor = dragState.kind === 'resize' ? 'ns-resize' : 'grabbing';
if (dragState.block) dragState.block.style.opacity = '0.35'; // show what is being moved
}
const now = gridMinutesFromEvent(e, cal);
if (now == null) return;
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 resetDragChrome = (st) => {
cal.style.touchAction = '';
cal.style.cursor = '';
if (st && st.block) st.block.style.opacity = '';
};
const finish = async (e) => {
const st = dragState;
dragState = null;
clearGhost();
if (st) clearTimeout(st.longPressTimer);
resetDragChrome(st);
try { cal.releasePointerCapture?.(e.pointerId); } catch (_) { /* already released */ }
if (!st) return;
// A tap or click on empty space with no drag still means "put something here" — and on a
// phone it is the ONLY create gesture, since dragging out a range with a finger is awkward.
if (!st.moved && st.kind === 'create' && st.anchorMin != null) {
const d0 = new Date(currentWeekStart);
d0.setDate(d0.getDate() + (st.dayIdx || 0));
const start = Math.floor(st.anchorMin / 15) * 15;
openCreateAt(d0, start, Math.min(start + DEFAULT_NEW_MIN, 24 * 60));
return;
}
if (!st.pending || !st.moved) return; // a tap on a block: let the click handler edit it
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'))) {
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', () => { const st = dragState; dragState = null; clearGhost(); resetDragChrome(st); });
// 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'), () => editSchedule(ev)],
[t('schedule.ctx_duplicate'), () => duplicateSchedule(ev)],
[t('schedule.ctx_delete'), () => deleteSchedule(ev)]]
: [[t('schedule.ctx_new'), () => 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'))) return;
try {
await API(`/schedules/${ev.id}`, { method: 'DELETE' });
showToast(t('schedule.toast.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) {
// Reuses the Add Schedule button's own handler so the dialog resets exactly as it does for a
// normal create. Guarded because a drag is a user gesture that must never throw: if that
// button or its handler is missing, the drop should quietly do nothing rather than raise an
// uncaught error in the middle of an interaction.
const addBtn = document.getElementById('addScheduleBtn');
if (!addBtn || typeof addBtn.onclick !== 'function') return;
addBtn.onclick();
const hhmm = (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const startEl = document.getElementById('schedStart');
const endEl = document.getElementById('schedEnd');
if (startEl) startEl.value = hhmm(startMin);
if (endEl) endEl.value = hhmm(endMin);
pendingCreateDate = dayDate;
}
let pendingCreateDate = null;
function editSchedule(ev) {
editingId = ev.id;
document.getElementById('schedModalTitle').textContent = t('schedule.edit_schedule');
document.getElementById('schedPlaylist').value = ev.playlist_id || '';
document.getElementById('schedLayout').value = ev.layout_id || '';
document.getElementById('schedContent').value = ev.content_id || '';
document.getElementById('schedTitle').value = ev.title || '';
const start = new Date(ev.start_time);
const end = new Date(ev.end_time);
document.getElementById('schedStart').value = `${String(start.getHours()).padStart(2,'0')}:${String(start.getMinutes()).padStart(2,'0')}`;
document.getElementById('schedEnd').value = `${String(end.getHours()).padStart(2,'0')}:${String(end.getMinutes()).padStart(2,'0')}`;
document.getElementById('schedRepeat').value = ev.recurrence || '';
document.getElementById('schedPriority').value = ev.priority || 0;
document.getElementById('schedColor').value = ev.color || '#3B82F6';
if (ev.group_id) {
groupRadio.checked = true;
groupSelect.value = ev.group_id;
} else {
deviceRadio.checked = true;
deviceSelect.value = ev.device_id || document.getElementById('schedDevice').value;
}
updateTargetVisibility();
document.getElementById('deleteScheduleBtn').style.display = '';
document.getElementById('scheduleModal').style.display = 'flex';
}
document.getElementById('addScheduleBtn').onclick = () => {
editingId = null;
document.getElementById('schedModalTitle').textContent = t('schedule.add_schedule');
document.getElementById('schedTitle').value = '';
document.getElementById('schedPlaylist').value = '';
document.getElementById('schedLayout').value = '';
document.getElementById('schedContent').value = '';
deviceRadio.checked = true;
deviceSelect.value = document.getElementById('schedDevice').value;
updateTargetVisibility();
document.getElementById('deleteScheduleBtn').style.display = 'none';
document.getElementById('scheduleModal').style.display = 'flex';
};
document.getElementById('deleteScheduleBtn').onclick = async () => {
if (!editingId) return;
if (!confirm(t('schedule.confirm_delete'))) return;
try {
await API(`/schedules/${editingId}`, { method: 'DELETE' });
document.getElementById('scheduleModal').style.display = 'none';
showToast(t('schedule.toast.deleted'), 'success');
loadCalendar();
} catch (err) {
showToast(err.message, 'error');
}
};
document.getElementById('saveScheduleBtn').onclick = async () => {
const isGroup = groupRadio.checked;
const contentId = document.getElementById('schedContent').value;
const startTime = document.getElementById('schedStart').value;
const endTime = document.getElementById('schedEnd').value;
if (isGroup && groups.length === 0) {
showToast(t('schedule.toast.no_groups'), 'error');
return;
}
const playlistId = document.getElementById('schedPlaylist').value;
const layoutId = document.getElementById('schedLayout').value;
// 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,
layout_id: layoutId || null,
title: document.getElementById('schedTitle').value,
start_time: `${today}T${startTime}:00`,
end_time: `${today}T${endTime}:00`,
recurrence: document.getElementById('schedRepeat').value || null,
priority: parseInt(document.getElementById('schedPriority').value) || 0,
color: document.getElementById('schedColor').value,
};
if (isGroup) {
data.group_id = groupSelect.value;
} else {
data.device_id = deviceSelect.value;
}
try {
if (editingId) {
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await API('/schedules', { method: 'POST', body: JSON.stringify(data) });
}
document.getElementById('scheduleModal').style.display = 'none';
showToast(t('schedule.toast.saved'), 'success');
loadCalendar();
} catch (err) {
showToast(err.message, 'error');
}
};
document.getElementById('schedDevice').onchange = loadCalendar;
document.getElementById('prevWeek').onclick = () => { currentWeekStart.setDate(currentWeekStart.getDate() - 7); loadCalendar(); };
document.getElementById('nextWeek').onclick = () => { currentWeekStart.setDate(currentWeekStart.getDate() + 7); loadCalendar(); };
// Re-render across the narrow/wide boundary only — resizing within one layout must not throw
// away a scroll position or an open drag for nothing.
// Rotating a phone crosses the breakpoint in both directions: ~390px portrait is one day,
// ~844px landscape is the full week. Rotation also fires several resize events in a burst, and
// on iOS the dimensions are briefly the OLD ones when orientationchange arrives — so settle
// first and then decide, rather than re-rendering against a size that is about to change again.
let wasNarrow = isNarrow();
let settleTimer = null;
const onViewportChange = () => {
clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
const now = isNarrow();
if (now !== wasNarrow) { wasNarrow = now; loadCalendar(); }
}, 150);
};
window.addEventListener('resize', onViewportChange);
window.addEventListener('orientationchange', onViewportChange);
cleanupFns.push(() => {
clearTimeout(settleTimer);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('orientationchange', onViewportChange);
});
loadCalendar();
}
export function cleanup() { while (cleanupFns.length) { try { cleanupFns.pop()(); } catch (_) { /* */ } } }