diff --git a/src/level-core/calendarDays.ts b/src/level-core/calendarDays.ts new file mode 100644 index 0000000..73afe9f --- /dev/null +++ b/src/level-core/calendarDays.ts @@ -0,0 +1,173 @@ +// Working-day arithmetic for `@levelset/level-core`. +// Hot-loop helpers — Date in / number out at the boundaries; no Dates +// flowing through the leveling pipeline. Working days are stored as +// Uint8Array bits with an Int32Array prefix sum. + +import type { Calendar } from "../model/Calendar.ts"; + +import type { DayIndex, ResolvedProject, WorkingCalendar } from "./types.ts"; + +const MS_PER_DAY = 86_400_000; + +export function startOfLocalDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +// MSPDI typically stores finish as end-of-workday (e.g., Fri 17:00). +// Round those forward to the next midnight so finishDay (exclusive) lines +// up with the day after the last working day. A finish already at midnight +// is treated as already-exclusive and left alone — including milestones +// where start === finish === midnight. +// +// Documented semantic loss on import-export — the original 17:00 +// time-of-day does not survive a round trip. +export function endOfLocalDayExclusive(d: Date): Date { + if ( + d.getHours() === 0 && + d.getMinutes() === 0 && + d.getSeconds() === 0 && + d.getMilliseconds() === 0 + ) { + return d; + } + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); +} + +export function addCalendarDays(origin: Date, n: number): Date { + const o = startOfLocalDay(origin); + return new Date(o.getFullYear(), o.getMonth(), o.getDate() + n); +} + +// MS Project dayType: 1=Sun, 2=Mon, …, 7=Sat. JS getDay(): 0=Sun…6=Sat. +function dayTypeOf(d: Date): number { + return d.getDay() + 1; +} + +function exceptionAt(calendar: Calendar, d: Date): { working: boolean } | null { + const t = startOfLocalDay(d).getTime(); + let match: { working: boolean } | null = null; + for (const ex of calendar.exceptions) { + if (!ex.fromDate || !ex.toDate) continue; + const from = startOfLocalDay(ex.fromDate).getTime(); + const to = startOfLocalDay(ex.toDate).getTime(); + if (t >= from && t <= to) { + // `working: null` is MS Project's "non-working day" sentinel. + match = { working: ex.working ?? false }; + } + } + return match; +} + +function isWorkingDayDate(calendar: Calendar, d: Date): boolean { + const ex = exceptionAt(calendar, d); + if (ex) return ex.working; + const w = calendar.weekDays.find((wd) => wd.dayType === dayTypeOf(d)); + return w?.working ?? false; +} + +/** Build a WorkingCalendar with Uint8Array bits and an Int32Array prefix sum. + * `cal === null` builds a synthetic calendar treating every day as working — + * useful as a fallback when a project has no calendars. */ +export function buildWorkingCalendar( + cal: Calendar | null, + calendarUniqueId: number | null, + epoch: Date, + horizonDays: number, +): WorkingCalendar { + if (horizonDays < 0) { + throw new Error(`buildWorkingCalendar: horizonDays must be >= 0, got ${String(horizonDays)}`); + } + const epochAtMidnight = startOfLocalDay(epoch); + const bits = new Uint8Array(horizonDays); + for (let i = 0; i < horizonDays; i++) { + const d = addCalendarDays(epochAtMidnight, i); + bits[i] = (cal === null ? true : isWorkingDayDate(cal, d)) ? 1 : 0; + } + // cumWorking[i] = bits[0] + ... + bits[i-1]; cumWorking[horizonDays] is total. + const cumWorking = new Int32Array(horizonDays + 1); + for (let i = 0; i < horizonDays; i++) { + cumWorking[i + 1] = cumWorking[i]! + bits[i]!; + } + return { + calendarUniqueId, + epoch: epochAtMidnight, + horizonDays, + bits, + cumWorking, + }; +} + +export function isWorkingDay(cal: WorkingCalendar, day: DayIndex): boolean { + return day >= 0 && day < cal.horizonDays && cal.bits[day] === 1; +} + +/** O(1) count of working days in `[fromDay, toDay)` clamped to the horizon. */ +export function countWorkingDays(cal: WorkingCalendar, fromDay: DayIndex, toDay: DayIndex): number { + const lo = Math.max(0, fromDay); + const hi = Math.min(cal.horizonDays, toDay); + if (hi <= lo) return 0; + return cal.cumWorking[hi]! - cal.cumWorking[lo]!; +} + +/** Forward search from `startDay` for the calendar-day position immediately + * after `workingDays` working days have elapsed. Throws if the horizon is + * too short. */ +export function advanceWorkingDays( + cal: WorkingCalendar, + startDay: DayIndex, + workingDays: number, +): DayIndex { + if (workingDays <= 0) return startDay; + let remaining = workingDays; + let day = startDay; + while (remaining > 0) { + if (day >= cal.horizonDays) { + throw new Error( + `calendar horizon too short: needed ${String(workingDays)} working days from day ${String(startDay)}, ran out at ${String(day)}`, + ); + } + if (cal.bits[day] === 1) remaining--; + day++; + } + return day; +} + +/** First working day at or after `day` within the horizon. Returns + * `cal.horizonDays` if none. */ +export function nextWorkingDay(cal: WorkingCalendar, day: DayIndex): DayIndex { + let d = Math.max(0, day); + if (d >= cal.horizonDays) return cal.horizonDays; + while (d < cal.horizonDays && cal.bits[d] !== 1) d++; + return d; +} + +export function dayToDate(cal: WorkingCalendar, day: DayIndex): Date { + return addCalendarDays(cal.epoch, day); +} + +/** Pick the WorkingCalendar for a given calendar override id, falling back + * to the project default and then to any available calendar. Throws if the + * ResolvedProject has no calendars at all — a precondition violation. */ +export function resolveWorkingCalendar( + resolved: ResolvedProject, + calendarUniqueId: number | null, +): WorkingCalendar { + const id = calendarUniqueId ?? resolved.defaultCalendarUniqueId; + if (id !== null) { + const cal = resolved.calendars.get(id); + if (cal) return cal; + } + const fallback = resolved.calendars.values().next().value; + if (!fallback) { + throw new Error("resolveWorkingCalendar: ResolvedProject has no calendars"); + } + return fallback; +} + +/** Returns a non-negative day index relative to `cal.epoch`. Caller must + * bound-check against `cal.horizonDays` if needed. */ +export function dateToDay(cal: WorkingCalendar, date: Date): DayIndex { + const dStart = startOfLocalDay(date).getTime(); + const eStart = cal.epoch.getTime(); + return Math.round((dStart - eStart) / MS_PER_DAY); +} diff --git a/test/level-core/calendarDays.test.ts b/test/level-core/calendarDays.test.ts new file mode 100644 index 0000000..e47614f --- /dev/null +++ b/test/level-core/calendarDays.test.ts @@ -0,0 +1,165 @@ +import { test, expect, describe } from "bun:test"; + +import { + addCalendarDays, + advanceWorkingDays, + buildWorkingCalendar, + countWorkingDays, + dateToDay, + dayToDate, + endOfLocalDayExclusive, + isWorkingDay, + nextWorkingDay, + startOfLocalDay, +} from "../../src/level-core/calendarDays.ts"; +import type { Calendar } from "../../src/model/Calendar.ts"; + +const monFri: Calendar = { + uniqueId: 1, + name: "Mon-Fri", + weekDays: [ + { dayType: 1, working: false, workingTimes: [] }, + { dayType: 2, working: true, workingTimes: [] }, + { dayType: 3, working: true, workingTimes: [] }, + { dayType: 4, working: true, workingTimes: [] }, + { dayType: 5, working: true, workingTimes: [] }, + { dayType: 6, working: true, workingTimes: [] }, + { dayType: 7, working: false, workingTimes: [] }, + ], + exceptions: [], +}; + +// Wed, Jan 1, 2025 +const epoch = new Date(2025, 0, 1); + +describe("buildWorkingCalendar", () => { + test("Mon-Fri 14-day window has 10 working days", () => { + const cal = buildWorkingCalendar(monFri, 1, epoch, 14); + expect(cal.cumWorking[14]).toBe(10); + }); + + test("synthetic calendar (cal=null) treats every day as working", () => { + const cal = buildWorkingCalendar(null, null, epoch, 7); + expect(cal.cumWorking[7]).toBe(7); + expect([...cal.bits]).toEqual([1, 1, 1, 1, 1, 1, 1]); + }); + + test("rejects negative horizon", () => { + expect(() => buildWorkingCalendar(monFri, 1, epoch, -1)).toThrow(); + }); + + test("zero-horizon calendar is valid", () => { + const cal = buildWorkingCalendar(monFri, 1, epoch, 0); + expect(cal.bits.length).toBe(0); + expect(cal.cumWorking.length).toBe(1); + expect(cal.cumWorking[0]).toBe(0); + }); +}); + +describe("countWorkingDays — O(1) prefix-sum query", () => { + const cal = buildWorkingCalendar(monFri, 1, epoch, 21); + + test("first work-week is 5 working days", () => { + expect(countWorkingDays(cal, 0, 7)).toBe(5); + }); + + test("range across a weekend gap", () => { + // Wed→Mon is 4 days: Wed, Thu, Fri, Mon (Sat, Sun nonworking). + expect(countWorkingDays(cal, 0, 6)).toBe(4); + }); + + test("clamps to horizon", () => { + expect(countWorkingDays(cal, -5, 200)).toBe(15); + }); + + test("empty range is 0", () => { + expect(countWorkingDays(cal, 5, 5)).toBe(0); + expect(countWorkingDays(cal, 7, 3)).toBe(0); + }); +}); + +describe("isWorkingDay / nextWorkingDay", () => { + const cal = buildWorkingCalendar(monFri, 1, epoch, 14); + + test("isWorkingDay agrees with bits", () => { + // Wed is working, Sat/Sun (days 3, 4) are not. + expect(isWorkingDay(cal, 0)).toBe(true); + expect(isWorkingDay(cal, 3)).toBe(false); + expect(isWorkingDay(cal, 4)).toBe(false); + expect(isWorkingDay(cal, 5)).toBe(true); + }); + + test("nextWorkingDay skips weekends", () => { + expect(nextWorkingDay(cal, 3)).toBe(5); + }); + + test("nextWorkingDay returns horizon if none ahead", () => { + expect(nextWorkingDay(cal, 999)).toBe(14); + }); +}); + +describe("advanceWorkingDays", () => { + const cal = buildWorkingCalendar(monFri, 1, epoch, 30); + + test("advance 5 working days from Wed Jan 1 lands on Wed Jan 8", () => { + expect(advanceWorkingDays(cal, 0, 5)).toBe(7); + }); + + test("advance 0 working days is identity", () => { + expect(advanceWorkingDays(cal, 0, 0)).toBe(0); + expect(advanceWorkingDays(cal, 7, 0)).toBe(7); + }); + + test("throws when horizon too short", () => { + const small = buildWorkingCalendar(monFri, 1, epoch, 3); + expect(() => advanceWorkingDays(small, 0, 5)).toThrow(); + }); +}); + +describe("dayToDate / dateToDay round trip", () => { + const cal = buildWorkingCalendar(monFri, 1, epoch, 60); + + test("dateToDay(dayToDate(d)) === d for every d in horizon", () => { + for (let d = 0; d < cal.horizonDays; d++) { + expect(dateToDay(cal, dayToDate(cal, d))).toBe(d); + } + }); + + test("identity holds across DST boundaries (US spring-forward)", () => { + const dstEpoch = new Date(2025, 2, 1); // Mar 1 + const dstCal = buildWorkingCalendar(monFri, 1, dstEpoch, 30); + for (let d = 0; d < dstCal.horizonDays; d++) { + expect(dateToDay(dstCal, dayToDate(dstCal, d))).toBe(d); + } + }); +}); + +describe("endOfLocalDayExclusive — MSPDI 17:00 → next-midnight normalization", () => { + test("Fri 17:00 rounds to Sat 00:00", () => { + const fri17 = new Date(2025, 0, 3, 17); + const result = endOfLocalDayExclusive(fri17); + expect(result.getDate()).toBe(4); + expect(result.getHours()).toBe(0); + }); + + test("midnight is left alone (milestones)", () => { + const midnight = new Date(2025, 0, 3); + expect(endOfLocalDayExclusive(midnight).getTime()).toBe(midnight.getTime()); + }); +}); + +describe("startOfLocalDay / addCalendarDays", () => { + test("startOfLocalDay strips time component", () => { + const t = new Date(2025, 5, 15, 14, 30, 25); + const s = startOfLocalDay(t); + expect(s.getHours()).toBe(0); + expect(s.getDate()).toBe(15); + }); + + test("addCalendarDays handles month rollover", () => { + const jan31 = new Date(2025, 0, 31); + const feb1 = addCalendarDays(jan31, 1); + expect(feb1.getMonth()).toBe(1); + expect(feb1.getDate()).toBe(1); + }); +});