mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Make the week calendar directly manipulable
The calendar rendered schedules but could not be used to change them. Creating or moving anything meant opening a dialog and typing times, which is the wrong instrument on a week grid: the grid already shows exactly where a thing goes, so the grid should be where it is put. My previous change made the grid easier to READ — all screens at once, a colour and a name per target — and left the interaction untouched, which was only half of what was asked for. Three gestures now share one pointer loop. Dragging empty space draws a slot and opens the dialog prefilled with the time drawn, so the gesture supplies the times and the dialog supplies only what it alone knows. Dragging a block moves it. Dragging its bottom grip resizes the end. A live ghost shows the range as a readable time while dragging, and nothing is committed until release, so an accidental nudge costs nothing. Right-click acts on what is under the pointer: new here, or edit, duplicate and delete on a block. Dragging a repeating schedule sideways is refused. A one-off's day IS its date, but a repeating one's day comes from its rule, so moving an instance across columns would rewrite the recurrence for every other occurrence — a different operation, and not one a mouse gesture should perform silently. Changing a repeating schedule's TIME does still edit the whole series, since a series has one time of day, so that is confirmed out loud rather than assumed. The arithmetic is a separate module of pure functions, because it is the part that fails quietly: a block that ends before it starts, a move near midnight truncated instead of slid back, or a stamp built with toISOString() putting anyone west of Greenwich on the previous day. Tests pin each of those. That last one was already present in the create path and is fixed here too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
433fbef191
commit
98bde220ff
90
frontend/js/lib/schedule-grid.js
Normal file
90
frontend/js/lib/schedule-grid.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
112
server/test/schedule-grid-math.test.js
Normal file
112
server/test/schedule-grid-math.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in a new issue