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): currentSchedule and materialize bridges

The two bridges between a ResolvedProject and a Schedule. currentSchedule
reads existing source dates into Schedule form, so round-trip identity
holds before any search has run — the no-search-yet case is first-class.
materialize goes the other way, carrying the entire source ProjectFile
through and overriding only start/finish/duration for tasks present in the
schedule, so a partial schedule passes the rest through untouched.
Durations are re-emitted in the source unit via the project's unit
conversions, and both honor per-task calendars. A mode change throws until
mode selection is implemented.

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

+436
+41
src/level-core/currentSchedule.ts
··· 1 + // Bridge for the no-search-yet case. Reads pre-existing dates from 2 + // `resolved.source` into a Schedule shape so round-trip identity holds for 3 + // projects that already have task dates pinned, and so partial pipelines 4 + // can hand a Schedule to materialize() without going through Search. 5 + 6 + import { 7 + dateToDay, 8 + endOfLocalDayExclusive, 9 + resolveWorkingCalendar, 10 + startOfLocalDay, 11 + } from "./calendarDays.ts"; 12 + import type { ResolvedProject, Schedule, ScheduledTask } from "./types.ts"; 13 + 14 + export function currentSchedule(resolved: ResolvedProject): Schedule { 15 + const tasks: ScheduledTask[] = []; 16 + let makespan = 0; 17 + const taskById = new Map(resolved.tasks.map((t) => [t.uniqueId, t])); 18 + for (const sourceTask of resolved.source.tasks) { 19 + if (sourceTask.uniqueId === null) continue; 20 + if (!sourceTask.start || !sourceTask.finish) continue; 21 + const cal = resolveWorkingCalendar( 22 + resolved, 23 + taskById.get(sourceTask.uniqueId)?.calendarUniqueId ?? null, 24 + ); 25 + const startDay = dateToDay(cal, startOfLocalDay(sourceTask.start)); 26 + const finishDay = dateToDay(cal, endOfLocalDayExclusive(sourceTask.finish)); 27 + tasks.push({ 28 + uniqueId: sourceTask.uniqueId, 29 + startDay, 30 + finishDay, 31 + modeId: null, 32 + }); 33 + if (finishDay > makespan) makespan = finishDay; 34 + } 35 + return { 36 + resolved, 37 + tasks, 38 + makespan, 39 + annotations: new Map(), 40 + }; 41 + }
+89
src/level-core/materialize.ts
··· 1 + // Schedule ──materialize──> ProjectFile. 2 + // Carries forward every source field except the ones the schedule decides 3 + // (start, finish, duration). Tasks that don't appear in `schedule.tasks` 4 + // pass through unchanged — useful when only a subset of tasks were 5 + // scheduled (e.g. mid-pipeline partial schedules). 6 + 7 + import { Duration } from "../model/Duration.ts"; 8 + import { TimeUnit } from "../model/types.ts"; 9 + import type { ProjectFile, ProjectProperties } from "../schema/project.ts"; 10 + import type { Task } from "../schema/task.ts"; 11 + 12 + import { countWorkingDays, dayToDate, resolveWorkingCalendar } from "./calendarDays.ts"; 13 + import type { Schedule, ScheduledTask, WorkingCalendar } from "./types.ts"; 14 + 15 + function workingDaysPerWeek(properties: ProjectProperties): number { 16 + const wpd = properties.minutesPerDay; 17 + const wpw = properties.minutesPerWeek; 18 + if (wpd <= 0 || wpw <= 0) return 5; 19 + return wpw / wpd; 20 + } 21 + 22 + // Re-emit duration in the original unit using properties-derived 23 + // conversions; falls back to Days when the source had no unit. 24 + function workingDaysToDuration( 25 + workingDays: number, 26 + original: Duration | null, 27 + properties: ProjectProperties, 28 + ): Duration | null { 29 + if (!original) return Duration.from(workingDays, TimeUnit.Days); 30 + const minutesPerDay = properties.minutesPerDay; 31 + const wpw = workingDaysPerWeek(properties); 32 + const dpm = properties.daysPerMonth; 33 + switch (original.unit) { 34 + case TimeUnit.Days: 35 + return Duration.from(workingDays, TimeUnit.Days); 36 + case TimeUnit.Weeks: 37 + return Duration.from(wpw > 0 ? workingDays / wpw : workingDays, TimeUnit.Weeks); 38 + case TimeUnit.Hours: 39 + return Duration.from((workingDays * minutesPerDay) / 60, TimeUnit.Hours); 40 + case TimeUnit.Minutes: 41 + return Duration.from(workingDays * minutesPerDay, TimeUnit.Minutes); 42 + case TimeUnit.Months: 43 + return Duration.from(dpm > 0 ? workingDays / dpm : workingDays, TimeUnit.Months); 44 + case TimeUnit.Percent: 45 + return original; 46 + } 47 + } 48 + 49 + function applyScheduledTask( 50 + task: Task, 51 + scheduled: ScheduledTask, 52 + cal: WorkingCalendar, 53 + properties: ProjectProperties, 54 + ): Task { 55 + if (scheduled.modeId !== null) { 56 + throw new Error( 57 + `materialize: mode-change tasks are not yet supported (task ${String(task.uniqueId)})`, 58 + ); 59 + } 60 + const workingDays = countWorkingDays(cal, scheduled.startDay, scheduled.finishDay); 61 + return { 62 + ...task, 63 + start: dayToDate(cal, scheduled.startDay), 64 + finish: dayToDate(cal, scheduled.finishDay), 65 + duration: workingDaysToDuration(workingDays, task.duration, properties), 66 + }; 67 + } 68 + 69 + export function materialize(schedule: Schedule): ProjectFile { 70 + const source = schedule.resolved.source; 71 + const properties = source.properties; 72 + 73 + const byUniqueId = new Map<number, ScheduledTask>(); 74 + for (const t of schedule.tasks) byUniqueId.set(t.uniqueId, t); 75 + const resolvedTaskById = new Map(schedule.resolved.tasks.map((t) => [t.uniqueId, t])); 76 + 77 + const tasks = source.tasks.map((task) => { 78 + if (task.uniqueId === null) return task; 79 + const scheduled = byUniqueId.get(task.uniqueId); 80 + if (!scheduled) return task; 81 + const cal = resolveWorkingCalendar( 82 + schedule.resolved, 83 + resolvedTaskById.get(task.uniqueId)?.calendarUniqueId ?? null, 84 + ); 85 + return applyScheduledTask(task, scheduled, cal, properties); 86 + }); 87 + 88 + return { ...source, tasks }; 89 + }
+306
test/level-core/materialize.test.ts
··· 1 + import { test, expect, describe } from "bun:test"; 2 + 3 + import { currentSchedule } from "../../src/level-core/currentSchedule.ts"; 4 + import { materialize } from "../../src/level-core/materialize.ts"; 5 + import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; 6 + import type { Schedule } from "../../src/level-core/types.ts"; 7 + import { Duration } from "../../src/model/Duration.ts"; 8 + import { ResourceType, TimeUnit } from "../../src/model/types.ts"; 9 + import type { Calendar, CalendarException } from "../../src/schema/calendar.ts"; 10 + import type { ProjectFile } from "../../src/schema/project.ts"; 11 + import type { Task } from "../../src/schema/task.ts"; 12 + 13 + function monFriCalendar(uniqueId = 1, exceptions: CalendarException[] = []): Calendar { 14 + return { 15 + uniqueId, 16 + name: "Standard", 17 + weekDays: [ 18 + { dayType: 1, working: false, workingTimes: [] }, 19 + { dayType: 2, working: true, workingTimes: [] }, 20 + { dayType: 3, working: true, workingTimes: [] }, 21 + { dayType: 4, working: true, workingTimes: [] }, 22 + { dayType: 5, working: true, workingTimes: [] }, 23 + { dayType: 6, working: true, workingTimes: [] }, 24 + { dayType: 7, working: false, workingTimes: [] }, 25 + ], 26 + exceptions, 27 + }; 28 + } 29 + 30 + function makeTask(args: { 31 + uniqueId: number; 32 + start: Date; 33 + finish: Date; 34 + duration?: Duration | null; 35 + notes?: string | null; 36 + baselineStart?: Date | null; 37 + }): Task { 38 + return { 39 + id: null, 40 + uniqueId: args.uniqueId, 41 + name: `Task ${String(args.uniqueId)}`, 42 + wbs: null, 43 + outlineLevel: 0, 44 + start: args.start, 45 + finish: args.finish, 46 + duration: args.duration ?? null, 47 + percentComplete: null, 48 + summary: false, 49 + milestone: false, 50 + critical: null, 51 + notes: args.notes ?? null, 52 + priority: null, 53 + cost: null, 54 + work: null, 55 + actualStart: null, 56 + actualFinish: null, 57 + baselineStart: args.baselineStart ?? null, 58 + baselineFinish: null, 59 + baselineDuration: null, 60 + actualWork: null, 61 + constraintType: null, 62 + freeSlack: null, 63 + totalSlack: null, 64 + earlyStart: null, 65 + earlyFinish: null, 66 + lateStart: null, 67 + lateFinish: null, 68 + levelingDelay: null, 69 + deadline: null, 70 + splits: null, 71 + predecessors: [], 72 + }; 73 + } 74 + 75 + function makeProject(tasks: Task[], calendars: Calendar[] = [monFriCalendar()]): ProjectFile { 76 + return { 77 + properties: { 78 + title: "Test", 79 + author: null, 80 + startDate: null, 81 + finishDate: null, 82 + statusDate: null, 83 + defaultCalendarUniqueId: calendars[0]?.uniqueId ?? null, 84 + minutesPerDay: 480, 85 + minutesPerWeek: 2400, 86 + daysPerMonth: 20, 87 + saveVersion: null, 88 + }, 89 + tasks, 90 + resources: [], 91 + assignments: [], 92 + calendars, 93 + }; 94 + } 95 + 96 + const MON_JAN_5 = new Date(2026, 0, 5); 97 + const SAT_JAN_10 = new Date(2026, 0, 10); 98 + 99 + describe("materialize — round-trip", () => { 100 + test("resolve → currentSchedule → materialize → resolve gives same day indices", () => { 101 + const project = makeProject([ 102 + makeTask({ 103 + uniqueId: 1, 104 + start: MON_JAN_5, 105 + finish: SAT_JAN_10, 106 + duration: Duration.from(5, TimeUnit.Days), 107 + }), 108 + ]); 109 + const resolved1 = resolveCalendar(project); 110 + const sched1 = currentSchedule(resolved1); 111 + const out = materialize(sched1); 112 + const resolved2 = resolveCalendar(out); 113 + const sched2 = currentSchedule(resolved2); 114 + expect(sched2.tasks[0]!.startDay).toBe(sched1.tasks[0]!.startDay); 115 + expect(sched2.tasks[0]!.finishDay).toBe(sched1.tasks[0]!.finishDay); 116 + expect(resolved2.tasks[0]!.durationDays).toBe(resolved1.tasks[0]!.durationDays); 117 + }); 118 + 119 + test("MSPDI Fri 17:00 finish round-trips through midnight normalization", () => { 120 + const friAt17 = new Date(2026, 0, 9, 17, 0, 0); 121 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: friAt17 })]); 122 + const out = materialize(currentSchedule(resolveCalendar(project))); 123 + expect(out.tasks[0]!.finish).toEqual(new Date(2026, 0, 10)); 124 + expect(currentSchedule(resolveCalendar(out)).tasks[0]!.finishDay).toBe(5); 125 + }); 126 + }); 127 + 128 + describe("materialize — field preservation", () => { 129 + test("notes and baselineStart pass through unchanged", () => { 130 + const baseline = new Date(2026, 0, 5); 131 + const project = makeProject([ 132 + makeTask({ 133 + uniqueId: 1, 134 + start: MON_JAN_5, 135 + finish: SAT_JAN_10, 136 + notes: "do not lose me", 137 + baselineStart: baseline, 138 + }), 139 + ]); 140 + const out = materialize(currentSchedule(resolveCalendar(project))); 141 + expect(out.tasks[0]!.notes).toBe("do not lose me"); 142 + expect(out.tasks[0]!.baselineStart).toEqual(baseline); 143 + }); 144 + 145 + test("source-level resources/assignments/calendars/properties survive", () => { 146 + const project: ProjectFile = { 147 + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), 148 + resources: [ 149 + { 150 + id: 1, 151 + uniqueId: 1, 152 + name: "Crew A", 153 + type: ResourceType.Work, 154 + email: null, 155 + group: null, 156 + maxUnits: null, 157 + cost: null, 158 + work: null, 159 + resourcePool: null, 160 + }, 161 + ], 162 + properties: { 163 + title: "Authoritative title", 164 + author: "RF", 165 + startDate: null, 166 + finishDate: null, 167 + statusDate: null, 168 + defaultCalendarUniqueId: 1, 169 + minutesPerDay: 480, 170 + minutesPerWeek: 2400, 171 + daysPerMonth: 20, 172 + saveVersion: 14, 173 + }, 174 + }; 175 + const out = materialize(currentSchedule(resolveCalendar(project))); 176 + expect(out.resources).toHaveLength(1); 177 + expect(out.resources[0]!.name).toBe("Crew A"); 178 + expect(out.properties.title).toBe("Authoritative title"); 179 + expect(out.properties.author).toBe("RF"); 180 + expect(out.properties.saveVersion).toBe(14); 181 + expect(out.calendars).toHaveLength(1); 182 + }); 183 + 184 + test("tasks not in schedule pass through unchanged", () => { 185 + const project = makeProject([ 186 + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), 187 + makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10 }), 188 + ]); 189 + const resolved = resolveCalendar(project); 190 + const partial: Schedule = { 191 + resolved, 192 + tasks: [{ uniqueId: 1, startDay: 7, finishDay: 12, modeId: null }], 193 + makespan: 12, 194 + annotations: new Map(), 195 + }; 196 + const out = materialize(partial); 197 + expect(out.tasks[0]!.start).toEqual(new Date(2026, 0, 12)); // shifted 198 + expect(out.tasks[1]!.start).toEqual(MON_JAN_5); // untouched 199 + expect(out.tasks[1]!.finish).toEqual(SAT_JAN_10); 200 + }); 201 + }); 202 + 203 + describe("materialize — duration update across units", () => { 204 + test("Days-unit duration mirrors working-day count", () => { 205 + const project = makeProject([ 206 + makeTask({ 207 + uniqueId: 1, 208 + start: MON_JAN_5, 209 + finish: SAT_JAN_10, 210 + duration: Duration.from(5, TimeUnit.Days), 211 + }), 212 + ]); 213 + const out = materialize(currentSchedule(resolveCalendar(project))); 214 + expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Days); 215 + expect(out.tasks[0]!.duration?.value).toBe(5); 216 + }); 217 + 218 + test("Hours-unit duration scales by minutesPerDay", () => { 219 + const project = makeProject([ 220 + makeTask({ 221 + uniqueId: 1, 222 + start: MON_JAN_5, 223 + finish: SAT_JAN_10, 224 + duration: Duration.from(40, TimeUnit.Hours), 225 + }), 226 + ]); 227 + const out = materialize(currentSchedule(resolveCalendar(project))); 228 + expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Hours); 229 + expect(out.tasks[0]!.duration?.value).toBe(40); // 5 days × 480 min / 60 230 + }); 231 + 232 + test("Weeks-unit duration uses minutesPerWeek/minutesPerDay", () => { 233 + const project = makeProject([ 234 + makeTask({ 235 + uniqueId: 1, 236 + start: MON_JAN_5, 237 + finish: SAT_JAN_10, 238 + duration: Duration.from(1, TimeUnit.Weeks), 239 + }), 240 + ]); 241 + const out = materialize(currentSchedule(resolveCalendar(project))); 242 + expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Weeks); 243 + expect(out.tasks[0]!.duration?.value).toBe(1); 244 + }); 245 + 246 + test("null source duration emits Days-unit by default", () => { 247 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); 248 + const out = materialize(currentSchedule(resolveCalendar(project))); 249 + expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Days); 250 + expect(out.tasks[0]!.duration?.value).toBe(5); 251 + }); 252 + 253 + test("non-working exception in window keeps duration aligned to working-day count", () => { 254 + const wedJan7 = new Date(2026, 0, 7); 255 + const project = makeProject( 256 + [ 257 + makeTask({ 258 + uniqueId: 1, 259 + start: MON_JAN_5, 260 + finish: new Date(2026, 0, 13), 261 + duration: Duration.from(5, TimeUnit.Days), 262 + }), 263 + ], 264 + [ 265 + monFriCalendar(1, [ 266 + { name: "Holiday", fromDate: wedJan7, toDate: wedJan7, working: false }, 267 + ]), 268 + ], 269 + ); 270 + const out = materialize(currentSchedule(resolveCalendar(project))); 271 + expect(out.tasks[0]!.duration?.value).toBe(5); 272 + }); 273 + }); 274 + 275 + describe("materialize — schedule shifts", () => { 276 + test("shifting startDay by 7 cal days updates finish dates correctly", () => { 277 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); 278 + const resolved = resolveCalendar(project); 279 + const seed = currentSchedule(resolved).tasks[0]!; 280 + const shifted: Schedule = { 281 + resolved, 282 + tasks: [ 283 + { uniqueId: 1, startDay: seed.startDay + 7, finishDay: seed.finishDay + 7, modeId: null }, 284 + ], 285 + makespan: seed.finishDay + 7, 286 + annotations: new Map(), 287 + }; 288 + const out = materialize(shifted); 289 + expect(out.tasks[0]!.start).toEqual(new Date(2026, 0, 12)); 290 + expect(out.tasks[0]!.finish).toEqual(new Date(2026, 0, 17)); 291 + }); 292 + }); 293 + 294 + describe("materialize — error paths", () => { 295 + test("mode-change task throws (not yet supported)", () => { 296 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); 297 + const resolved = resolveCalendar(project); 298 + const sched: Schedule = { 299 + resolved, 300 + tasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: 2 }], 301 + makespan: 5, 302 + annotations: new Map(), 303 + }; 304 + expect(() => materialize(sched)).toThrow(/mode-change/); 305 + }); 306 + });