221 lines
6.8 KiB
TypeScript
221 lines
6.8 KiB
TypeScript
// ─── 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<number, number>();
|
|
const rankMap = new Map<number, number>();
|
|
|
|
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<string, number> {
|
|
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<string, number>();
|
|
|
|
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;
|
|
}
|