screentinker/frontend/js/views/schedule.js
ScreenTinker 653108624d Show one day at a time when the week will not fit
The week grid was a fixed 800px of seven columns. On a phone that is a
horizontal scroll through ~50px columns — too narrow to read a name or aim a
finger at, and the sideways scrolling fights the vertical drag gesture the
calendar depends on.

Below 700px it now renders a single day, with a strip of the seven dates above
it to move between them. The hour column narrows to match, and nothing scrolls
horizontally in either orientation.

Rotation crosses that boundary in both directions — a phone is about 390px
upright and about 844px on its side — so the layout is rebuilt on resize and on
orientationchange. Both are debounced: rotation fires a burst of resize events,
and on iOS the reported dimensions are briefly the pre-rotation ones, so
settling first avoids rebuilding against a size that is about to change again.
Only a crossing rebuilds; resizing within one layout leaves the view alone. The
opening scroll is re-aimed after a crossing, since it was measured against a
grid that no longer exists.

Verified by driving a real browser through portrait, landscape and back:
one column then seven then one, no horizontal overflow at any point, and the
day strip moves between days.

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

763 lines
41 KiB
JavaScript

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,
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();
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 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');
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.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 * 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) {
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;
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) {
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');
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 (_) { /* */ } } }