// ─── Date helpers ───────────────────────────────────────────────────────────── export function addDays(date: Date, days: number): Date { const d = new Date(date); d.setDate(d.getDate() + days); return d; } export function daysBetween(a: Date, b: Date): number { return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)); } /** * Converts a date to a left-pixel offset within the visible timeline. * Accounts for weekend-skipping when showWeekends is false. */ export function dateToLeft( date: Date, viewStart: Date, viewEnd: Date, cellWidth: number, showWeekends: boolean, ): number { const clamped = date < viewStart ? viewStart : date > viewEnd ? viewEnd : date; if (showWeekends) { return daysBetween(viewStart, clamped) * cellWidth; } let count = 0; const cur = new Date(viewStart); cur.setHours(0, 0, 0, 0); const target = new Date(clamped); target.setHours(0, 0, 0, 0); while (cur < target) { const dow = cur.getDay(); if (dow !== 0 && dow !== 6) count++; cur.setDate(cur.getDate() + 1); } return count * cellWidth; } /** * Computes the pixel width of a date range within the visible timeline. * Accounts for weekend-skipping when showWeekends is false. */ export function dateRangeToWidth( start: Date, end: Date, viewStart: Date, viewEnd: Date, cellWidth: number, showWeekends: boolean, ): number { let count = 0; const cur = new Date(start < viewStart ? viewStart : start); cur.setHours(0, 0, 0, 0); const endC = new Date(end > viewEnd ? viewEnd : end); endC.setHours(0, 0, 0, 0); while (cur <= endC) { const dow = cur.getDay(); if (showWeekends || (dow !== 0 && dow !== 6)) count++; cur.setDate(cur.getDate() + 1); } return count * cellWidth; } // ─── O(1) position cache ────────────────────────────────────────────────────── /** * Pre-computes a pixel-offset lookup table for the entire visible date range. * Returns `toLeft` / `toWidth` with O(1) lookups instead of O(n) loops. * Use inside a `useMemo` that depends on viewStart / viewDays / cellWidth / showWeekends. */ export function createDatePositionCache( viewStart: Date, viewDays: number, cellWidth: number, showWeekends: boolean, ): { toLeft: (date: Date) => number; toWidth: (start: Date, end: Date) => number } { // offsetMap: day-start-timestamp → pixel left rankMap: same → 0-based visible-day index const offsetMap = new Map(); const rankMap = new Map(); let rank = 0; const cur = new Date(viewStart); cur.setHours(0, 0, 0, 0); const viewStartT = cur.getTime(); for (let i = 0; i < viewDays; i++) { const dow = cur.getDay(); if (showWeekends || (dow !== 0 && dow !== 6)) { const t = cur.getTime(); offsetMap.set(t, rank * cellWidth); rankMap.set(t, rank); rank++; } cur.setDate(cur.getDate() + 1); } const totalWidth = rank * cellWidth; const viewEndT = cur.getTime(); // timestamp of the day AFTER the last visible day function toLeft(date: Date): number { const d = new Date(date); d.setHours(0, 0, 0, 0); const t = d.getTime(); if (t <= viewStartT) return 0; if (t >= viewEndT) return totalWidth; const cached = offsetMap.get(t); if (cached !== undefined) return cached; // Weekend when showWeekends=false: return the *next* visible day's offset. // This matches the original dateToLeft which counts strictly-before business days, // so Saturday gets the same offset as the following Monday. const next = new Date(d); for (let i = 1; i <= 6; i++) { next.setDate(next.getDate() + 1); if (next.getTime() >= viewEndT) return totalWidth; const v = offsetMap.get(next.getTime()); if (v !== undefined) return v; } return totalWidth; } function toWidth(start: Date, end: Date): number { const sNorm = new Date(start < viewStart ? viewStart : start); sNorm.setHours(0, 0, 0, 0); const eNorm = new Date(end); eNorm.setHours(0, 0, 0, 0); // Rank of the first visible day at-or-after sNorm let sRank: number; const sT = sNorm.getTime(); const rS = rankMap.get(sT); if (rS !== undefined) { sRank = rS; } else { sRank = rank; // default: past end → 0 width const next = new Date(sNorm); for (let i = 1; i <= 6; i++) { next.setDate(next.getDate() + 1); if (next.getTime() >= viewEndT) break; const r = rankMap.get(next.getTime()); if (r !== undefined) { sRank = r; break; } } } // Rank of the last visible day at-or-before eNorm let eRank: number; const eT = eNorm.getTime(); if (eT >= viewEndT) { eRank = rank - 1; // clamp to last visible day } else { const rE = rankMap.get(eT); if (rE !== undefined) { eRank = rE; } else { eRank = sRank - 1; // default: no visible day → 0 width const prev = new Date(eNorm); for (let i = 1; i <= 6; i++) { prev.setDate(prev.getDate() - 1); if (prev.getTime() < viewStartT) break; const r = rankMap.get(prev.getTime()); if (r !== undefined) { eRank = r; break; } } } } if (eRank < sRank) return 0; return (eRank - sRank + 1) * cellWidth; } return { toLeft, toWidth }; } // ─── Sub-lane computation ────────────────────────────────────────────────────── export interface SubLaneAlloc { id: string; startDate: Date; endDate: Date; } /** * Greedy lane assignment for overlapping allocations. * Returns a map of allocationId → lane index (0-based). * Allocations are sorted by startDate then assigned to the first lane * that doesn't overlap with the previous occupant. */ export function computeSubLanes(allocs: SubLaneAlloc[]): Map { const sorted = [...allocs].sort( (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), ); // laneEnds[i] = end date of the last allocation placed in lane i const laneEnds: Date[] = []; const result = new Map(); for (const alloc of sorted) { const start = new Date(alloc.startDate); const end = new Date(alloc.endDate); let placed = false; for (let i = 0; i < laneEnds.length; i++) { const laneEnd = laneEnds[i]!; if (start > laneEnd) { // Lane is free laneEnds[i] = end; result.set(alloc.id, i); placed = true; break; } } if (!placed) { result.set(alloc.id, laneEnds.length); laneEnds.push(end); } } return result; }