Files
trip-plan/public/js/timelineLayout.js
T
grabowski b0bdd2570c Add hourly day timeline and red now indicators to the calendar
Days with 4+ entries open with an hour-grid timeline in the day editor:
untimed entries in an all-day strip, timed ones positioned by start/end
with side-by-side columns for overlaps, click-to-edit. Layout math lives
in a pure, unit-tested module (timelineLayout.js).

Today's month-grid cell gets a vertical red slider positioned by the
fraction of the day elapsed; the timeline gets the classic horizontal
red current-time line. Both tick every minute with cleanup on unmount,
and the initial positioning runs unconditionally while the node is
still detached (the isConnected self-heal gates only interval ticks).

Also splits entryRow rendering out of dayEditor.js to stay under the
500-line cap.
2026-08-30 12:49:17 +02:00

89 lines
3.3 KiB
JavaScript

// Pure, DOM-free layout helpers for the hourly day timeline (dayTimeline.js).
// No imports from DOM-touching modules — unit-tested directly under node:test.
// See docs/API.md "Hourly day timeline & 'now' indicator" for the binding spec.
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
// "HH:MM" -> minutes since midnight, or null if missing/malformed.
function parseHHMM(str) {
if (typeof str !== 'string') return null;
const m = TIME_RE.exec(str);
if (!m) return null;
return Number(m[1]) * 60 + Number(m[2]);
}
// entries with a valid start_time -> [{entry, startMin, endMin}] (minutes
// since midnight). Missing/invalid end_time -> endMin = startMin + 60.
// end_time <= start_time, or the entry spanning into end_date, clamps
// endMin to 24*60 (overnight / multi-day entries run to the end of the day).
// An invalid start_time (not HH:MM) counts as untimed and is skipped.
export function timedSlots(entries) {
const slots = [];
for (const entry of entries || []) {
const startMin = parseHHMM(entry.start_time);
if (startMin == null) continue;
const spansPastToday = !!(entry.end_date && entry.end_date > entry.date);
let endMin = parseHHMM(entry.end_time);
if (endMin == null) endMin = startMin + 60;
else if (endMin <= startMin || spansPastToday) endMin = 24 * 60;
slots.push({ entry, startMin, endMin });
}
return slots;
}
// slots -> same slots + {col, cols}: overlapping slots split their shared row
// into side-by-side columns. Greedy: sort by startMin then endMin; place each
// slot in the first column whose last slot ends <= its start. `cols` is the
// column count of the slot's overlap cluster (a maximal run of mutually-
// reachable overlapping slots), not a single global maximum.
export function layoutColumns(slots) {
const sorted = [...slots].sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin);
const out = sorted.map((s) => ({ ...s, col: 0, cols: 1 }));
let cluster = [];
let columnEnds = []; // columnEnds[i] = endMin of the last slot placed in column i
let clusterEnd = -Infinity;
const finalizeCluster = () => {
for (const item of cluster) item.cols = columnEnds.length;
cluster = [];
columnEnds = [];
};
for (const item of out) {
if (item.startMin >= clusterEnd) {
finalizeCluster();
clusterEnd = -Infinity;
}
let col = columnEnds.findIndex((end) => end <= item.startMin);
if (col === -1) {
col = columnEnds.length;
columnEnds.push(item.endMin);
} else {
columnEnds[col] = item.endMin;
}
item.col = col;
cluster.push(item);
clusterEnd = Math.max(clusterEnd, item.endMin);
}
finalizeCluster();
return out;
}
// slots -> {startHour, endHour}: one full hour before the earliest startMin to
// one full hour after the latest endMin, clamped to [0, 24].
// {startHour: 8, endHour: 21} when slots is empty.
export function gridWindow(slots) {
if (!slots || !slots.length) return { startHour: 8, endHour: 21 };
let minStart = Infinity;
let maxEnd = -Infinity;
for (const s of slots) {
if (s.startMin < minStart) minStart = s.startMin;
if (s.endMin > maxEnd) maxEnd = s.endMin;
}
const startHour = Math.max(0, Math.floor(minStart / 60) - 1);
const endHour = Math.min(24, Math.ceil(maxEnd / 60) + 1);
return { startHour, endHour };
}