mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
10pm to 4am is an ordinary signage schedule and the playback engine has always understood it — schedule-eval treats an end before a start as a wrap. The calendar did not. It computed four minus twenty-two, got negative eighteen hours, and drew an eighteen-pixel sliver at 10pm with nothing at all after midnight. The schedule played correctly while appearing broken. An overnight window is now split into the pieces a week grid can draw: the part before midnight on its own day, the part after it on the next, squared off where they meet so they read as one window rather than two schedules. The tooltip names the whole span, since neither half shows it alone. A Saturday night spill is simply not drawn rather than wrapped round to Sunday, where it would appear to have played six days early. Dragging one is refused. A drag describes a window inside a single day, so applying it to a wrap would clamp it into that day and silently destroy the schedule — the same reason a recurring schedule's day cannot be dragged. Verified in a browser against a real 22:00 to 04:00 schedule: 88px on Tuesday night, 176px on Wednesday morning, alongside an ordinary daytime block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
153 lines
7.6 KiB
JavaScript
153 lines
7.6 KiB
JavaScript
// 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.
|
||
|
||
// 28px/hour made a 15-minute block SEVEN pixels tall — legible, but not something you can
|
||
// reliably grab, and its resize grip would have covered the whole block. 44 keeps a full day on
|
||
// screen on a laptop while making the smallest schedule an 11px target.
|
||
export const HOUR_PX = 44;
|
||
|
||
// A pointer must travel this far before a press counts as a drag. Without it, the 1px of movement
|
||
// in an ordinary click turns every click into a drag and swallows click-to-edit — so the calendar
|
||
// would feel broken in the most common interaction of all.
|
||
export const DRAG_THRESHOLD_PX = 4;
|
||
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)}`;
|
||
}
|
||
|
||
// How long a touch must be held before it becomes a drag. A touchscreen cannot use the mouse
|
||
// rule: the browser decides at touch-START whether the gesture is a page scroll, so a drag that
|
||
// only declares itself after the finger moves has already lost — the page scrolls and the pointer
|
||
// stream is cancelled. Holding still first is the signal that this is a drag and not a scroll.
|
||
export const LONG_PRESS_MS = 350;
|
||
|
||
// Touch arms by holding; a mouse or pen arms as soon as it has travelled. Returning the mode
|
||
// rather than branching on pointerType at each site keeps the two paths from drifting apart.
|
||
export function dragArmMode(pointerType) {
|
||
return pointerType === 'touch' ? 'longpress' : 'immediate';
|
||
}
|
||
|
||
// A default slot for "I tapped a time" rather than dragging one out — the whole gesture on a
|
||
// phone, where dragging out a range is awkward.
|
||
export const DEFAULT_NEW_MIN = 60;
|
||
|
||
// Has the pointer moved far enough to mean "drag" rather than "click"?
|
||
export function isDrag(dx, dy, threshold = DRAG_THRESHOLD_PX) {
|
||
return Math.hypot(dx, dy) >= threshold;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// A window whose end is BEFORE its start crosses midnight — 22:00 to 04:00 is a real and common
|
||
// signage schedule (a bar, a hotel lobby, anything running overnight). The playback engine has
|
||
// always understood this: schedule-eval treats start > end as a wrap. The calendar did not, and
|
||
// computed a negative height, so an overnight schedule appeared as an 18px sliver at 10pm with
|
||
// nothing at all after midnight.
|
||
export function crossesMidnight(startMin, endMin) {
|
||
return endMin <= startMin;
|
||
}
|
||
|
||
// Split an overnight window into the pieces a week grid can actually draw: the part before
|
||
// midnight on its own day, and the part after midnight on the NEXT one. A same-day window is
|
||
// returned unchanged as a single piece, so callers have one shape to render.
|
||
export function splitAcrossMidnight(dayIdx, startMin, endMin) {
|
||
if (!crossesMidnight(startMin, endMin)) {
|
||
return [{ dayIdx, startMin, endMin, continues: false, continued: false }];
|
||
}
|
||
const out = [{ dayIdx, startMin, endMin: DAY_MIN, continues: true, continued: false }];
|
||
// Sunday-night spill lands on Monday of the SAME grid, which is what a week view shows; a
|
||
// Saturday-night spill would run off the end, so it is simply not drawn rather than wrapping
|
||
// round to Sunday and appearing to be a week early.
|
||
if (dayIdx < 6 && endMin > 0) {
|
||
out.push({ dayIdx: dayIdx + 1, startMin: 0, endMin, continues: false, continued: true });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// A drag can only express a window inside one day. Moving or resizing an overnight schedule with
|
||
// the mouse would therefore clamp it into that day and silently destroy the wrap, so it is
|
||
// refused and left to the dialog — the same reasoning as a recurring schedule's day.
|
||
export function canDragEvent(ev, startMin, endMin) {
|
||
return !crossesMidnight(startMin, endMin);
|
||
}
|