Something went wrong. Try again.
A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
Something went wrong. Try again.
12 kB · 391 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392// Project ──resolveCalendar──> ResolvedProject.// Pure: in goes the user-facing `ProjectFile` with Date objects, out comes// the day-indexed working value the rest of the pipeline operates on.
import type { Calendar } from "../model/Calendar.ts";import type { 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 { addCalendarDays, buildWorkingCalendar, endOfLocalDayExclusive, startOfLocalDay,} from "./calendarDays.ts";import type { PrecedenceEdge, ResolveOptions, ResolvedAssignment, ResolvedProject, ResolvedResource, ResolvedTask, WorkUnit, WorkingCalendar,} from "./types.ts";
const HORIZON_BUFFER_DAYS = 90;const MS_PER_DAY = 86_400_000;const TEN_YEARS_DAYS = 365 * 10;const SYNTHETIC_CAL_ID = -1;
function syntheticMonFriCalendar(): Calendar { return { uniqueId: SYNTHETIC_CAL_ID, name: "Synthetic Mon–Fri", weekDays: [ { dayType: 1, working: false, workingTimes: [] }, // Sun { 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: [] }, // Sat ], exceptions: [], };}
/** opts.epoch ?? properties.statusDate ?? min(task.start). Throws if none. */function pickEpoch(project: ProjectFile, opts: ResolveOptions): Date { if (opts.epoch) return startOfLocalDay(opts.epoch); if (project.properties.statusDate) return startOfLocalDay(project.properties.statusDate); let earliest: Date | null = project.properties.startDate ? startOfLocalDay(project.properties.startDate) : null; for (const t of project.tasks) { if (t.start) { const s = startOfLocalDay(t.start); if (!earliest || s.getTime() < earliest.getTime()) earliest = s; } } if (!earliest) { throw new Error( "resolveCalendar: cannot pick epoch — no opts.epoch, statusDate, startDate, or task starts available", ); } return earliest;}
function pickHorizon( project: ProjectFile, epoch: Date, opts: ResolveOptions,): number { if (opts.horizonDays !== undefined) { if (opts.horizonDays < 0) { throw new Error( `resolveCalendar: horizonDays must be >= 0, got ${String(opts.horizonDays)}`, ); } return opts.horizonDays; } let latest: Date | null = project.properties.finishDate ? endOfLocalDayExclusive(project.properties.finishDate) : null; for (const t of project.tasks) { if (t.finish) { const f = endOfLocalDayExclusive(t.finish); if (!latest || f.getTime() > latest.getTime()) latest = f; } } if (!latest) return 365 + HORIZON_BUFFER_DAYS; const span = Math.max( 0, Math.round((latest.getTime() - epoch.getTime()) / MS_PER_DAY), ); const scaled = Math.ceil(span * 1.25) + HORIZON_BUFFER_DAYS; return Math.min(TEN_YEARS_DAYS, scaled);}
// Working-days-per-week derived from properties so non-Mon–Fri calendars// are handled; the `5` below is only a degenerate fallback.function workingDaysPerWeek(properties: ProjectProperties): number { const wpd = properties.minutesPerDay; const wpw = properties.minutesPerWeek; if (wpd <= 0 || wpw <= 0) return 5; return wpw / wpd;}
// Lag → working days. MSPDI doesn't distinguish elapsed vs. working lag, so we// pick the working-day interpretation (matches MS Project default when no// `e`-prefix is present). Elapsed-lag support is a future addition.function lagToWorkingDays( lag: Duration | null, properties: ProjectProperties,): number { if (!lag) return 0; const minutesPerDay = properties.minutesPerDay; const wpw = workingDaysPerWeek(properties); const dpm = properties.daysPerMonth; switch (lag.unit) { case TimeUnit.Days: return Math.round(lag.value); case TimeUnit.Weeks: return Math.round(lag.value * wpw); case TimeUnit.Hours: return Math.round((lag.value * 60) / minutesPerDay); case TimeUnit.Minutes: return Math.round(lag.value / minutesPerDay); case TimeUnit.Months: return Math.round(lag.value * dpm); case TimeUnit.Percent: return 0; }}
function durationToWorkingDays( dur: Duration | null, cal: WorkingCalendar, startDay: number, properties: ProjectProperties,): number { if (!dur) return 0; const minutesPerDay = properties.minutesPerDay; const wpw = workingDaysPerWeek(properties); const dpm = properties.daysPerMonth; switch (dur.unit) { case TimeUnit.Days: return Math.max(0, Math.round(dur.value)); case TimeUnit.Weeks: return Math.max(0, Math.round(dur.value * wpw)); case TimeUnit.Hours: return Math.max(0, Math.round((dur.value * 60) / minutesPerDay)); case TimeUnit.Minutes: return Math.max(0, Math.round(dur.value / minutesPerDay)); case TimeUnit.Months: return Math.max(0, Math.round(dur.value * dpm)); case TimeUnit.Percent: // Without a known reference, treat as zero. The pipeline can add a CPM // stage later if percent durations need to be resolved. void cal; void startDay; return 0; }}
function calendarDayOffset(d: Date, epoch: Date): number { const dStart = startOfLocalDay(d).getTime(); const eStart = epoch.getTime(); return Math.round((dStart - eStart) / MS_PER_DAY);}
function countWorkingDaysInRange( cal: WorkingCalendar, fromDay: number, toDay: number,): 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]!;}
function resolveTask( task: Task, epoch: Date, cal: WorkingCalendar, properties: ProjectProperties,): ResolvedTask { if (task.uniqueId === null) { throw new Error("resolveCalendar: task missing uniqueId"); } if (!task.start || !task.finish) { throw new Error( `resolveCalendar: task ${String(task.uniqueId)} missing start or finish`, ); } const startDay = calendarDayOffset(task.start, epoch); const finishDay = calendarDayOffset( endOfLocalDayExclusive(task.finish), epoch, ); const durationDays = task.duration !== null ? durationToWorkingDays(task.duration, cal, startDay, properties) : countWorkingDaysInRange(cal, startDay, finishDay); return { uniqueId: task.uniqueId, name: task.name, durationDays, outlineLevel: task.outlineLevel, summary: task.summary ?? false, milestone: task.milestone ?? false, // Per-task calendar override would land here once the schema carries it. calendarUniqueId: null, };}
function resolveEdges( tasks: readonly Task[], properties: ProjectProperties,): PrecedenceEdge[] { const edges: PrecedenceEdge[] = []; for (const t of tasks) { if (t.uniqueId === null) continue; for (const r of t.predecessors) { if (r.predecessorUniqueId === null) continue; edges.push({ predecessorUniqueId: r.predecessorUniqueId, successorUniqueId: t.uniqueId, type: r.type, lagDays: lagToWorkingDays(r.lag, properties), }); } } return edges;}
function resolveAssignments(project: ProjectFile): ResolvedAssignment[] { const out: ResolvedAssignment[] = []; for (const a of project.assignments) { if (a.taskUniqueId === null || a.resourceUniqueId === null) continue; out.push({ taskUniqueId: a.taskUniqueId, resourceUniqueId: a.resourceUniqueId, units: a.units ?? 1, }); } return out;}
// capacityPerDay = (resource.maxUnits ?? 1) * (minutesPerDay / 60).function resolveResources(project: ProjectFile): ResolvedResource[] { const minutesPerDay = project.properties.minutesPerDay; const out: ResolvedResource[] = []; for (const r of project.resources) { if (r.uniqueId === null) continue; const maxUnits = r.maxUnits ?? 1; out.push({ uniqueId: r.uniqueId, capacityPerDay: maxUnits * (minutesPerDay / 60), // Per-resource calendar override would land here once the schema carries it. calendarUniqueId: null, }); } return out;}
function validateTasks(project: ProjectFile): void { for (const t of project.tasks) { if (t.uniqueId === null) { throw new Error("resolveCalendar: task missing uniqueId"); } if (!t.start || !t.finish) { throw new Error( `resolveCalendar: task ${String(t.uniqueId)} missing start or finish`, ); } }}
/** Build a WorkingCalendar per distinct calendarUniqueId referenced by * the project, plus any synthetic fallback. */function buildCalendarMap( project: ProjectFile, defaultCalendar: Calendar, defaultCalendarUniqueId: number | null, epoch: Date, horizonDays: number,): Map<number, WorkingCalendar> { const map = new Map<number, WorkingCalendar>(); // Always include the default. map.set( defaultCalendarUniqueId ?? SYNTHETIC_CAL_ID, buildWorkingCalendar( defaultCalendar, defaultCalendarUniqueId, epoch, horizonDays, ), ); // Any other named calendar with a uniqueId. for (const c of project.calendars) { if (c.uniqueId === null) continue; if (map.has(c.uniqueId)) continue; map.set( c.uniqueId, buildWorkingCalendar(c, c.uniqueId, epoch, horizonDays), ); } return map;}
/** Canonicalize WorkUnits into a by-id registry, the single owner of unit * definitions. Rejects duplicate ids so a unit id maps to exactly one * definition across every consumer (JS scorers and the MiniZinc backend). */export function resolveWorkUnits( units: readonly WorkUnit[] = [],): ReadonlyMap<number, WorkUnit> { const map = new Map<number, WorkUnit>(); for (const u of units) { if (map.has(u.id)) { throw new Error( `resolveWorkUnits: duplicate WorkUnit id ${String(u.id)}`, ); } map.set(u.id, u); } return map;}
function pickDefaultCalendar(project: ProjectFile): { calendar: Calendar; uniqueId: number | null;} { const id = project.properties.defaultCalendarUniqueId; if (id !== null) { const found = project.calendars.find((c) => c.uniqueId === id); if (found) return { calendar: found, uniqueId: found.uniqueId }; } const first = project.calendars[0]; if (first) return { calendar: first, uniqueId: first.uniqueId }; return { calendar: syntheticMonFriCalendar(), uniqueId: null };}
export function resolveCalendar( project: ProjectFile, opts: ResolveOptions = {},): ResolvedProject { validateTasks(project); const epoch = pickEpoch(project, opts); const horizonDays = pickHorizon(project, epoch, opts); const { calendar: defaultCalendar, uniqueId: defaultCalendarUniqueId } = pickDefaultCalendar(project); const calendars = buildCalendarMap( project, defaultCalendar, defaultCalendarUniqueId, epoch, horizonDays, ); // The default calendar drives task/edge resolution. Per-task overrides // would consult `calendars.get(task.calendarUniqueId)` here once the // schema carries that field. const defaultId = defaultCalendarUniqueId ?? SYNTHETIC_CAL_ID; const defaultWorking = calendars.get(defaultId)!; const tasks = project.tasks.map((t) => resolveTask(t, epoch, defaultWorking, project.properties), ); const precedences = resolveEdges(project.tasks, project.properties); const assignments = resolveAssignments(project); const resources = resolveResources(project); const workUnits = resolveWorkUnits(opts.workUnits); return { source: project, defaultCalendarUniqueId: defaultCalendarUniqueId, calendars, tasks, resources, assignments, precedences, workUnits, };}
// Unused-but-exported epoch arithmetic kept for symmetry with calendarDays —// resolveCalendar uses these via calendarDays helpers, but downstream stages// often need the offset too.export { addCalendarDays, calendarDayOffset };