A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(level-core): O(1) working-day calendar arithmetic

Working-day arithmetic over the bitmap calendar. countWorkingDays is an
O(1) prefix-sum query (cumWorking[hi] - cumWorking[lo]) — the dominant
operation in the scheduler. day-to-date conversion is DST-safe and
round-trips exactly. resolveWorkingCalendar centralizes the per-task →
project-default → first-available → throw fallback chain the schedulers
share, and a null calendar yields a synthetic all-working calendar so a
project with no calendars still resolves. Forward walks clamp at the
horizon instead of running off the end of the bitmap.

Russ T. Fugal (Apr 25, 2026, 11:00 AM -0600) ad77856a 6d66a4f2

+338
+173
src/level-core/calendarDays.ts
··· 1 + // Working-day arithmetic for `@levelset/level-core`. 2 + // Hot-loop helpers — Date in / number out at the boundaries; no Dates 3 + // flowing through the leveling pipeline. Working days are stored as 4 + // Uint8Array bits with an Int32Array prefix sum. 5 + 6 + import type { Calendar } from "../model/Calendar.ts"; 7 + 8 + import type { DayIndex, ResolvedProject, WorkingCalendar } from "./types.ts"; 9 + 10 + const MS_PER_DAY = 86_400_000; 11 + 12 + export function startOfLocalDay(d: Date): Date { 13 + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); 14 + } 15 + 16 + // MSPDI typically stores finish as end-of-workday (e.g., Fri 17:00). 17 + // Round those forward to the next midnight so finishDay (exclusive) lines 18 + // up with the day after the last working day. A finish already at midnight 19 + // is treated as already-exclusive and left alone — including milestones 20 + // where start === finish === midnight. 21 + // 22 + // Documented semantic loss on import-export — the original 17:00 23 + // time-of-day does not survive a round trip. 24 + export function endOfLocalDayExclusive(d: Date): Date { 25 + if ( 26 + d.getHours() === 0 && 27 + d.getMinutes() === 0 && 28 + d.getSeconds() === 0 && 29 + d.getMilliseconds() === 0 30 + ) { 31 + return d; 32 + } 33 + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); 34 + } 35 + 36 + export function addCalendarDays(origin: Date, n: number): Date { 37 + const o = startOfLocalDay(origin); 38 + return new Date(o.getFullYear(), o.getMonth(), o.getDate() + n); 39 + } 40 + 41 + // MS Project dayType: 1=Sun, 2=Mon, …, 7=Sat. JS getDay(): 0=Sun…6=Sat. 42 + function dayTypeOf(d: Date): number { 43 + return d.getDay() + 1; 44 + } 45 + 46 + function exceptionAt(calendar: Calendar, d: Date): { working: boolean } | null { 47 + const t = startOfLocalDay(d).getTime(); 48 + let match: { working: boolean } | null = null; 49 + for (const ex of calendar.exceptions) { 50 + if (!ex.fromDate || !ex.toDate) continue; 51 + const from = startOfLocalDay(ex.fromDate).getTime(); 52 + const to = startOfLocalDay(ex.toDate).getTime(); 53 + if (t >= from && t <= to) { 54 + // `working: null` is MS Project's "non-working day" sentinel. 55 + match = { working: ex.working ?? false }; 56 + } 57 + } 58 + return match; 59 + } 60 + 61 + function isWorkingDayDate(calendar: Calendar, d: Date): boolean { 62 + const ex = exceptionAt(calendar, d); 63 + if (ex) return ex.working; 64 + const w = calendar.weekDays.find((wd) => wd.dayType === dayTypeOf(d)); 65 + return w?.working ?? false; 66 + } 67 + 68 + /** Build a WorkingCalendar with Uint8Array bits and an Int32Array prefix sum. 69 + * `cal === null` builds a synthetic calendar treating every day as working — 70 + * useful as a fallback when a project has no calendars. */ 71 + export function buildWorkingCalendar( 72 + cal: Calendar | null, 73 + calendarUniqueId: number | null, 74 + epoch: Date, 75 + horizonDays: number, 76 + ): WorkingCalendar { 77 + if (horizonDays < 0) { 78 + throw new Error(`buildWorkingCalendar: horizonDays must be >= 0, got ${String(horizonDays)}`); 79 + } 80 + const epochAtMidnight = startOfLocalDay(epoch); 81 + const bits = new Uint8Array(horizonDays); 82 + for (let i = 0; i < horizonDays; i++) { 83 + const d = addCalendarDays(epochAtMidnight, i); 84 + bits[i] = (cal === null ? true : isWorkingDayDate(cal, d)) ? 1 : 0; 85 + } 86 + // cumWorking[i] = bits[0] + ... + bits[i-1]; cumWorking[horizonDays] is total. 87 + const cumWorking = new Int32Array(horizonDays + 1); 88 + for (let i = 0; i < horizonDays; i++) { 89 + cumWorking[i + 1] = cumWorking[i]! + bits[i]!; 90 + } 91 + return { 92 + calendarUniqueId, 93 + epoch: epochAtMidnight, 94 + horizonDays, 95 + bits, 96 + cumWorking, 97 + }; 98 + } 99 + 100 + export function isWorkingDay(cal: WorkingCalendar, day: DayIndex): boolean { 101 + return day >= 0 && day < cal.horizonDays && cal.bits[day] === 1; 102 + } 103 + 104 + /** O(1) count of working days in `[fromDay, toDay)` clamped to the horizon. */ 105 + export function countWorkingDays(cal: WorkingCalendar, fromDay: DayIndex, toDay: DayIndex): number { 106 + const lo = Math.max(0, fromDay); 107 + const hi = Math.min(cal.horizonDays, toDay); 108 + if (hi <= lo) return 0; 109 + return cal.cumWorking[hi]! - cal.cumWorking[lo]!; 110 + } 111 + 112 + /** Forward search from `startDay` for the calendar-day position immediately 113 + * after `workingDays` working days have elapsed. Throws if the horizon is 114 + * too short. */ 115 + export function advanceWorkingDays( 116 + cal: WorkingCalendar, 117 + startDay: DayIndex, 118 + workingDays: number, 119 + ): DayIndex { 120 + if (workingDays <= 0) return startDay; 121 + let remaining = workingDays; 122 + let day = startDay; 123 + while (remaining > 0) { 124 + if (day >= cal.horizonDays) { 125 + throw new Error( 126 + `calendar horizon too short: needed ${String(workingDays)} working days from day ${String(startDay)}, ran out at ${String(day)}`, 127 + ); 128 + } 129 + if (cal.bits[day] === 1) remaining--; 130 + day++; 131 + } 132 + return day; 133 + } 134 + 135 + /** First working day at or after `day` within the horizon. Returns 136 + * `cal.horizonDays` if none. */ 137 + export function nextWorkingDay(cal: WorkingCalendar, day: DayIndex): DayIndex { 138 + let d = Math.max(0, day); 139 + if (d >= cal.horizonDays) return cal.horizonDays; 140 + while (d < cal.horizonDays && cal.bits[d] !== 1) d++; 141 + return d; 142 + } 143 + 144 + export function dayToDate(cal: WorkingCalendar, day: DayIndex): Date { 145 + return addCalendarDays(cal.epoch, day); 146 + } 147 + 148 + /** Pick the WorkingCalendar for a given calendar override id, falling back 149 + * to the project default and then to any available calendar. Throws if the 150 + * ResolvedProject has no calendars at all — a precondition violation. */ 151 + export function resolveWorkingCalendar( 152 + resolved: ResolvedProject, 153 + calendarUniqueId: number | null, 154 + ): WorkingCalendar { 155 + const id = calendarUniqueId ?? resolved.defaultCalendarUniqueId; 156 + if (id !== null) { 157 + const cal = resolved.calendars.get(id); 158 + if (cal) return cal; 159 + } 160 + const fallback = resolved.calendars.values().next().value; 161 + if (!fallback) { 162 + throw new Error("resolveWorkingCalendar: ResolvedProject has no calendars"); 163 + } 164 + return fallback; 165 + } 166 + 167 + /** Returns a non-negative day index relative to `cal.epoch`. Caller must 168 + * bound-check against `cal.horizonDays` if needed. */ 169 + export function dateToDay(cal: WorkingCalendar, date: Date): DayIndex { 170 + const dStart = startOfLocalDay(date).getTime(); 171 + const eStart = cal.epoch.getTime(); 172 + return Math.round((dStart - eStart) / MS_PER_DAY); 173 + }
+165
test/level-core/calendarDays.test.ts
··· 1 + import { test, expect, describe } from "bun:test"; 2 + 3 + import { 4 + addCalendarDays, 5 + advanceWorkingDays, 6 + buildWorkingCalendar, 7 + countWorkingDays, 8 + dateToDay, 9 + dayToDate, 10 + endOfLocalDayExclusive, 11 + isWorkingDay, 12 + nextWorkingDay, 13 + startOfLocalDay, 14 + } from "../../src/level-core/calendarDays.ts"; 15 + import type { Calendar } from "../../src/model/Calendar.ts"; 16 + 17 + const monFri: Calendar = { 18 + uniqueId: 1, 19 + name: "Mon-Fri", 20 + weekDays: [ 21 + { dayType: 1, working: false, workingTimes: [] }, 22 + { dayType: 2, working: true, workingTimes: [] }, 23 + { dayType: 3, working: true, workingTimes: [] }, 24 + { dayType: 4, working: true, workingTimes: [] }, 25 + { dayType: 5, working: true, workingTimes: [] }, 26 + { dayType: 6, working: true, workingTimes: [] }, 27 + { dayType: 7, working: false, workingTimes: [] }, 28 + ], 29 + exceptions: [], 30 + }; 31 + 32 + // Wed, Jan 1, 2025 33 + const epoch = new Date(2025, 0, 1); 34 + 35 + describe("buildWorkingCalendar", () => { 36 + test("Mon-Fri 14-day window has 10 working days", () => { 37 + const cal = buildWorkingCalendar(monFri, 1, epoch, 14); 38 + expect(cal.cumWorking[14]).toBe(10); 39 + }); 40 + 41 + test("synthetic calendar (cal=null) treats every day as working", () => { 42 + const cal = buildWorkingCalendar(null, null, epoch, 7); 43 + expect(cal.cumWorking[7]).toBe(7); 44 + expect([...cal.bits]).toEqual([1, 1, 1, 1, 1, 1, 1]); 45 + }); 46 + 47 + test("rejects negative horizon", () => { 48 + expect(() => buildWorkingCalendar(monFri, 1, epoch, -1)).toThrow(); 49 + }); 50 + 51 + test("zero-horizon calendar is valid", () => { 52 + const cal = buildWorkingCalendar(monFri, 1, epoch, 0); 53 + expect(cal.bits.length).toBe(0); 54 + expect(cal.cumWorking.length).toBe(1); 55 + expect(cal.cumWorking[0]).toBe(0); 56 + }); 57 + }); 58 + 59 + describe("countWorkingDays — O(1) prefix-sum query", () => { 60 + const cal = buildWorkingCalendar(monFri, 1, epoch, 21); 61 + 62 + test("first work-week is 5 working days", () => { 63 + expect(countWorkingDays(cal, 0, 7)).toBe(5); 64 + }); 65 + 66 + test("range across a weekend gap", () => { 67 + // Wed→Mon is 4 days: Wed, Thu, Fri, Mon (Sat, Sun nonworking). 68 + expect(countWorkingDays(cal, 0, 6)).toBe(4); 69 + }); 70 + 71 + test("clamps to horizon", () => { 72 + expect(countWorkingDays(cal, -5, 200)).toBe(15); 73 + }); 74 + 75 + test("empty range is 0", () => { 76 + expect(countWorkingDays(cal, 5, 5)).toBe(0); 77 + expect(countWorkingDays(cal, 7, 3)).toBe(0); 78 + }); 79 + }); 80 + 81 + describe("isWorkingDay / nextWorkingDay", () => { 82 + const cal = buildWorkingCalendar(monFri, 1, epoch, 14); 83 + 84 + test("isWorkingDay agrees with bits", () => { 85 + // Wed is working, Sat/Sun (days 3, 4) are not. 86 + expect(isWorkingDay(cal, 0)).toBe(true); 87 + expect(isWorkingDay(cal, 3)).toBe(false); 88 + expect(isWorkingDay(cal, 4)).toBe(false); 89 + expect(isWorkingDay(cal, 5)).toBe(true); 90 + }); 91 + 92 + test("nextWorkingDay skips weekends", () => { 93 + expect(nextWorkingDay(cal, 3)).toBe(5); 94 + }); 95 + 96 + test("nextWorkingDay returns horizon if none ahead", () => { 97 + expect(nextWorkingDay(cal, 999)).toBe(14); 98 + }); 99 + }); 100 + 101 + describe("advanceWorkingDays", () => { 102 + const cal = buildWorkingCalendar(monFri, 1, epoch, 30); 103 + 104 + test("advance 5 working days from Wed Jan 1 lands on Wed Jan 8", () => { 105 + expect(advanceWorkingDays(cal, 0, 5)).toBe(7); 106 + }); 107 + 108 + test("advance 0 working days is identity", () => { 109 + expect(advanceWorkingDays(cal, 0, 0)).toBe(0); 110 + expect(advanceWorkingDays(cal, 7, 0)).toBe(7); 111 + }); 112 + 113 + test("throws when horizon too short", () => { 114 + const small = buildWorkingCalendar(monFri, 1, epoch, 3); 115 + expect(() => advanceWorkingDays(small, 0, 5)).toThrow(); 116 + }); 117 + }); 118 + 119 + describe("dayToDate / dateToDay round trip", () => { 120 + const cal = buildWorkingCalendar(monFri, 1, epoch, 60); 121 + 122 + test("dateToDay(dayToDate(d)) === d for every d in horizon", () => { 123 + for (let d = 0; d < cal.horizonDays; d++) { 124 + expect(dateToDay(cal, dayToDate(cal, d))).toBe(d); 125 + } 126 + }); 127 + 128 + test("identity holds across DST boundaries (US spring-forward)", () => { 129 + const dstEpoch = new Date(2025, 2, 1); // Mar 1 130 + const dstCal = buildWorkingCalendar(monFri, 1, dstEpoch, 30); 131 + for (let d = 0; d < dstCal.horizonDays; d++) { 132 + expect(dateToDay(dstCal, dayToDate(dstCal, d))).toBe(d); 133 + } 134 + }); 135 + }); 136 + 137 + describe("endOfLocalDayExclusive — MSPDI 17:00 → next-midnight normalization", () => { 138 + test("Fri 17:00 rounds to Sat 00:00", () => { 139 + const fri17 = new Date(2025, 0, 3, 17); 140 + const result = endOfLocalDayExclusive(fri17); 141 + expect(result.getDate()).toBe(4); 142 + expect(result.getHours()).toBe(0); 143 + }); 144 + 145 + test("midnight is left alone (milestones)", () => { 146 + const midnight = new Date(2025, 0, 3); 147 + expect(endOfLocalDayExclusive(midnight).getTime()).toBe(midnight.getTime()); 148 + }); 149 + }); 150 + 151 + describe("startOfLocalDay / addCalendarDays", () => { 152 + test("startOfLocalDay strips time component", () => { 153 + const t = new Date(2025, 5, 15, 14, 30, 25); 154 + const s = startOfLocalDay(t); 155 + expect(s.getHours()).toBe(0); 156 + expect(s.getDate()).toBe(15); 157 + }); 158 + 159 + test("addCalendarDays handles month rollover", () => { 160 + const jan31 = new Date(2025, 0, 31); 161 + const feb1 = addCalendarDays(jan31, 1); 162 + expect(feb1.getMonth()).toBe(1); 163 + expect(feb1.getDate()).toBe(1); 164 + }); 165 + });