@critical-path/core 0.43.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -355,7 +355,7 @@ const leveled = await engine.calculateCriticalPath('proj_123', { calendars: 'ass
355
355
  console.log(leveled.unleveledProjectEndDate, '->', leveled.projectEndDate);
356
356
  ```
357
357
 
358
- With `calendars: 'assignee'` (or `criticalPathCalendars: 'assignee'` on the engine), each task uses its assignee's schedule, then its team's, then the project calendar. Passes run on dates, slack is measured in each task's calendar, and each calendar is evaluated in its own `timezone` (IANA name; default UTC), including daylight-saving changes. `levelResources: true` additionally delays tasks so nobody works on two at once (priority rule `levelingPriority`, default least slack), reporting `levelingDelayHours` and `waitingOn` per task. Tasks with `allocation` below 1 take that share of their assignee (a 16h task at 0.5 spans 32 working hours); levelling fits them within 100% per person, and `overallocations` lists who is booked above 100% and when.
358
+ With `calendars: 'assignee'` (or `criticalPathCalendars: 'assignee'` on the engine), each task uses its assignee's schedule, then its team's, then the project calendar. Passes run on dates, slack is measured in each task's calendar, and each calendar is evaluated in its own `timezone` (IANA name; default UTC), including daylight-saving changes. `levelResources: true` additionally delays tasks so nobody works on two at once (priority rule `levelingPriority`, default least slack), reporting `levelingDelayHours` and `waitingOn` per task. Tasks with `allocation` below 1 take that share of their assignee (a 16h task at 0.5 spans 32 working hours); levelling fits them within 100% per person, and `overallocations` lists who is booked above 100% and when. Teams act as pools of `headcount` people (default: member count) shared by team-only tasks and members' own tasks. Finished tasks take no time. `calculatePortfolioCriticalPath({ projectIds })` levels several projects together, sharing people and teams across them.
359
359
 
360
360
  ### 9. Fractional Lexical Indexing (Kanban Reordering)
361
361
 
@@ -1,9 +1,16 @@
1
- import type { Task, TaskDependency, CriticalPathAnalysis, WorkSchedule, User, Team } from '../types/index.js';
1
+ import type { Task, TaskDependency, CriticalPathAnalysis, WorkSchedule, User, Team, PortfolioCriticalPathAnalysis } from '../types/index.js';
2
2
  export declare function getTaskDurationHours(task: Task): number;
3
3
  /** The task's `allocation`, clamped to (0, 1]; 1 when unset or invalid. */
4
4
  export declare function getTaskAllocation(task: Task): number;
5
5
  /** Working hours a task spans: its effort divided by its allocation. */
6
6
  export declare function getTaskElapsedHours(task: Task): number;
7
+ /** True for completed or canceled tasks (by `semanticStatus`, else the default `done`/`canceled` statuses). */
8
+ export declare function isTaskFinished(task: Task): boolean;
9
+ /**
10
+ * Working hours critical path analysis schedules for a task: 0 when it is finished (it needs no
11
+ * more time and occupies nobody), otherwise its elapsed hours.
12
+ */
13
+ export declare function getTaskScheduledHours(task: Task): number;
7
14
  export interface CPMOptions {
8
15
  projectStartDate?: string | Date;
9
16
  /** The project calendar. Default: `DEFAULT_WORK_SCHEDULE`. */
@@ -39,6 +46,26 @@ export declare function resolveTaskSchedule(task: Task, context: {
39
46
  teams?: Team[];
40
47
  schedule?: WorkSchedule;
41
48
  }): WorkSchedule;
49
+ export interface PortfolioProjectInput {
50
+ projectId: string;
51
+ tasks: Task[];
52
+ /** The project's start. Default: today (UTC midnight). */
53
+ projectStartDate?: string | Date;
54
+ /** The project calendar. Default: `DEFAULT_WORK_SCHEDULE`. */
55
+ schedule?: WorkSchedule;
56
+ }
57
+ export interface PortfolioCPMOptions extends Omit<CPMOptions, 'projectStartDate' | 'schedule'> {
58
+ /** Project ids in priority order; when levelling, their tasks get people first. Unlisted projects come last. */
59
+ projectOrder?: string[];
60
+ }
61
+ /**
62
+ * Critical path analysis over several projects at once. Always date-based. Each task starts no
63
+ * earlier than its own project's start and is due by its project's end. Dependencies between tasks
64
+ * in different projects are honoured. With `levelResources` (requires `calendars: 'assignee'`),
65
+ * assignees and team pools are shared across all projects, so nobody is booked above capacity in
66
+ * total. Returns one analysis per project, the latest end, and the combined over-allocation report.
67
+ */
68
+ export declare function calculatePortfolioCPM(projects: PortfolioProjectInput[], dependencies: TaskDependency[], options?: PortfolioCPMOptions): PortfolioCriticalPathAnalysis;
42
69
  /**
43
70
  * Calculates the Critical Path Method (CPM) schedule for a set of tasks and their dependencies.
44
71
  * - Forward Pass: computes earlyStart and earlyFinish (in working hours and calendar dates)
@@ -1 +1 @@
1
- {"version":3,"file":"cpm.d.ts","sourceRoot":"","sources":["../../src/domain/cpm.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EACJ,cAAc,EAEd,oBAAoB,EACpB,YAAY,EACZ,IAAI,EACJ,IAAI,EAEL,MAAM,mBAAmB,CAAC;AAW3B,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAYvD;AAED,2EAA2E;AAC3E,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAGpD;AAED,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAEtD;AAED,MAAM,WAAW,UAAU;IACzB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,GAAG,UAAU,CAAC;IACnC,iFAAiF;IACjF,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,kFAAkF;IAClF,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;CACrC;AAED,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,OAAO,CAAC;AAE1E,4FAA4F;AAC5F,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE;IAAE,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,YAAY,CAAA;CAAE,GACnE,YAAY,CAOd;AAybD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,IAAI,EAAE,EACb,YAAY,EAAE,cAAc,EAAE,EAC9B,OAAO,GAAE,UAAe,GACvB,oBAAoB,CA4JtB"}
1
+ {"version":3,"file":"cpm.d.ts","sourceRoot":"","sources":["../../src/domain/cpm.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EACJ,cAAc,EAEd,oBAAoB,EACpB,YAAY,EACZ,IAAI,EACJ,IAAI,EAEJ,6BAA6B,EAC9B,MAAM,mBAAmB,CAAC;AAW3B,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAYvD;AAED,2EAA2E;AAC3E,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAGpD;AAED,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAEtD;AAED,+GAA+G;AAC/G,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAGlD;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAExD;AAED,MAAM,WAAW,UAAU;IACzB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,GAAG,UAAU,CAAC;IACnC,iFAAiF;IACjF,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,kFAAkF;IAClF,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;CACrC;AAED,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,OAAO,CAAC;AAE1E,4FAA4F;AAC5F,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE;IAAE,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,YAAY,CAAA;CAAE,GACnE,YAAY,CAOd;AAmjBD,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,YAAY,CAAC;CACzB;AAED,MAAM,WAAW,mBAAoB,SAAQ,IAAI,CAAC,UAAU,EAAE,kBAAkB,GAAG,UAAU,CAAC;IAC5F,gHAAgH;IAChH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,qBAAqB,EAAE,EACjC,YAAY,EAAE,cAAc,EAAE,EAC9B,OAAO,GAAE,mBAAwB,GAChC,6BAA6B,CAqB/B;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,IAAI,EAAE,EACb,YAAY,EAAE,cAAc,EAAE,EAC9B,OAAO,GAAE,UAAe,GACvB,oBAAoB,CA4JtB"}
@@ -21,6 +21,19 @@ export function getTaskAllocation(task) {
21
21
  export function getTaskElapsedHours(task) {
22
22
  return getTaskDurationHours(task) / getTaskAllocation(task);
23
23
  }
24
+ /** True for completed or canceled tasks (by `semanticStatus`, else the default `done`/`canceled` statuses). */
25
+ export function isTaskFinished(task) {
26
+ if (task.semanticStatus)
27
+ return task.semanticStatus === 'completed' || task.semanticStatus === 'canceled';
28
+ return task.status === 'done' || task.status === 'canceled';
29
+ }
30
+ /**
31
+ * Working hours critical path analysis schedules for a task: 0 when it is finished (it needs no
32
+ * more time and occupies nobody), otherwise its elapsed hours.
33
+ */
34
+ export function getTaskScheduledHours(task) {
35
+ return isTaskFinished(task) ? 0 : getTaskElapsedHours(task);
36
+ }
24
37
  /** The calendar a task runs on in assignee mode: assignee, then task team, then project. */
25
38
  export function resolveTaskSchedule(task, context) {
26
39
  return ((task.assigneeId && context.users?.find((u) => u.id === task.assigneeId)?.schedule) ||
@@ -105,39 +118,80 @@ function buildGraph(tasks, dependencies) {
105
118
  * the project start. With `levelResources`, see `levelSchedule`.
106
119
  */
107
120
  function calculateCalendarCPM(projectId, calculatedAt, graph, options) {
108
- const { taskMap, taskIds, prerequisites, successors, topoOrder } = graph;
109
- const projectSchedule = options.schedule || DEFAULT_WORK_SCHEDULE;
121
+ const segment = {
122
+ projectId,
123
+ start: startOrToday(options.projectStartDate),
124
+ schedule: options.schedule || DEFAULT_WORK_SCHEDULE,
125
+ taskIds: graph.taskIds
126
+ };
127
+ const result = scheduleOnDates(graph, [segment], options, calculatedAt);
128
+ return result.projects[0];
129
+ }
130
+ function startOrToday(input) {
131
+ if (input)
132
+ return parseDate(input);
110
133
  const today = new Date();
111
- const projectStart = options.projectStartDate
112
- ? parseDate(options.projectStartDate)
113
- : new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
114
- const calendarOf = new Map(taskIds.map((id) => [id, resolveTaskSchedule(taskMap.get(id), options)]));
115
- const durationOf = new Map(taskIds.map((id) => [id, getTaskElapsedHours(taskMap.get(id))]));
116
- const ctx = { taskMap, calendarOf, durationOf, projectStart };
134
+ return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
135
+ }
136
+ /**
137
+ * Date-based passes over one or more projects at once. Each task starts no earlier than its
138
+ * project's start and is due by its project's end; dependencies, assignees and team pools may span
139
+ * projects. Returns one analysis per project plus the combined over-allocation report.
140
+ */
141
+ function scheduleOnDates(graph, segments, options, calculatedAt) {
142
+ const { taskMap, taskIds, prerequisites, successors, topoOrder } = graph;
143
+ const segmentOf = new Map(segments.flatMap((seg) => seg.taskIds.map((id) => [id, seg])));
144
+ const calendarOf = new Map(taskIds.map((id) => {
145
+ const seg = segmentOf.get(id);
146
+ const task = taskMap.get(id);
147
+ return [id, options.calendars === 'assignee' ? resolveTaskSchedule(task, { ...options, schedule: seg.schedule }) : seg.schedule];
148
+ }));
149
+ const durationOf = new Map(taskIds.map((id) => [id, getTaskScheduledHours(taskMap.get(id))]));
150
+ const ctx = { taskMap, calendarOf, durationOf, startOf: (id) => segmentOf.get(id).start };
151
+ const endOfProjects = (dates) => {
152
+ const ends = new Map(segments.map((seg) => [seg.projectId, latest(seg.taskIds.map((id) => dates.finish.get(id)), seg.start)]));
153
+ return (id) => ends.get(segmentOf.get(id).projectId);
154
+ };
155
+ const resourcesOf = resourceModel(options.teams);
117
156
  const early = forwardPass(topoOrder, prerequisites, ctx);
118
- const late = backwardPass([...topoOrder].reverse(), (id) => successors.get(id), latest(early.finish, projectStart), ctx);
157
+ const late = backwardPass([...topoOrder].reverse(), (id) => successors.get(id), endOfProjects(early), ctx);
158
+ const analyse = (seg, e, l, extra) => buildAnalysis(seg, calculatedAt, e, l, ctx, extra);
119
159
  if (!options.levelResources) {
120
- const analysis = buildAnalysis(projectId, calculatedAt, taskIds, early, late, projectSchedule, ctx);
121
- return { ...analysis, overallocations: findOverallocations(taskMap, early) };
160
+ const overallocations = findOverallocations(taskMap, early, resourcesOf);
161
+ return {
162
+ projects: segments.map((seg) => ({ ...analyse(seg, early, late), overallocations: involving(overallocations, seg) })),
163
+ overallocations
164
+ };
122
165
  }
123
166
  const unleveledSlack = new Map(taskIds.map((id) => [id, slackHours(early.finish.get(id), late.finish.get(id), calendarOf.get(id))]));
124
- const leveled = levelSchedule(graph, ctx, unleveledSlack, options.levelingPriority ?? 'slack');
125
- const leveledLate = backwardPass(leveled.backwardOrder, (id) => [...successors.get(id), ...(leveled.resourceSuccessors.get(id) ?? [])], latest(leveled.early.finish, projectStart), ctx);
126
- const analysis = buildAnalysis(projectId, calculatedAt, taskIds, leveled.early, leveledLate, projectSchedule, ctx, (id) => {
127
- const delay = round2(getWorkingHoursBetween(early.start.get(id), leveled.early.start.get(id), calendarOf.get(id)));
128
- const waitingOn = leveled.waitingOn.get(id);
129
- return { levelingDelayHours: delay, ...(waitingOn ? { waitingOn } : {}) };
130
- });
167
+ const projectRank = new Map((options.projectOrder ?? []).map((pid, i) => [pid, i]));
168
+ const rankOf = (id) => projectRank.get(segmentOf.get(id).projectId) ?? projectRank.size;
169
+ const leveled = levelSchedule(graph, ctx, unleveledSlack, options.levelingPriority ?? 'slack', resourcesOf, rankOf);
170
+ const leveledLate = backwardPass(leveled.backwardOrder, (id) => [...successors.get(id), ...(leveled.resourceSuccessors.get(id) ?? [])], endOfProjects(leveled.early), ctx);
171
+ const overallocations = findOverallocations(taskMap, leveled.early, resourcesOf);
172
+ const unleveledEnd = endOfProjects(early);
131
173
  return {
132
- ...analysis,
133
- leveled: true,
134
- unleveledProjectEndDate: latest(early.finish, projectStart).toISOString(),
135
- overallocations: findOverallocations(taskMap, leveled.early)
174
+ projects: segments.map((seg) => ({
175
+ ...analyse(seg, leveled.early, leveledLate, (id) => {
176
+ const delay = round2(getWorkingHoursBetween(early.start.get(id), leveled.early.start.get(id), calendarOf.get(id)));
177
+ const waitingOn = leveled.waitingOn.get(id);
178
+ return { levelingDelayHours: delay, ...(waitingOn ? { waitingOn } : {}) };
179
+ }),
180
+ leveled: true,
181
+ unleveledProjectEndDate: (seg.taskIds.length ? unleveledEnd(seg.taskIds[0]) : seg.start).toISOString(),
182
+ overallocations: involving(overallocations, seg)
183
+ })),
184
+ overallocations
136
185
  };
137
186
  }
187
+ /** Over-allocation entries that include at least one of the project's tasks. */
188
+ function involving(overallocations, seg) {
189
+ const ids = new Set(seg.taskIds);
190
+ return overallocations.filter((o) => o.taskIds.some((id) => ids.has(id)));
191
+ }
138
192
  function latest(dates, floor) {
139
193
  let max = floor;
140
- for (const d of dates.values())
194
+ for (const d of dates)
141
195
  if (d > max)
142
196
  max = d;
143
197
  return max;
@@ -152,7 +206,7 @@ function forwardPass(order, prerequisites, ctx) {
152
206
  const start = new Map();
153
207
  const finish = new Map();
154
208
  for (const id of order) {
155
- let ready = ctx.projectStart;
209
+ let ready = ctx.startOf(id);
156
210
  for (const p of prerequisites.get(id)) {
157
211
  const f = finish.get(p);
158
212
  if (f && f > ready)
@@ -166,11 +220,11 @@ function forwardPass(order, prerequisites, ctx) {
166
220
  return { start, finish };
167
221
  }
168
222
  /** Latest dates; `order` must list every successor before its predecessors. */
169
- function backwardPass(order, successorsOf, projectEnd, ctx) {
223
+ function backwardPass(order, successorsOf, endOf, ctx) {
170
224
  const start = new Map();
171
225
  const finish = new Map();
172
226
  for (const id of order) {
173
- let due = projectEnd;
227
+ let due = endOf(id);
174
228
  for (const s of successorsOf(id)) {
175
229
  const ls = start.get(s);
176
230
  if (ls && ls < due)
@@ -187,15 +241,16 @@ const PRIORITY_RANK = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 };
187
241
  /**
188
242
  * Resource levelling with a serial schedule-generation scheme: repeatedly take the eligible task
189
243
  * (all predecessors placed) that ranks first under `rule`, and place it at the earliest time its
190
- * predecessors are finished and its assignee has `allocation` spare for its whole span on their
191
- * calendar. Each assignee's bookings total at most 1 at any moment; tasks are not split.
192
- * Unassigned tasks and zero-duration milestones do not occupy anyone.
244
+ * predecessors are finished and every resource it uses (see `resourceModel`) has `allocation`
245
+ * spare for its whole span on the task's calendar. Tasks are not split. Tasks without resources
246
+ * and zero-duration milestones do not occupy anything.
193
247
  *
194
248
  * Returns the levelled early dates, which task each delayed task waited for, and resource links
195
- * (`resourceSuccessors`): for every two tasks of one assignee that cannot overlap (allocations
196
- * sum above 1), the earlier one precedes the later one, so the backward pass keeps them apart.
249
+ * (`resourceSuccessors`) so the backward pass keeps the levelling: for every two tasks on one
250
+ * resource whose allocations exceed its capacity the earlier precedes the later, and each delayed
251
+ * task follows the task whose finish freed its capacity.
197
252
  */
198
- function levelSchedule({ taskMap, taskIds, prerequisites, successors, topoOrder }, ctx, unleveledSlack, rule) {
253
+ function levelSchedule({ taskMap, taskIds, prerequisites, successors, topoOrder }, ctx, unleveledSlack, rule, resourcesOf, projectRankOf = () => 0) {
199
254
  const creation = new Map([...taskIds]
200
255
  .sort((a, b) => (taskMap.get(a).createdAt ?? '').localeCompare(taskMap.get(b).createdAt ?? '') || a.localeCompare(b))
201
256
  .map((id, i) => [id, i]));
@@ -210,18 +265,27 @@ function levelSchedule({ taskMap, taskIds, prerequisites, successors, topoOrder
210
265
  dueDate: (id) => [due(id), unleveledSlack.get(id), creation.get(id)],
211
266
  order: (id) => [creation.get(id)]
212
267
  };
213
- const compare = (a, b) => {
214
- const ka = keys[rule](a);
215
- const kb = keys[rule](b);
268
+ // Keys never change, so compute them once; picking the next task is then a linear scan.
269
+ const keyOf = new Map(taskIds.map((id) => [id, [projectRankOf(id), ...keys[rule](id)]]));
270
+ const before = (a, b) => {
271
+ const [ka, kb] = [keyOf.get(a), keyOf.get(b)];
216
272
  for (let i = 0; i < ka.length; i++)
217
273
  if (ka[i] !== kb[i])
218
- return ka[i] - kb[i];
219
- return 0;
274
+ return ka[i] < kb[i];
275
+ return false;
276
+ };
277
+ const first = (ids) => {
278
+ let best;
279
+ for (const id of ids)
280
+ if (best === undefined || before(id, best))
281
+ best = id;
282
+ return best;
220
283
  };
221
284
  const start = new Map();
222
285
  const finish = new Map();
223
286
  const waitingOn = new Map();
224
287
  const busy = new Map();
288
+ const capacityOf = new Map();
225
289
  const placed = [];
226
290
  const remaining = new Map(taskIds.map((id) => [id, prerequisites.get(id).size]));
227
291
  const eligible = new Set(taskIds.filter((id) => remaining.get(id) === 0));
@@ -229,9 +293,9 @@ function levelSchedule({ taskMap, taskIds, prerequisites, successors, topoOrder
229
293
  // A dependency cycle leaves tasks that never become eligible; take them in topological order.
230
294
  if (eligible.size === 0)
231
295
  eligible.add(topoOrder.find((id) => !start.has(id)));
232
- const id = [...eligible].sort(compare)[0];
296
+ const id = first(eligible);
233
297
  eligible.delete(id);
234
- let ready = ctx.projectStart;
298
+ let ready = ctx.startOf(id);
235
299
  for (const p of prerequisites.get(id)) {
236
300
  const f = finish.get(p);
237
301
  if (f && f > ready)
@@ -239,13 +303,17 @@ function levelSchedule({ taskMap, taskIds, prerequisites, successors, topoOrder
239
303
  }
240
304
  const calendar = ctx.calendarOf.get(id);
241
305
  const duration = ctx.durationOf.get(id);
242
- const assignee = taskMap.get(id).assigneeId;
243
- const slots = assignee && duration > 0 ? busy.get(assignee) ?? [] : [];
306
+ const resources = duration > 0 ? resourcesOf(taskMap.get(id)) : [];
244
307
  const allocation = getTaskAllocation(taskMap.get(id));
245
308
  let es = nextWorkingTime(ready, calendar);
246
309
  let ef = addWorkingHours(es, duration, calendar);
247
310
  for (;;) {
248
- const release = firstCapacityRelease(slots, es.getTime(), ef.getTime(), allocation);
311
+ let release;
312
+ for (const resource of resources) {
313
+ release = firstCapacityRelease(busy.get(resource.key) ?? [], es.getTime(), ef.getTime(), allocation, resource.capacity);
314
+ if (release)
315
+ break;
316
+ }
249
317
  if (!release)
250
318
  break;
251
319
  waitingOn.set(id, release.taskId);
@@ -254,8 +322,9 @@ function levelSchedule({ taskMap, taskIds, prerequisites, successors, topoOrder
254
322
  }
255
323
  start.set(id, es);
256
324
  finish.set(id, ef);
257
- if (assignee && duration > 0) {
258
- busy.set(assignee, [...slots, { start: es.getTime(), end: ef.getTime(), allocation, taskId: id }]);
325
+ for (const resource of resources) {
326
+ busy.set(resource.key, [...(busy.get(resource.key) ?? []), { start: es.getTime(), end: ef.getTime(), allocation, taskId: id }]);
327
+ capacityOf.set(resource.key, resource.capacity);
259
328
  }
260
329
  placed.push(id);
261
330
  for (const s of successors.get(id)) {
@@ -266,29 +335,42 @@ function levelSchedule({ taskMap, taskIds, prerequisites, successors, topoOrder
266
335
  }
267
336
  }
268
337
  const resourceSuccessors = new Map();
269
- for (const slots of busy.values()) {
338
+ const link = (from, to) => {
339
+ if (from !== to)
340
+ resourceSuccessors.set(from, (resourceSuccessors.get(from) ?? new Set()).add(to));
341
+ };
342
+ for (const [key, slots] of busy) {
343
+ const capacity = capacityOf.get(key);
270
344
  const ordered = [...slots].sort((a, b) => a.start - b.start);
271
345
  for (let i = 0; i < ordered.length; i++) {
272
346
  for (let j = i + 1; j < ordered.length; j++) {
273
347
  const [u, v] = [ordered[i], ordered[j]];
274
- if (u.end <= v.start && u.allocation + v.allocation > 1 + EPSILON) {
275
- resourceSuccessors.set(u.taskId, [...(resourceSuccessors.get(u.taskId) ?? []), v.taskId]);
276
- }
348
+ if (u.end <= v.start && u.allocation + v.allocation > capacity + EPSILON)
349
+ link(u.taskId, v.taskId);
277
350
  }
278
351
  }
279
352
  }
353
+ // In pools a delayed task may not conflict pairwise with anything; it still follows the task
354
+ // whose finish freed its capacity.
355
+ for (const [taskId, blocker] of waitingOn)
356
+ link(blocker, taskId);
280
357
  // Every edge (dependency or resource chain) goes forward in levelled start time; ties are
281
358
  // zero-duration tasks, ordered by placement.
282
359
  const placement = new Map(placed.map((id, i) => [id, i]));
283
360
  const backwardOrder = [...taskIds].sort((a, b) => start.get(b).getTime() - start.get(a).getTime() || placement.get(b) - placement.get(a));
284
- return { early: { start, finish }, waitingOn, resourceSuccessors, backwardOrder };
361
+ return {
362
+ early: { start, finish },
363
+ waitingOn,
364
+ resourceSuccessors: new Map([...resourceSuccessors].map(([k, v]) => [k, [...v]])),
365
+ backwardOrder
366
+ };
285
367
  }
286
368
  const EPSILON = 1e-9;
287
369
  /**
288
- * If `allocation` does not fit alongside `bookings` somewhere in [start, end), returns the booking
370
+ * If `allocation` does not fit alongside `bookings` within `capacity` somewhere in [start, end), returns the booking
289
371
  * whose end frees enough capacity soonest after the first overloaded moment; otherwise undefined.
290
372
  */
291
- function firstCapacityRelease(bookings, start, end, allocation) {
373
+ function firstCapacityRelease(bookings, start, end, allocation, capacity) {
292
374
  const overlapping = bookings.filter((b) => b.start < end && b.end > start);
293
375
  if (overlapping.length === 0)
294
376
  return undefined;
@@ -297,51 +379,81 @@ function firstCapacityRelease(bookings, start, end, allocation) {
297
379
  for (const t of points) {
298
380
  const active = overlapping.filter((b) => b.start <= t && b.end > t);
299
381
  const used = active.reduce((sum, b) => sum + b.allocation, 0);
300
- if (used + allocation <= 1 + EPSILON)
382
+ if (used + allocation <= capacity + EPSILON)
301
383
  continue;
302
384
  // Release bookings in end order until the task fits.
303
385
  let freed = used;
304
386
  for (const b of [...active].sort((x, y) => x.end - y.end)) {
305
387
  freed -= b.allocation;
306
- if (freed + allocation <= 1 + EPSILON)
388
+ if (freed + allocation <= capacity + EPSILON)
307
389
  return b;
308
390
  }
309
391
  }
310
392
  return undefined;
311
393
  }
312
- /** Periods in which an assignee's overlapping tasks need more than 100% of them. */
313
- function findOverallocations(taskMap, dates) {
314
- const byAssignee = new Map();
394
+ /**
395
+ * Which resources each task uses. An assigned task uses its assignee and every team pool the
396
+ * assignee belongs to (their own work takes up team capacity); a task with only `teamId` uses that
397
+ * team's pool. A pool's capacity is `team.headcount`, else its member count; pools with no capacity
398
+ * are ignored, so their tasks stay unconstrained.
399
+ */
400
+ function resourceModel(teams = []) {
401
+ const pools = teams
402
+ .map((team) => ({ team, capacity: team.headcount ?? team.memberIds.length }))
403
+ .filter((pool) => pool.capacity > 0);
404
+ const poolResource = ({ team, capacity }) => ({ key: `team:${team.id}`, capacity, teamId: team.id });
405
+ return (task) => {
406
+ if (task.assigneeId) {
407
+ return [
408
+ { key: `user:${task.assigneeId}`, capacity: 1, assigneeId: task.assigneeId },
409
+ ...pools.filter((pool) => pool.team.memberIds.includes(task.assigneeId)).map(poolResource)
410
+ ];
411
+ }
412
+ const pool = task.teamId ? pools.find((p) => p.team.id === task.teamId) : undefined;
413
+ return pool ? [poolResource(pool)] : [];
414
+ };
415
+ }
416
+ /** Periods in which an assignee or team pool is booked above its capacity. */
417
+ function findOverallocations(taskMap, dates, resourcesOf) {
418
+ const byResource = new Map();
315
419
  for (const [id, task] of taskMap) {
316
420
  const s = dates.start.get(id).getTime();
317
421
  const e = dates.finish.get(id).getTime();
318
- if (!task.assigneeId || e <= s)
422
+ if (e <= s)
319
423
  continue;
320
- byAssignee.set(task.assigneeId, [...(byAssignee.get(task.assigneeId) ?? []), { start: s, end: e, allocation: getTaskAllocation(task), taskId: id }]);
424
+ for (const resource of resourcesOf(task)) {
425
+ const entry = byResource.get(resource.key) ?? { resource, bookings: [] };
426
+ entry.bookings.push({ start: s, end: e, allocation: getTaskAllocation(task), taskId: id });
427
+ byResource.set(resource.key, entry);
428
+ }
321
429
  }
322
430
  const result = [];
323
- for (const [assigneeId, bookings] of byAssignee) {
431
+ for (const { resource, bookings } of byResource.values()) {
432
+ const owner = resource.assigneeId ? { assigneeId: resource.assigneeId } : { teamId: resource.teamId };
324
433
  const points = [...new Set(bookings.flatMap((b) => [b.start, b.end]))].sort((a, b) => a - b);
434
+ let previous;
325
435
  for (let i = 0; i + 1 < points.length; i++) {
326
436
  const [from, to] = [points[i], points[i + 1]];
327
437
  const active = bookings.filter((b) => b.start <= from && b.end >= to);
328
438
  const allocation = round2(active.reduce((sum, b) => sum + b.allocation, 0));
329
- if (allocation <= 1 + EPSILON)
439
+ if (allocation <= resource.capacity + EPSILON) {
440
+ previous = undefined;
330
441
  continue;
442
+ }
331
443
  const taskIds = active.map((b) => b.taskId).sort();
332
- const previous = result.at(-1);
333
- if (previous && previous.assigneeId === assigneeId && previous.end === new Date(from).toISOString() && previous.taskIds.join() === taskIds.join()) {
444
+ if (previous && previous.end === new Date(from).toISOString() && previous.taskIds.join() === taskIds.join()) {
334
445
  previous.end = new Date(to).toISOString();
335
446
  }
336
447
  else {
337
- result.push({ assigneeId, start: new Date(from).toISOString(), end: new Date(to).toISOString(), allocation, taskIds });
448
+ previous = { ...owner, start: new Date(from).toISOString(), end: new Date(to).toISOString(), allocation, capacity: resource.capacity, taskIds };
449
+ result.push(previous);
338
450
  }
339
451
  }
340
452
  }
341
453
  return result;
342
454
  }
343
- function buildAnalysis(projectId, calculatedAt, taskIds, early, late, projectSchedule, ctx, extra = () => ({})) {
344
- const { projectStart, calendarOf, durationOf, taskMap } = ctx;
455
+ function buildAnalysis({ projectId, start: projectStart, schedule: projectSchedule, taskIds }, calculatedAt, early, late, ctx, extra = () => ({})) {
456
+ const { calendarOf, durationOf, taskMap } = ctx;
345
457
  const offset = (date) => date >= projectStart
346
458
  ? getWorkingHoursBetween(projectStart, date, projectSchedule)
347
459
  : -getWorkingHoursBetween(date, projectStart, projectSchedule);
@@ -374,7 +486,7 @@ function buildAnalysis(projectId, calculatedAt, taskIds, early, late, projectSch
374
486
  ...extra(id)
375
487
  });
376
488
  }
377
- const projectEnd = latest(early.finish, projectStart);
489
+ const projectEnd = latest(taskIds.map((id) => early.finish.get(id)), projectStart);
378
490
  const totalHours = round2(offset(projectEnd));
379
491
  return {
380
492
  projectId,
@@ -387,6 +499,35 @@ function buildAnalysis(projectId, calculatedAt, taskIds, early, late, projectSch
387
499
  tasks: schedules
388
500
  };
389
501
  }
502
+ /**
503
+ * Critical path analysis over several projects at once. Always date-based. Each task starts no
504
+ * earlier than its own project's start and is due by its project's end. Dependencies between tasks
505
+ * in different projects are honoured. With `levelResources` (requires `calendars: 'assignee'`),
506
+ * assignees and team pools are shared across all projects, so nobody is booked above capacity in
507
+ * total. Returns one analysis per project, the latest end, and the combined over-allocation report.
508
+ */
509
+ export function calculatePortfolioCPM(projects, dependencies, options = {}) {
510
+ if (options.levelResources && options.calendars !== 'assignee') {
511
+ throw new Error("levelResources requires calendars: 'assignee'.");
512
+ }
513
+ const calculatedAt = new Date().toISOString();
514
+ const graph = buildGraph(projects.flatMap((p) => p.tasks), dependencies);
515
+ const segments = projects.map((p) => ({
516
+ projectId: p.projectId,
517
+ start: startOrToday(p.projectStartDate),
518
+ schedule: p.schedule || DEFAULT_WORK_SCHEDULE,
519
+ taskIds: p.tasks.map((t) => t.id)
520
+ }));
521
+ const result = scheduleOnDates(graph, segments, options, calculatedAt);
522
+ const ends = result.projects.map((a) => a.projectEndDate).filter(Boolean).sort();
523
+ return {
524
+ calculatedAt,
525
+ projects: result.projects,
526
+ ...(ends.length ? { projectEndDate: ends[ends.length - 1] } : {}),
527
+ ...(options.levelResources ? { leveled: true } : {}),
528
+ overallocations: result.overallocations
529
+ };
530
+ }
390
531
  /**
391
532
  * Calculates the Critical Path Method (CPM) schedule for a set of tasks and their dependencies.
392
533
  * - Forward Pass: computes earlyStart and earlyFinish (in working hours and calendar dates)
@@ -422,7 +563,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
422
563
  const earlyFinish = new Map();
423
564
  for (const id of topoOrder) {
424
565
  const task = taskMap.get(id);
425
- const duration = getTaskElapsedHours(task);
566
+ const duration = getTaskScheduledHours(task);
426
567
  const prereqs = prerequisites.get(id) || new Set();
427
568
  let maxPrereqFinish = 0;
428
569
  for (const p of prereqs) {
@@ -450,7 +591,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
450
591
  for (let i = topoOrder.length - 1; i >= 0; i--) {
451
592
  const id = topoOrder[i];
452
593
  const task = taskMap.get(id);
453
- const duration = getTaskElapsedHours(task);
594
+ const duration = getTaskScheduledHours(task);
454
595
  const succs = successors.get(id) || new Set();
455
596
  let minSuccLateStart = totalDurationHours;
456
597
  if (succs.size > 0) {
@@ -485,7 +626,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
485
626
  criticalTaskIds.push(id);
486
627
  }
487
628
  const task = taskMap.get(id);
488
- const duration = getTaskElapsedHours(task);
629
+ const duration = getTaskScheduledHours(task);
489
630
  const earlyStartDate = projectStartDate
490
631
  ? addWorkingHours(projectStartDate, es, schedule).toISOString()
491
632
  : undefined;