Files
CapaKraken/apps/web/src/components/timeline/utils.ts
T

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