// 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 }; }