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): resolveCalendar pipeline entry

resolveCalendar turns a ProjectFile into a ResolvedProject — the entry
point of the leveling pipeline. The epoch is opts.epoch ?? statusDate ??
earliest task start, and resolving with no available origin throws rather
than silently defaulting. capacityPerDay is derived as work-unit-hours per
day; calendars are built into a per-id map with a synthetic Mon–Fri
fallback; durations and lags are converted to working-day units from the
project's minutes-per-day and minutes-per-week.

WorkUnit definitions are resolved into the by-id registry here, and
duplicate unit ids are rejected at resolve time so a duplicate cannot
diverge the greedy and MiniZinc objectives downstream.

Russ T. Fugal (Apr 26, 2026, 9:00 AM -0600) ba17dc08 ad77856a

+790
+349
src/level-core/resolveCalendar.ts
··· 1 + // Project ──resolveCalendar──> ResolvedProject. 2 + // Pure: in goes the user-facing `ProjectFile` with Date objects, out comes 3 + // the day-indexed working value the rest of the pipeline operates on. 4 + 5 + import type { Calendar } from "../model/Calendar.ts"; 6 + import type { Duration } from "../model/Duration.ts"; 7 + import { TimeUnit } from "../model/types.ts"; 8 + import type { ProjectFile, ProjectProperties } from "../schema/project.ts"; 9 + import type { Task } from "../schema/task.ts"; 10 + 11 + import { 12 + addCalendarDays, 13 + buildWorkingCalendar, 14 + endOfLocalDayExclusive, 15 + startOfLocalDay, 16 + } from "./calendarDays.ts"; 17 + import type { 18 + PrecedenceEdge, 19 + ResolveOptions, 20 + ResolvedAssignment, 21 + ResolvedProject, 22 + ResolvedResource, 23 + ResolvedTask, 24 + WorkUnit, 25 + WorkingCalendar, 26 + } from "./types.ts"; 27 + 28 + const HORIZON_BUFFER_DAYS = 90; 29 + const MS_PER_DAY = 86_400_000; 30 + const TEN_YEARS_DAYS = 365 * 10; 31 + const SYNTHETIC_CAL_ID = -1; 32 + 33 + function syntheticMonFriCalendar(): Calendar { 34 + return { 35 + uniqueId: SYNTHETIC_CAL_ID, 36 + name: "Synthetic Mon–Fri", 37 + weekDays: [ 38 + { dayType: 1, working: false, workingTimes: [] }, // Sun 39 + { dayType: 2, working: true, workingTimes: [] }, 40 + { dayType: 3, working: true, workingTimes: [] }, 41 + { dayType: 4, working: true, workingTimes: [] }, 42 + { dayType: 5, working: true, workingTimes: [] }, 43 + { dayType: 6, working: true, workingTimes: [] }, 44 + { dayType: 7, working: false, workingTimes: [] }, // Sat 45 + ], 46 + exceptions: [], 47 + }; 48 + } 49 + 50 + /** opts.epoch ?? properties.statusDate ?? min(task.start). Throws if none. */ 51 + function pickEpoch(project: ProjectFile, opts: ResolveOptions): Date { 52 + if (opts.epoch) return startOfLocalDay(opts.epoch); 53 + if (project.properties.statusDate) return startOfLocalDay(project.properties.statusDate); 54 + let earliest: Date | null = project.properties.startDate 55 + ? startOfLocalDay(project.properties.startDate) 56 + : null; 57 + for (const t of project.tasks) { 58 + if (t.start) { 59 + const s = startOfLocalDay(t.start); 60 + if (!earliest || s.getTime() < earliest.getTime()) earliest = s; 61 + } 62 + } 63 + if (!earliest) { 64 + throw new Error( 65 + "resolveCalendar: cannot pick epoch — no opts.epoch, statusDate, startDate, or task starts available", 66 + ); 67 + } 68 + return earliest; 69 + } 70 + 71 + function pickHorizon(project: ProjectFile, epoch: Date, opts: ResolveOptions): number { 72 + if (opts.horizonDays !== undefined) { 73 + if (opts.horizonDays < 0) { 74 + throw new Error(`resolveCalendar: horizonDays must be >= 0, got ${String(opts.horizonDays)}`); 75 + } 76 + return opts.horizonDays; 77 + } 78 + let latest: Date | null = project.properties.finishDate 79 + ? endOfLocalDayExclusive(project.properties.finishDate) 80 + : null; 81 + for (const t of project.tasks) { 82 + if (t.finish) { 83 + const f = endOfLocalDayExclusive(t.finish); 84 + if (!latest || f.getTime() > latest.getTime()) latest = f; 85 + } 86 + } 87 + if (!latest) return 365 + HORIZON_BUFFER_DAYS; 88 + const span = Math.max(0, Math.round((latest.getTime() - epoch.getTime()) / MS_PER_DAY)); 89 + const scaled = Math.ceil(span * 1.25) + HORIZON_BUFFER_DAYS; 90 + return Math.min(TEN_YEARS_DAYS, scaled); 91 + } 92 + 93 + // Working-days-per-week derived from properties so non-Mon–Fri calendars 94 + // are handled; the `5` below is only a degenerate fallback. 95 + function workingDaysPerWeek(properties: ProjectProperties): number { 96 + const wpd = properties.minutesPerDay; 97 + const wpw = properties.minutesPerWeek; 98 + if (wpd <= 0 || wpw <= 0) return 5; 99 + return wpw / wpd; 100 + } 101 + 102 + // Lag → working days. MSPDI doesn't distinguish elapsed vs. working lag, so we 103 + // pick the working-day interpretation (matches MS Project default when no 104 + // `e`-prefix is present). Elapsed-lag support is a future addition. 105 + function lagToWorkingDays(lag: Duration | null, properties: ProjectProperties): number { 106 + if (!lag) return 0; 107 + const minutesPerDay = properties.minutesPerDay; 108 + const wpw = workingDaysPerWeek(properties); 109 + const dpm = properties.daysPerMonth; 110 + switch (lag.unit) { 111 + case TimeUnit.Days: 112 + return Math.round(lag.value); 113 + case TimeUnit.Weeks: 114 + return Math.round(lag.value * wpw); 115 + case TimeUnit.Hours: 116 + return Math.round((lag.value * 60) / minutesPerDay); 117 + case TimeUnit.Minutes: 118 + return Math.round(lag.value / minutesPerDay); 119 + case TimeUnit.Months: 120 + return Math.round(lag.value * dpm); 121 + case TimeUnit.Percent: 122 + return 0; 123 + } 124 + } 125 + 126 + function durationToWorkingDays( 127 + dur: Duration | null, 128 + cal: WorkingCalendar, 129 + startDay: number, 130 + properties: ProjectProperties, 131 + ): number { 132 + if (!dur) return 0; 133 + const minutesPerDay = properties.minutesPerDay; 134 + const wpw = workingDaysPerWeek(properties); 135 + const dpm = properties.daysPerMonth; 136 + switch (dur.unit) { 137 + case TimeUnit.Days: 138 + return Math.max(0, Math.round(dur.value)); 139 + case TimeUnit.Weeks: 140 + return Math.max(0, Math.round(dur.value * wpw)); 141 + case TimeUnit.Hours: 142 + return Math.max(0, Math.round((dur.value * 60) / minutesPerDay)); 143 + case TimeUnit.Minutes: 144 + return Math.max(0, Math.round(dur.value / minutesPerDay)); 145 + case TimeUnit.Months: 146 + return Math.max(0, Math.round(dur.value * dpm)); 147 + case TimeUnit.Percent: 148 + // Without a known reference, treat as zero. The pipeline can add a CPM 149 + // stage later if percent durations need to be resolved. 150 + void cal; 151 + void startDay; 152 + return 0; 153 + } 154 + } 155 + 156 + function calendarDayOffset(d: Date, epoch: Date): number { 157 + const dStart = startOfLocalDay(d).getTime(); 158 + const eStart = epoch.getTime(); 159 + return Math.round((dStart - eStart) / MS_PER_DAY); 160 + } 161 + 162 + function countWorkingDaysInRange(cal: WorkingCalendar, fromDay: number, toDay: number): number { 163 + const lo = Math.max(0, fromDay); 164 + const hi = Math.min(cal.horizonDays, toDay); 165 + if (hi <= lo) return 0; 166 + return cal.cumWorking[hi]! - cal.cumWorking[lo]!; 167 + } 168 + 169 + function resolveTask( 170 + task: Task, 171 + epoch: Date, 172 + cal: WorkingCalendar, 173 + properties: ProjectProperties, 174 + ): ResolvedTask { 175 + if (task.uniqueId === null) { 176 + throw new Error("resolveCalendar: task missing uniqueId"); 177 + } 178 + if (!task.start || !task.finish) { 179 + throw new Error(`resolveCalendar: task ${String(task.uniqueId)} missing start or finish`); 180 + } 181 + const startDay = calendarDayOffset(task.start, epoch); 182 + const finishDay = calendarDayOffset(endOfLocalDayExclusive(task.finish), epoch); 183 + const durationDays = 184 + task.duration !== null 185 + ? durationToWorkingDays(task.duration, cal, startDay, properties) 186 + : countWorkingDaysInRange(cal, startDay, finishDay); 187 + return { 188 + uniqueId: task.uniqueId, 189 + name: task.name, 190 + durationDays, 191 + outlineLevel: task.outlineLevel, 192 + summary: task.summary ?? false, 193 + milestone: task.milestone ?? false, 194 + // Per-task calendar override would land here once the schema carries it. 195 + calendarUniqueId: null, 196 + }; 197 + } 198 + 199 + function resolveEdges(tasks: ReadonlyArray<Task>, properties: ProjectProperties): PrecedenceEdge[] { 200 + const edges: PrecedenceEdge[] = []; 201 + for (const t of tasks) { 202 + if (t.uniqueId === null) continue; 203 + for (const r of t.predecessors) { 204 + if (r.predecessorUniqueId === null) continue; 205 + edges.push({ 206 + predecessorUniqueId: r.predecessorUniqueId, 207 + successorUniqueId: t.uniqueId, 208 + type: r.type, 209 + lagDays: lagToWorkingDays(r.lag, properties), 210 + }); 211 + } 212 + } 213 + return edges; 214 + } 215 + 216 + function resolveAssignments(project: ProjectFile): ResolvedAssignment[] { 217 + const out: ResolvedAssignment[] = []; 218 + for (const a of project.assignments) { 219 + if (a.taskUniqueId === null || a.resourceUniqueId === null) continue; 220 + out.push({ 221 + taskUniqueId: a.taskUniqueId, 222 + resourceUniqueId: a.resourceUniqueId, 223 + units: a.units ?? 1, 224 + }); 225 + } 226 + return out; 227 + } 228 + 229 + // capacityPerDay = (resource.maxUnits ?? 1) * (minutesPerDay / 60). 230 + function resolveResources(project: ProjectFile): ResolvedResource[] { 231 + const minutesPerDay = project.properties.minutesPerDay; 232 + const out: ResolvedResource[] = []; 233 + for (const r of project.resources) { 234 + if (r.uniqueId === null) continue; 235 + const maxUnits = r.maxUnits ?? 1; 236 + out.push({ 237 + uniqueId: r.uniqueId, 238 + capacityPerDay: maxUnits * (minutesPerDay / 60), 239 + // Per-resource calendar override would land here once the schema carries it. 240 + calendarUniqueId: null, 241 + }); 242 + } 243 + return out; 244 + } 245 + 246 + function validateTasks(project: ProjectFile): void { 247 + for (const t of project.tasks) { 248 + if (t.uniqueId === null) { 249 + throw new Error("resolveCalendar: task missing uniqueId"); 250 + } 251 + if (!t.start || !t.finish) { 252 + throw new Error(`resolveCalendar: task ${String(t.uniqueId)} missing start or finish`); 253 + } 254 + } 255 + } 256 + 257 + /** Build a WorkingCalendar per distinct calendarUniqueId referenced by 258 + * the project, plus any synthetic fallback. */ 259 + function buildCalendarMap( 260 + project: ProjectFile, 261 + defaultCalendar: Calendar, 262 + defaultCalendarUniqueId: number | null, 263 + epoch: Date, 264 + horizonDays: number, 265 + ): Map<number, WorkingCalendar> { 266 + const map = new Map<number, WorkingCalendar>(); 267 + // Always include the default. 268 + map.set( 269 + defaultCalendarUniqueId ?? SYNTHETIC_CAL_ID, 270 + buildWorkingCalendar(defaultCalendar, defaultCalendarUniqueId, epoch, horizonDays), 271 + ); 272 + // Any other named calendar with a uniqueId. 273 + for (const c of project.calendars) { 274 + if (c.uniqueId === null) continue; 275 + if (map.has(c.uniqueId)) continue; 276 + map.set(c.uniqueId, buildWorkingCalendar(c, c.uniqueId, epoch, horizonDays)); 277 + } 278 + return map; 279 + } 280 + 281 + /** Canonicalize WorkUnits into a by-id registry, the single owner of unit 282 + * definitions. Rejects duplicate ids so a unit id maps to exactly one 283 + * definition across every consumer (JS scorers and the MiniZinc backend). */ 284 + export function resolveWorkUnits( 285 + units: ReadonlyArray<WorkUnit> = [], 286 + ): ReadonlyMap<number, WorkUnit> { 287 + const map = new Map<number, WorkUnit>(); 288 + for (const u of units) { 289 + if (map.has(u.id)) { 290 + throw new Error(`resolveWorkUnits: duplicate WorkUnit id ${String(u.id)}`); 291 + } 292 + map.set(u.id, u); 293 + } 294 + return map; 295 + } 296 + 297 + function pickDefaultCalendar(project: ProjectFile): { 298 + calendar: Calendar; 299 + uniqueId: number | null; 300 + } { 301 + const id = project.properties.defaultCalendarUniqueId; 302 + if (id !== null) { 303 + const found = project.calendars.find((c) => c.uniqueId === id); 304 + if (found) return { calendar: found, uniqueId: found.uniqueId }; 305 + } 306 + const first = project.calendars[0]; 307 + if (first) return { calendar: first, uniqueId: first.uniqueId }; 308 + return { calendar: syntheticMonFriCalendar(), uniqueId: null }; 309 + } 310 + 311 + export function resolveCalendar(project: ProjectFile, opts: ResolveOptions = {}): ResolvedProject { 312 + validateTasks(project); 313 + const epoch = pickEpoch(project, opts); 314 + const horizonDays = pickHorizon(project, epoch, opts); 315 + const { calendar: defaultCalendar, uniqueId: defaultCalendarUniqueId } = 316 + pickDefaultCalendar(project); 317 + const calendars = buildCalendarMap( 318 + project, 319 + defaultCalendar, 320 + defaultCalendarUniqueId, 321 + epoch, 322 + horizonDays, 323 + ); 324 + // The default calendar drives task/edge resolution. Per-task overrides 325 + // would consult `calendars.get(task.calendarUniqueId)` here once the 326 + // schema carries that field. 327 + const defaultId = defaultCalendarUniqueId ?? SYNTHETIC_CAL_ID; 328 + const defaultWorking = calendars.get(defaultId)!; 329 + const tasks = project.tasks.map((t) => resolveTask(t, epoch, defaultWorking, project.properties)); 330 + const precedences = resolveEdges(project.tasks, project.properties); 331 + const assignments = resolveAssignments(project); 332 + const resources = resolveResources(project); 333 + const workUnits = resolveWorkUnits(opts.workUnits); 334 + return { 335 + source: project, 336 + defaultCalendarUniqueId: defaultCalendarUniqueId, 337 + calendars, 338 + tasks, 339 + resources, 340 + assignments, 341 + precedences, 342 + workUnits, 343 + }; 344 + } 345 + 346 + // Unused-but-exported epoch arithmetic kept for symmetry with calendarDays — 347 + // resolveCalendar uses these via calendarDays helpers, but downstream stages 348 + // often need the offset too. 349 + export { addCalendarDays, calendarDayOffset };
+441
test/level-core/resolveCalendar.test.ts
··· 1 + import { test, expect, describe } from "bun:test"; 2 + 3 + import { currentSchedule } from "../../src/level-core/currentSchedule.ts"; 4 + import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; 5 + import { Duration } from "../../src/model/Duration.ts"; 6 + import { RelationType, TimeUnit } from "../../src/model/types.ts"; 7 + import type { Calendar, CalendarException } from "../../src/schema/calendar.ts"; 8 + import type { ProjectFile } from "../../src/schema/project.ts"; 9 + import type { Relation } from "../../src/schema/relation.ts"; 10 + import type { Task } from "../../src/schema/task.ts"; 11 + 12 + function monFriCalendar(uniqueId = 1, exceptions: CalendarException[] = []): Calendar { 13 + return { 14 + uniqueId, 15 + name: "Standard", 16 + weekDays: [ 17 + { dayType: 1, working: false, workingTimes: [] }, 18 + { dayType: 2, working: true, workingTimes: [] }, 19 + { dayType: 3, working: true, workingTimes: [] }, 20 + { dayType: 4, working: true, workingTimes: [] }, 21 + { dayType: 5, working: true, workingTimes: [] }, 22 + { dayType: 6, working: true, workingTimes: [] }, 23 + { dayType: 7, working: false, workingTimes: [] }, 24 + ], 25 + exceptions, 26 + }; 27 + } 28 + 29 + function makeTask(args: { 30 + uniqueId: number; 31 + start: Date | null; 32 + finish: Date | null; 33 + predecessors?: Relation[]; 34 + milestone?: boolean; 35 + duration?: Duration | null; 36 + }): Task { 37 + return { 38 + id: null, 39 + uniqueId: args.uniqueId, 40 + name: `Task ${String(args.uniqueId)}`, 41 + wbs: null, 42 + outlineLevel: 0, 43 + start: args.start, 44 + finish: args.finish, 45 + duration: args.duration ?? null, 46 + percentComplete: null, 47 + summary: false, 48 + milestone: args.milestone ?? false, 49 + critical: null, 50 + notes: null, 51 + priority: null, 52 + cost: null, 53 + work: null, 54 + actualStart: null, 55 + actualFinish: null, 56 + baselineStart: null, 57 + baselineFinish: null, 58 + baselineDuration: null, 59 + actualWork: null, 60 + constraintType: null, 61 + freeSlack: null, 62 + totalSlack: null, 63 + earlyStart: null, 64 + earlyFinish: null, 65 + lateStart: null, 66 + lateFinish: null, 67 + levelingDelay: null, 68 + deadline: null, 69 + splits: null, 70 + predecessors: args.predecessors ?? [], 71 + }; 72 + } 73 + 74 + function makeProject(tasks: Task[], calendars: Calendar[] = [monFriCalendar()]): ProjectFile { 75 + return { 76 + properties: { 77 + title: "Test", 78 + author: null, 79 + startDate: null, 80 + finishDate: null, 81 + statusDate: null, 82 + defaultCalendarUniqueId: calendars[0]?.uniqueId ?? null, 83 + minutesPerDay: 480, 84 + minutesPerWeek: 2400, 85 + daysPerMonth: 20, 86 + saveVersion: null, 87 + }, 88 + tasks, 89 + resources: [], 90 + assignments: [], 91 + calendars, 92 + }; 93 + } 94 + 95 + function defaultCal(resolved: ReturnType<typeof resolveCalendar>) { 96 + const id = resolved.defaultCalendarUniqueId; 97 + return id !== null ? resolved.calendars.get(id)! : resolved.calendars.values().next().value!; 98 + } 99 + 100 + const MON_JAN_5 = new Date(2026, 0, 5); 101 + const SAT_JAN_10 = new Date(2026, 0, 10); 102 + const TUE_JAN_13 = new Date(2026, 0, 13); 103 + 104 + describe("resolveCalendar — Mon–Fri calendar", () => { 105 + test("1-week task: durationDays = 5, currentSchedule places at days 0..5", () => { 106 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); 107 + const resolved = resolveCalendar(project); 108 + expect(resolved.tasks[0]!.durationDays).toBe(5); 109 + const schedule = currentSchedule(resolved); 110 + expect(schedule.tasks[0]).toEqual({ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }); 111 + expect(schedule.makespan).toBe(5); 112 + }); 113 + 114 + test("MSPDI Fri 17:00 finish rounds up to Sat midnight", () => { 115 + const friAt17 = new Date(2026, 0, 9, 17, 0, 0); 116 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: friAt17 })]); 117 + const resolved = resolveCalendar(project); 118 + expect(currentSchedule(resolved).tasks[0]!.finishDay).toBe(5); 119 + }); 120 + 121 + test("milestone (start === finish at midnight) has zero duration", () => { 122 + const project = makeProject([ 123 + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: MON_JAN_5, milestone: true }), 124 + ]); 125 + const resolved = resolveCalendar(project); 126 + expect(resolved.tasks[0]!.durationDays).toBe(0); 127 + expect(resolved.tasks[0]!.milestone).toBe(true); 128 + expect(currentSchedule(resolved).tasks[0]!.startDay).toBe(0); 129 + expect(currentSchedule(resolved).tasks[0]!.finishDay).toBe(0); 130 + }); 131 + 132 + test("epoch is start-of-day even when source uses non-midnight times", () => { 133 + const monAt9 = new Date(2026, 0, 5, 9, 0, 0); 134 + const project = makeProject([makeTask({ uniqueId: 1, start: monAt9, finish: SAT_JAN_10 })]); 135 + const resolved = resolveCalendar(project); 136 + const cal = defaultCal(resolved); 137 + expect(cal.epoch.getHours()).toBe(0); 138 + expect(cal.epoch.getDate()).toBe(5); 139 + }); 140 + 141 + test("Sat/Sun gap is non-working in the bits", () => { 142 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: TUE_JAN_13 })]); 143 + const cal = defaultCal(resolveCalendar(project)); 144 + expect(cal.bits[5]).toBe(0); 145 + expect(cal.bits[6]).toBe(0); 146 + expect(cal.bits[7]).toBe(1); 147 + }); 148 + }); 149 + 150 + describe("resolveCalendar — exceptions", () => { 151 + test("mid-week non-working exception lengthens span", () => { 152 + const wedJan7 = new Date(2026, 0, 7); 153 + const calendar = monFriCalendar(1, [ 154 + { name: "Plant maintenance", fromDate: wedJan7, toDate: wedJan7, working: false }, 155 + ]); 156 + const project = makeProject( 157 + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: TUE_JAN_13 })], 158 + [calendar], 159 + ); 160 + const resolved = resolveCalendar(project); 161 + expect(defaultCal(resolved).bits[2]).toBe(0); 162 + // 5 working days with one mid-week non-working day → start 0, finish 8. 163 + const sched = currentSchedule(resolved).tasks[0]!; 164 + expect(sched.startDay).toBe(0); 165 + expect(sched.finishDay).toBe(8); 166 + }); 167 + 168 + test("exception with `working: null` is treated as non-working", () => { 169 + const wedJan7 = new Date(2026, 0, 7); 170 + const calendar = monFriCalendar(1, [ 171 + { name: "Holiday", fromDate: wedJan7, toDate: wedJan7, working: null }, 172 + ]); 173 + const project = makeProject( 174 + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })], 175 + [calendar], 176 + ); 177 + expect(defaultCal(resolveCalendar(project)).bits[2]).toBe(0); 178 + }); 179 + }); 180 + 181 + describe("resolveCalendar — precedence edges", () => { 182 + test("FS edge with 2-day lag carries lagDays through", () => { 183 + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); 184 + const t2 = makeTask({ 185 + uniqueId: 2, 186 + start: TUE_JAN_13, 187 + finish: new Date(2026, 0, 20), 188 + predecessors: [ 189 + { 190 + predecessorUniqueId: 1, 191 + successorUniqueId: 2, 192 + type: RelationType.FinishToStart, 193 + lag: Duration.from(2, TimeUnit.Days), 194 + }, 195 + ], 196 + }); 197 + const resolved = resolveCalendar(makeProject([t1, t2])); 198 + expect(resolved.precedences[0]).toEqual({ 199 + predecessorUniqueId: 1, 200 + successorUniqueId: 2, 201 + type: RelationType.FinishToStart, 202 + lagDays: 2, 203 + }); 204 + }); 205 + 206 + test("week-unit lag uses minutesPerWeek/minutesPerDay (not hardcoded 5)", () => { 207 + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); 208 + const t2 = makeTask({ 209 + uniqueId: 2, 210 + start: TUE_JAN_13, 211 + finish: new Date(2026, 0, 20), 212 + predecessors: [ 213 + { 214 + predecessorUniqueId: 1, 215 + successorUniqueId: 2, 216 + type: RelationType.StartToStart, 217 + lag: Duration.from(1, TimeUnit.Weeks), 218 + }, 219 + ], 220 + }); 221 + // 480 min/day, 2400 min/week → 5 working days/week. 222 + expect(resolveCalendar(makeProject([t1, t2])).precedences[0]!.lagDays).toBe(5); 223 + }); 224 + 225 + test("week-unit lag honors a 6-day workweek (minutesPerWeek = 2880)", () => { 226 + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); 227 + const t2 = makeTask({ 228 + uniqueId: 2, 229 + start: TUE_JAN_13, 230 + finish: new Date(2026, 0, 20), 231 + predecessors: [ 232 + { 233 + predecessorUniqueId: 1, 234 + successorUniqueId: 2, 235 + type: RelationType.StartToStart, 236 + lag: Duration.from(1, TimeUnit.Weeks), 237 + }, 238 + ], 239 + }); 240 + const project: ProjectFile = { 241 + ...makeProject([t1, t2]), 242 + properties: { 243 + title: null, 244 + author: null, 245 + startDate: null, 246 + finishDate: null, 247 + statusDate: null, 248 + defaultCalendarUniqueId: 1, 249 + minutesPerDay: 480, 250 + minutesPerWeek: 2880, // 6 working days/week 251 + daysPerMonth: 20, 252 + saveVersion: null, 253 + }, 254 + }; 255 + expect(resolveCalendar(project).precedences[0]!.lagDays).toBe(6); 256 + }); 257 + }); 258 + 259 + describe("resolveCalendar — assignments, resources, source preservation", () => { 260 + test("assignments pass through with default units = 1 when null", () => { 261 + const project: ProjectFile = { 262 + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), 263 + assignments: [ 264 + { 265 + taskUniqueId: 1, 266 + resourceUniqueId: 10, 267 + work: null, 268 + units: null, 269 + start: null, 270 + finish: null, 271 + actualWork: null, 272 + remainingWork: null, 273 + }, 274 + ], 275 + }; 276 + expect(resolveCalendar(project).assignments).toEqual([ 277 + { taskUniqueId: 1, resourceUniqueId: 10, units: 1 }, 278 + ]); 279 + }); 280 + 281 + test("capacityPerDay = (maxUnits ?? 1) * (minutesPerDay/60)", () => { 282 + // 480 min/day → 8 hours/day; maxUnits = 2 → capacity = 16. 283 + const project: ProjectFile = { 284 + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), 285 + resources: [ 286 + { 287 + id: 10, 288 + uniqueId: 10, 289 + name: "Crew", 290 + type: "Work" as never, 291 + email: null, 292 + group: null, 293 + maxUnits: 2, 294 + cost: null, 295 + work: null, 296 + resourcePool: null, 297 + }, 298 + ], 299 + }; 300 + expect(resolveCalendar(project).resources).toEqual([ 301 + { uniqueId: 10, capacityPerDay: 16, calendarUniqueId: null }, 302 + ]); 303 + }); 304 + 305 + test("`source` field is the input project (identity)", () => { 306 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); 307 + expect(resolveCalendar(project).source).toBe(project); 308 + }); 309 + }); 310 + 311 + describe("resolveCalendar — epoch", () => { 312 + test("opts.epoch wins over statusDate and task starts", () => { 313 + const explicitEpoch = new Date(2025, 11, 29); // Mon Dec 29 2025 314 + const project: ProjectFile = { 315 + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), 316 + properties: { 317 + title: null, 318 + author: null, 319 + startDate: null, 320 + finishDate: null, 321 + statusDate: new Date(2026, 0, 1), 322 + defaultCalendarUniqueId: 1, 323 + minutesPerDay: 480, 324 + minutesPerWeek: 2400, 325 + daysPerMonth: 20, 326 + saveVersion: null, 327 + }, 328 + }; 329 + const resolved = resolveCalendar(project, { epoch: explicitEpoch }); 330 + expect(defaultCal(resolved).epoch.getDate()).toBe(29); 331 + expect(defaultCal(resolved).epoch.getMonth()).toBe(11); 332 + }); 333 + 334 + test("statusDate is used when opts.epoch is absent", () => { 335 + const project: ProjectFile = { 336 + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), 337 + properties: { 338 + title: null, 339 + author: null, 340 + startDate: null, 341 + finishDate: null, 342 + statusDate: new Date(2025, 11, 30), 343 + defaultCalendarUniqueId: 1, 344 + minutesPerDay: 480, 345 + minutesPerWeek: 2400, 346 + daysPerMonth: 20, 347 + saveVersion: null, 348 + }, 349 + }; 350 + expect(defaultCal(resolveCalendar(project)).epoch.getDate()).toBe(30); 351 + }); 352 + 353 + test("throws when neither opts.epoch, statusDate, startDate, nor task starts exist", () => { 354 + const project: ProjectFile = { 355 + ...makeProject([]), 356 + }; 357 + expect(() => resolveCalendar(project)).toThrow(/cannot pick epoch/); 358 + }); 359 + }); 360 + 361 + describe("resolveCalendar — multi-calendar map", () => { 362 + test("default calendar lives in the map under its uniqueId", () => { 363 + const c1 = monFriCalendar(7); 364 + const project = makeProject( 365 + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })], 366 + [c1], 367 + ); 368 + const resolved = resolveCalendar(project); 369 + expect(resolved.defaultCalendarUniqueId).toBe(7); 370 + expect(resolved.calendars.get(7)).toBeDefined(); 371 + }); 372 + 373 + test("non-default calendars also build in the map", () => { 374 + const c1 = monFriCalendar(1); 375 + const c2 = monFriCalendar(2); 376 + const project = makeProject( 377 + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })], 378 + [c1, c2], 379 + ); 380 + const resolved = resolveCalendar(project); 381 + expect(resolved.calendars.has(1)).toBe(true); 382 + expect(resolved.calendars.has(2)).toBe(true); 383 + }); 384 + 385 + test("missing calendars synthesizes a Mon–Fri fallback", () => { 386 + const project = makeProject( 387 + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })], 388 + [], 389 + ); 390 + const resolved = resolveCalendar(project); 391 + expect(resolved.defaultCalendarUniqueId).toBeNull(); 392 + expect(resolved.calendars.size).toBe(1); 393 + const cal = resolved.calendars.values().next().value!; 394 + expect(cal.bits[0]).toBe(1); // Mon 395 + expect(cal.bits[5]).toBe(0); // Sat 396 + }); 397 + }); 398 + 399 + describe("resolveCalendar — error paths", () => { 400 + test("task missing start throws", () => { 401 + const broken = makeTask({ uniqueId: 1, start: null, finish: SAT_JAN_10 }); 402 + expect(() => resolveCalendar(makeProject([broken]))).toThrow(/missing start or finish/); 403 + }); 404 + 405 + test("task missing uniqueId throws", () => { 406 + const broken = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); 407 + expect(() => resolveCalendar(makeProject([{ ...broken, uniqueId: null }]))).toThrow( 408 + /missing uniqueId/, 409 + ); 410 + }); 411 + }); 412 + 413 + describe("resolveCalendar — WorkUnit registry", () => { 414 + const task = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); 415 + 416 + test("resolves WorkUnits into a by-id registry", () => { 417 + const resolved = resolveCalendar(makeProject([task]), { 418 + workUnits: [ 419 + { id: 10, taskUniqueIds: [1] }, 420 + { id: 20, taskUniqueIds: [2] }, 421 + ], 422 + }); 423 + expect(resolved.workUnits.size).toBe(2); 424 + expect(resolved.workUnits.get(10)!.taskUniqueIds).toEqual([1]); 425 + }); 426 + 427 + test("defaults to an empty registry when no workUnits are given", () => { 428 + expect(resolveCalendar(makeProject([task])).workUnits.size).toBe(0); 429 + }); 430 + 431 + test("rejects duplicate WorkUnit ids", () => { 432 + expect(() => 433 + resolveCalendar(makeProject([task]), { 434 + workUnits: [ 435 + { id: 10, taskUniqueIds: [1] }, 436 + { id: 10, taskUniqueIds: [2] }, 437 + ], 438 + }), 439 + ).toThrow(/duplicate WorkUnit id 10/); 440 + }); 441 + });