@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 +1 -1
- package/dist/domain/cpm.d.ts +28 -1
- package/dist/domain/cpm.d.ts.map +1 -1
- package/dist/domain/cpm.js +210 -69
- package/dist/domain/cpm.js.map +1 -1
- package/dist/engine/index.d.ts +23 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +81 -25
- package/dist/engine/index.js.map +1 -1
- package/dist/schemas/index.d.ts +6 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +1 -0
- package/dist/testing/conformance.js.map +1 -1
- package/dist/types/index.d.ts +25 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
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
|
|
package/dist/domain/cpm.d.ts
CHANGED
|
@@ -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)
|
package/dist/domain/cpm.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/domain/cpm.js
CHANGED
|
@@ -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
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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),
|
|
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
|
|
121
|
-
return {
|
|
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
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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 =
|
|
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
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
|
196
|
-
*
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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]
|
|
219
|
-
return
|
|
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 =
|
|
296
|
+
const id = first(eligible);
|
|
233
297
|
eligible.delete(id);
|
|
234
|
-
let ready = ctx.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
busy.set(
|
|
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
|
-
|
|
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 >
|
|
275
|
-
|
|
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 {
|
|
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 <=
|
|
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 <=
|
|
388
|
+
if (freed + allocation <= capacity + EPSILON)
|
|
307
389
|
return b;
|
|
308
390
|
}
|
|
309
391
|
}
|
|
310
392
|
return undefined;
|
|
311
393
|
}
|
|
312
|
-
/**
|
|
313
|
-
|
|
314
|
-
|
|
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 (
|
|
422
|
+
if (e <= s)
|
|
319
423
|
continue;
|
|
320
|
-
|
|
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
|
|
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 <=
|
|
439
|
+
if (allocation <= resource.capacity + EPSILON) {
|
|
440
|
+
previous = undefined;
|
|
330
441
|
continue;
|
|
442
|
+
}
|
|
331
443
|
const taskIds = active.map((b) => b.taskId).sort();
|
|
332
|
-
|
|
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
|
-
|
|
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,
|
|
344
|
-
const {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
629
|
+
const duration = getTaskScheduledHours(task);
|
|
489
630
|
const earlyStartDate = projectStartDate
|
|
490
631
|
? addWorkingHours(projectStartDate, es, schedule).toISOString()
|
|
491
632
|
: undefined;
|