From fc62a7388935946df70f762859c06bf86c94adae Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Sun, 26 Apr 2026 11:00:00 -0600 Subject: [PATCH] feat(level-core): currentSchedule and materialize bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/level-core/currentSchedule.ts | 41 ++++ src/level-core/materialize.ts | 89 ++++++++ test/level-core/materialize.test.ts | 306 ++++++++++++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 src/level-core/currentSchedule.ts create mode 100644 src/level-core/materialize.ts create mode 100644 test/level-core/materialize.test.ts diff --git a/src/level-core/currentSchedule.ts b/src/level-core/currentSchedule.ts new file mode 100644 index 0000000..d22b4bc --- /dev/null +++ b/src/level-core/currentSchedule.ts @@ -0,0 +1,41 @@ +// Bridge for the no-search-yet case. Reads pre-existing dates from +// `resolved.source` into a Schedule shape so round-trip identity holds for +// projects that already have task dates pinned, and so partial pipelines +// can hand a Schedule to materialize() without going through Search. + +import { + dateToDay, + endOfLocalDayExclusive, + resolveWorkingCalendar, + startOfLocalDay, +} from "./calendarDays.ts"; +import type { ResolvedProject, Schedule, ScheduledTask } from "./types.ts"; + +export function currentSchedule(resolved: ResolvedProject): Schedule { + const tasks: ScheduledTask[] = []; + let makespan = 0; + const taskById = new Map(resolved.tasks.map((t) => [t.uniqueId, t])); + for (const sourceTask of resolved.source.tasks) { + if (sourceTask.uniqueId === null) continue; + if (!sourceTask.start || !sourceTask.finish) continue; + const cal = resolveWorkingCalendar( + resolved, + taskById.get(sourceTask.uniqueId)?.calendarUniqueId ?? null, + ); + const startDay = dateToDay(cal, startOfLocalDay(sourceTask.start)); + const finishDay = dateToDay(cal, endOfLocalDayExclusive(sourceTask.finish)); + tasks.push({ + uniqueId: sourceTask.uniqueId, + startDay, + finishDay, + modeId: null, + }); + if (finishDay > makespan) makespan = finishDay; + } + return { + resolved, + tasks, + makespan, + annotations: new Map(), + }; +} diff --git a/src/level-core/materialize.ts b/src/level-core/materialize.ts new file mode 100644 index 0000000..02c49db --- /dev/null +++ b/src/level-core/materialize.ts @@ -0,0 +1,89 @@ +// Schedule ──materialize──> ProjectFile. +// Carries forward every source field except the ones the schedule decides +// (start, finish, duration). Tasks that don't appear in `schedule.tasks` +// pass through unchanged — useful when only a subset of tasks were +// scheduled (e.g. mid-pipeline partial schedules). + +import { Duration } from "../model/Duration.ts"; +import { TimeUnit } from "../model/types.ts"; +import type { ProjectFile, ProjectProperties } from "../schema/project.ts"; +import type { Task } from "../schema/task.ts"; + +import { countWorkingDays, dayToDate, resolveWorkingCalendar } from "./calendarDays.ts"; +import type { Schedule, ScheduledTask, WorkingCalendar } from "./types.ts"; + +function workingDaysPerWeek(properties: ProjectProperties): number { + const wpd = properties.minutesPerDay; + const wpw = properties.minutesPerWeek; + if (wpd <= 0 || wpw <= 0) return 5; + return wpw / wpd; +} + +// Re-emit duration in the original unit using properties-derived +// conversions; falls back to Days when the source had no unit. +function workingDaysToDuration( + workingDays: number, + original: Duration | null, + properties: ProjectProperties, +): Duration | null { + if (!original) return Duration.from(workingDays, TimeUnit.Days); + const minutesPerDay = properties.minutesPerDay; + const wpw = workingDaysPerWeek(properties); + const dpm = properties.daysPerMonth; + switch (original.unit) { + case TimeUnit.Days: + return Duration.from(workingDays, TimeUnit.Days); + case TimeUnit.Weeks: + return Duration.from(wpw > 0 ? workingDays / wpw : workingDays, TimeUnit.Weeks); + case TimeUnit.Hours: + return Duration.from((workingDays * minutesPerDay) / 60, TimeUnit.Hours); + case TimeUnit.Minutes: + return Duration.from(workingDays * minutesPerDay, TimeUnit.Minutes); + case TimeUnit.Months: + return Duration.from(dpm > 0 ? workingDays / dpm : workingDays, TimeUnit.Months); + case TimeUnit.Percent: + return original; + } +} + +function applyScheduledTask( + task: Task, + scheduled: ScheduledTask, + cal: WorkingCalendar, + properties: ProjectProperties, +): Task { + if (scheduled.modeId !== null) { + throw new Error( + `materialize: mode-change tasks are not yet supported (task ${String(task.uniqueId)})`, + ); + } + const workingDays = countWorkingDays(cal, scheduled.startDay, scheduled.finishDay); + return { + ...task, + start: dayToDate(cal, scheduled.startDay), + finish: dayToDate(cal, scheduled.finishDay), + duration: workingDaysToDuration(workingDays, task.duration, properties), + }; +} + +export function materialize(schedule: Schedule): ProjectFile { + const source = schedule.resolved.source; + const properties = source.properties; + + const byUniqueId = new Map(); + for (const t of schedule.tasks) byUniqueId.set(t.uniqueId, t); + const resolvedTaskById = new Map(schedule.resolved.tasks.map((t) => [t.uniqueId, t])); + + const tasks = source.tasks.map((task) => { + if (task.uniqueId === null) return task; + const scheduled = byUniqueId.get(task.uniqueId); + if (!scheduled) return task; + const cal = resolveWorkingCalendar( + schedule.resolved, + resolvedTaskById.get(task.uniqueId)?.calendarUniqueId ?? null, + ); + return applyScheduledTask(task, scheduled, cal, properties); + }); + + return { ...source, tasks }; +} diff --git a/test/level-core/materialize.test.ts b/test/level-core/materialize.test.ts new file mode 100644 index 0000000..bc27ea3 --- /dev/null +++ b/test/level-core/materialize.test.ts @@ -0,0 +1,306 @@ +import { test, expect, describe } from "bun:test"; + +import { currentSchedule } from "../../src/level-core/currentSchedule.ts"; +import { materialize } from "../../src/level-core/materialize.ts"; +import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; +import type { Schedule } from "../../src/level-core/types.ts"; +import { Duration } from "../../src/model/Duration.ts"; +import { ResourceType, TimeUnit } from "../../src/model/types.ts"; +import type { Calendar, CalendarException } from "../../src/schema/calendar.ts"; +import type { ProjectFile } from "../../src/schema/project.ts"; +import type { Task } from "../../src/schema/task.ts"; + +function monFriCalendar(uniqueId = 1, exceptions: CalendarException[] = []): Calendar { + return { + uniqueId, + name: "Standard", + 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, + }; +} + +function makeTask(args: { + uniqueId: number; + start: Date; + finish: Date; + duration?: Duration | null; + notes?: string | null; + baselineStart?: Date | null; +}): Task { + return { + id: null, + uniqueId: args.uniqueId, + name: `Task ${String(args.uniqueId)}`, + wbs: null, + outlineLevel: 0, + start: args.start, + finish: args.finish, + duration: args.duration ?? null, + percentComplete: null, + summary: false, + milestone: false, + critical: null, + notes: args.notes ?? null, + priority: null, + cost: null, + work: null, + actualStart: null, + actualFinish: null, + baselineStart: args.baselineStart ?? null, + baselineFinish: null, + baselineDuration: null, + actualWork: null, + constraintType: null, + freeSlack: null, + totalSlack: null, + earlyStart: null, + earlyFinish: null, + lateStart: null, + lateFinish: null, + levelingDelay: null, + deadline: null, + splits: null, + predecessors: [], + }; +} + +function makeProject(tasks: Task[], calendars: Calendar[] = [monFriCalendar()]): ProjectFile { + return { + properties: { + title: "Test", + author: null, + startDate: null, + finishDate: null, + statusDate: null, + defaultCalendarUniqueId: calendars[0]?.uniqueId ?? null, + minutesPerDay: 480, + minutesPerWeek: 2400, + daysPerMonth: 20, + saveVersion: null, + }, + tasks, + resources: [], + assignments: [], + calendars, + }; +} + +const MON_JAN_5 = new Date(2026, 0, 5); +const SAT_JAN_10 = new Date(2026, 0, 10); + +describe("materialize — round-trip", () => { + test("resolve → currentSchedule → materialize → resolve gives same day indices", () => { + const project = makeProject([ + makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: SAT_JAN_10, + duration: Duration.from(5, TimeUnit.Days), + }), + ]); + const resolved1 = resolveCalendar(project); + const sched1 = currentSchedule(resolved1); + const out = materialize(sched1); + const resolved2 = resolveCalendar(out); + const sched2 = currentSchedule(resolved2); + expect(sched2.tasks[0]!.startDay).toBe(sched1.tasks[0]!.startDay); + expect(sched2.tasks[0]!.finishDay).toBe(sched1.tasks[0]!.finishDay); + expect(resolved2.tasks[0]!.durationDays).toBe(resolved1.tasks[0]!.durationDays); + }); + + test("MSPDI Fri 17:00 finish round-trips through midnight normalization", () => { + const friAt17 = new Date(2026, 0, 9, 17, 0, 0); + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: friAt17 })]); + const out = materialize(currentSchedule(resolveCalendar(project))); + expect(out.tasks[0]!.finish).toEqual(new Date(2026, 0, 10)); + expect(currentSchedule(resolveCalendar(out)).tasks[0]!.finishDay).toBe(5); + }); +}); + +describe("materialize — field preservation", () => { + test("notes and baselineStart pass through unchanged", () => { + const baseline = new Date(2026, 0, 5); + const project = makeProject([ + makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: SAT_JAN_10, + notes: "do not lose me", + baselineStart: baseline, + }), + ]); + const out = materialize(currentSchedule(resolveCalendar(project))); + expect(out.tasks[0]!.notes).toBe("do not lose me"); + expect(out.tasks[0]!.baselineStart).toEqual(baseline); + }); + + test("source-level resources/assignments/calendars/properties survive", () => { + const project: ProjectFile = { + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), + resources: [ + { + id: 1, + uniqueId: 1, + name: "Crew A", + type: ResourceType.Work, + email: null, + group: null, + maxUnits: null, + cost: null, + work: null, + resourcePool: null, + }, + ], + properties: { + title: "Authoritative title", + author: "RF", + startDate: null, + finishDate: null, + statusDate: null, + defaultCalendarUniqueId: 1, + minutesPerDay: 480, + minutesPerWeek: 2400, + daysPerMonth: 20, + saveVersion: 14, + }, + }; + const out = materialize(currentSchedule(resolveCalendar(project))); + expect(out.resources).toHaveLength(1); + expect(out.resources[0]!.name).toBe("Crew A"); + expect(out.properties.title).toBe("Authoritative title"); + expect(out.properties.author).toBe("RF"); + expect(out.properties.saveVersion).toBe(14); + expect(out.calendars).toHaveLength(1); + }); + + test("tasks not in schedule pass through unchanged", () => { + const project = makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]); + const resolved = resolveCalendar(project); + const partial: Schedule = { + resolved, + tasks: [{ uniqueId: 1, startDay: 7, finishDay: 12, modeId: null }], + makespan: 12, + annotations: new Map(), + }; + const out = materialize(partial); + expect(out.tasks[0]!.start).toEqual(new Date(2026, 0, 12)); // shifted + expect(out.tasks[1]!.start).toEqual(MON_JAN_5); // untouched + expect(out.tasks[1]!.finish).toEqual(SAT_JAN_10); + }); +}); + +describe("materialize — duration update across units", () => { + test("Days-unit duration mirrors working-day count", () => { + const project = makeProject([ + makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: SAT_JAN_10, + duration: Duration.from(5, TimeUnit.Days), + }), + ]); + const out = materialize(currentSchedule(resolveCalendar(project))); + expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Days); + expect(out.tasks[0]!.duration?.value).toBe(5); + }); + + test("Hours-unit duration scales by minutesPerDay", () => { + const project = makeProject([ + makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: SAT_JAN_10, + duration: Duration.from(40, TimeUnit.Hours), + }), + ]); + const out = materialize(currentSchedule(resolveCalendar(project))); + expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Hours); + expect(out.tasks[0]!.duration?.value).toBe(40); // 5 days × 480 min / 60 + }); + + test("Weeks-unit duration uses minutesPerWeek/minutesPerDay", () => { + const project = makeProject([ + makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: SAT_JAN_10, + duration: Duration.from(1, TimeUnit.Weeks), + }), + ]); + const out = materialize(currentSchedule(resolveCalendar(project))); + expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Weeks); + expect(out.tasks[0]!.duration?.value).toBe(1); + }); + + test("null source duration emits Days-unit by default", () => { + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const out = materialize(currentSchedule(resolveCalendar(project))); + expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Days); + expect(out.tasks[0]!.duration?.value).toBe(5); + }); + + test("non-working exception in window keeps duration aligned to working-day count", () => { + const wedJan7 = new Date(2026, 0, 7); + const project = makeProject( + [ + makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: new Date(2026, 0, 13), + duration: Duration.from(5, TimeUnit.Days), + }), + ], + [ + monFriCalendar(1, [ + { name: "Holiday", fromDate: wedJan7, toDate: wedJan7, working: false }, + ]), + ], + ); + const out = materialize(currentSchedule(resolveCalendar(project))); + expect(out.tasks[0]!.duration?.value).toBe(5); + }); +}); + +describe("materialize — schedule shifts", () => { + test("shifting startDay by 7 cal days updates finish dates correctly", () => { + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const resolved = resolveCalendar(project); + const seed = currentSchedule(resolved).tasks[0]!; + const shifted: Schedule = { + resolved, + tasks: [ + { uniqueId: 1, startDay: seed.startDay + 7, finishDay: seed.finishDay + 7, modeId: null }, + ], + makespan: seed.finishDay + 7, + annotations: new Map(), + }; + const out = materialize(shifted); + expect(out.tasks[0]!.start).toEqual(new Date(2026, 0, 12)); + expect(out.tasks[0]!.finish).toEqual(new Date(2026, 0, 17)); + }); +}); + +describe("materialize — error paths", () => { + test("mode-change task throws (not yet supported)", () => { + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const resolved = resolveCalendar(project); + const sched: Schedule = { + resolved, + tasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: 2 }], + makespan: 5, + annotations: new Map(), + }; + expect(() => materialize(sched)).toThrow(/mode-change/); + }); +}); -- 2.54.0