@critical-path/core 0.53.0 → 0.55.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -3
  3. package/dist/domain/cpm.d.ts +14 -3
  4. package/dist/domain/cpm.d.ts.map +1 -1
  5. package/dist/domain/cpm.js +146 -48
  6. package/dist/domain/cpm.js.map +1 -1
  7. package/dist/domain/effort.d.ts +17 -0
  8. package/dist/domain/effort.d.ts.map +1 -0
  9. package/dist/domain/effort.js +30 -0
  10. package/dist/domain/effort.js.map +1 -0
  11. package/dist/domain/entities.d.ts.map +1 -1
  12. package/dist/domain/entities.js +4 -6
  13. package/dist/domain/entities.js.map +1 -1
  14. package/dist/domain/errors.d.ts +7 -0
  15. package/dist/domain/errors.d.ts.map +1 -1
  16. package/dist/domain/errors.js +10 -0
  17. package/dist/domain/errors.js.map +1 -1
  18. package/dist/domain/index.d.ts +1 -0
  19. package/dist/domain/index.d.ts.map +1 -1
  20. package/dist/domain/index.js +1 -0
  21. package/dist/domain/index.js.map +1 -1
  22. package/dist/domain/ladder.d.ts.map +1 -1
  23. package/dist/domain/ladder.js +33 -21
  24. package/dist/domain/ladder.js.map +1 -1
  25. package/dist/domain/metrics.d.ts.map +1 -1
  26. package/dist/domain/metrics.js +18 -4
  27. package/dist/domain/metrics.js.map +1 -1
  28. package/dist/domain/workload.d.ts.map +1 -1
  29. package/dist/domain/workload.js +3 -1
  30. package/dist/domain/workload.js.map +1 -1
  31. package/dist/engine/index.d.ts +27 -1
  32. package/dist/engine/index.d.ts.map +1 -1
  33. package/dist/engine/index.js +130 -48
  34. package/dist/engine/index.js.map +1 -1
  35. package/dist/schemas/index.d.ts +8 -60
  36. package/dist/schemas/index.d.ts.map +1 -1
  37. package/dist/schemas/index.js +7 -11
  38. package/dist/schemas/index.js.map +1 -1
  39. package/dist/types/index.d.ts +15 -1
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/dist/utils/status.d.ts.map +1 -1
  42. package/dist/utils/status.js +3 -13
  43. package/dist/utils/status.js.map +1 -1
  44. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @critical-path/core
2
2
 
3
+ ## 0.55.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5dbf25b: Effort and progress roll-ups agree, count subtasks once and leave canceled work out.
8
+
9
+ - New `getTaskEstimateHours(task)` is the one place that reads an estimate (0 for milestones; an explicit 0 counts). Critical path analysis, workload, the ladder, metrics/EVM and deliverable summaries all use it, along with `getSummaryTaskIds` / `getLeafTasks`.
10
+ - **BREAKING:** a task with subtasks is a summary task. Critical path analysis schedules only leaf tasks: dependencies and deadlines on a summary apply to every leaf under it, and the summary is reported as a roll-up (`isSummary: true`; not in `criticalTaskIds`). Workload, the ladder and deliverable summaries count effort from leaves only, so an epic's estimate no longer doubles its subtasks'.
11
+ - **BREAKING:** canceled tasks leave estimate totals and progress denominators in the ladder and deliverable summaries (their logged hours still count), so a project can reach 100% and `completed`. Canceled tasks have zero planned and earned value in EVM. The ladder and deliverable summaries use inferred progress (`inferTaskProgress`) like task metrics.
12
+ - Unestimated tasks are listed in `unestimatedTaskIds`, and the hours assumed for them are configurable: `defaultEstimateHours` per call (`?defaultEstimateHours=` over HTTP, client and MCP options) or `criticalPathDefaultEstimateHours` on the engine. Default 1, as before.
13
+
14
+ ## 0.54.0
15
+
16
+ ### Minor Changes
17
+
18
+ - 1021eb5: Status, workflow and effort integrity.
19
+
20
+ - **BREAKING:** a task's `status` must be one of its project's statuses (`statusDefinitions`, else the workflow's), on create and update (`400` otherwise). Unknown keys are no longer guessed from their spelling (`unfinished` was treated as completed); `resolveStatusDefinition` returns `not_started` for them.
21
+ - **BREAKING:** derived task fields are rejected in request bodies and left out of `CreateTaskInput`: `semanticStatus`, `loggedHours`, `actualHours`, `billableHours`, `actualDurationSeconds`, `actualDurationMinutes`, `billableDurationMinutes`, `inProgressSince`, `blockedSince`, `blockedDurationSeconds`. `semanticStatus` is always derived from the status, so CPM, metrics and lifecycle agree. Hours come from time entries.
22
+ - `actualHours` is no longer overwritten with wall-clock time when a task leaves in-progress (logged effort was being lost). Elapsed time stays in `actualDurationSeconds` / `actualDurationMinutes`.
23
+ - **BREAKING:** changing a project's statuses (`workflowId`, `workflow` or `statusDefinitions`) requires `statusMapping` for every status its tasks are in that the project will no longer have (`engine.updateProject(id, updates, { statusMapping })`, or `statusMapping` in the `PATCH /projects/:id` body); tasks are moved in the same operation. Deleting a workflow that a project uses, or removing a status tasks are in, is refused with the new `ConflictError` (`409`).
24
+ - New projects are pinned to the workflow they start with, so a later default does not move them.
25
+ - MCP: new `log_time` tool; `update_task` no longer takes `loggedHours`.
26
+
3
27
  ## 0.53.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -238,15 +238,16 @@ const deliverable = await engine.createDeliverable({
238
238
  });
239
239
 
240
240
  // 2. Attach tasks to deliverable
241
- await engine.createTask({
241
+ const storyboard = await engine.createTask({
242
242
  projectId: 'proj_1',
243
243
  deliverableId: deliverable.id,
244
244
  title: 'Storyboard & Animatic',
245
245
  status: 'approved',
246
246
  estimatedHours: 12,
247
- loggedHours: 12,
248
247
  progress: 100
249
248
  });
249
+ // Logged hours come from time entries
250
+ await engine.logTime({ taskId: storyboard.id, hours: 12 });
250
251
 
251
252
  // 3. Rollup metrics
252
253
  const summary = await engine.getDeliverableSummary(deliverable.id);
@@ -364,7 +365,7 @@ console.log(leveled.unleveledProjectEndDate, '->', leveled.projectEndDate);
364
365
 
365
366
  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; `includeHiddenWork` also counts unreadable projects' work as `'hidden'`. `effort: 'remaining'` schedules only what is left on in-progress tasks.
366
367
 
367
- Tasks with `isMilestone: true` are zero-duration checkpoints (their estimates are ignored). A `deadline` on any task caps its late finish: a forecast that misses it shows as negative slack along the path driving it, with `deadlineDate` and `deadlineVarianceHours` per task and `missedDeadlineTaskIds` on the analysis. A date-only deadline means the end of that working day; project-calendar mode needs a start date to apply deadlines.
368
+ Tasks with subtasks are summary tasks, reported as roll-ups of their leaves (`isSummary`); dependencies and deadlines on them apply to every leaf. Unestimated tasks use `defaultEstimateHours` (default 1) and are listed in `unestimatedTaskIds`. Tasks with `isMilestone: true` are zero-duration checkpoints (their estimates are ignored). A `deadline` on any task caps its late finish: a forecast that misses it shows as negative slack along the path driving it, with `deadlineDate` and `deadlineVarianceHours` per task and `missedDeadlineTaskIds` on the analysis. A date-only deadline means the end of that working day; project-calendar mode needs a start date to apply deadlines.
368
369
 
369
370
  ### 9. Fractional Lexical Indexing (Kanban Reordering)
370
371
 
@@ -1,9 +1,15 @@
1
1
  import type { Task, TaskDependency, CriticalPathAnalysis, WorkSchedule, User, Team, PortfolioCriticalPathAnalysis } from '../types/index.js';
2
- export declare function getTaskDurationHours(task: Task): number;
2
+ /** Hours assumed for a task with no estimate and no logged effort (see `CPMOptions.defaultEstimateHours`). */
3
+ export declare const DEFAULT_ESTIMATE_HOURS = 1;
4
+ /**
5
+ * Effort critical path analysis and workload plan for a task: its estimate (`getTaskEstimateHours`;
6
+ * 0 for milestones), else the effort logged on it, else `defaultEstimateHours`.
7
+ */
8
+ export declare function getTaskDurationHours(task: Task, defaultEstimateHours?: number): number;
3
9
  /** The task's `allocation`, clamped to (0, 1]; 1 when unset or invalid. */
4
10
  export declare function getTaskAllocation(task: Task): number;
5
11
  /** Working hours a task spans: its effort divided by its allocation. */
6
- export declare function getTaskElapsedHours(task: Task): number;
12
+ export declare function getTaskElapsedHours(task: Task, defaultEstimateHours?: number): number;
7
13
  /** True for completed or canceled tasks (by `semanticStatus`, else the default `done`/`canceled` statuses). */
8
14
  export declare function isTaskFinished(task: Task): boolean;
9
15
  /** What effort critical path analysis schedules: the full estimate, or what remains on in-progress tasks. */
@@ -13,7 +19,7 @@ export type EffortBasis = 'estimate' | 'remaining';
13
19
  * more time and occupies nobody), otherwise its effort divided by its allocation. With
14
20
  * `effort: 'remaining'`, an in-progress task's effort is `max(0, estimate - loggedHours)`.
15
21
  */
16
- export declare function getTaskScheduledHours(task: Task, effort?: EffortBasis): number;
22
+ export declare function getTaskScheduledHours(task: Task, effort?: EffortBasis, defaultEstimateHours?: number): number;
17
23
  export interface CPMOptions {
18
24
  projectStartDate?: string | Date;
19
25
  /** The project calendar. Default: `DEFAULT_WORK_SCHEDULE`. */
@@ -47,6 +53,11 @@ export interface CPMOptions {
47
53
  * no time either way.
48
54
  */
49
55
  effort?: EffortBasis;
56
+ /**
57
+ * Hours assumed for an unfinished task with no estimate and no logged effort. Default 1. Such
58
+ * tasks are listed in `unestimatedTaskIds`, so a half-estimated plan does not look certain.
59
+ */
60
+ defaultEstimateHours?: number;
50
61
  }
51
62
  export type LevelingPriority = 'slack' | 'priority' | 'dueDate' | 'order';
52
63
  /** The calendar a task runs on in assignee mode: assignee, then task team, then project. */
@@ -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,EAEJ,6BAA6B,EAC9B,MAAM,mBAAmB,CAAC;AAc3B,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAcvD;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,6GAA6G;AAC7G,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,WAAW,CAAC;AAEnD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,GAAE,WAAwB,GAAG,MAAM,CAM1F;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;IACpC;;;;OAIG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;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;AAID;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,GAAE,YAAoC,GAAG,IAAI,CAKzG;AA2kBD,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;IACxB;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;CACnC;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,CAsC/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,CAqLtB"}
1
+ {"version":3,"file":"cpm.d.ts","sourceRoot":"","sources":["../../src/domain/cpm.ts"],"names":[],"mappings":"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"}
@@ -1,21 +1,24 @@
1
1
  import { DEFAULT_WORK_SCHEDULE, addWorkingHours, subtractWorkingHours, getWorkingHoursBetween, nextWorkingTime, previousWorkingTime, parseDate, toDateString } from './calendar.js';
2
2
  import { isTaskInProgress } from '../utils/status.js';
3
3
  import { precedenceOf } from './graph.js';
4
- export function getTaskDurationHours(task) {
5
- // A milestone is a point in time: it never takes effort, whatever its estimates say
6
- if (task.isMilestone)
7
- return 0;
8
- if (typeof task.estimatedHours === 'number' && task.estimatedHours >= 0) {
9
- return task.estimatedHours;
10
- }
11
- if (typeof task.estimatedDurationMinutes === 'number' && task.estimatedDurationMinutes >= 0) {
12
- return task.estimatedDurationMinutes / 60;
13
- }
14
- if (typeof task.actualHours === 'number' && task.actualHours > 0) {
4
+ import { getTaskEstimateHours, getSummaryTaskIds } from './effort.js';
5
+ /** Hours assumed for a task with no estimate and no logged effort (see `CPMOptions.defaultEstimateHours`). */
6
+ export const DEFAULT_ESTIMATE_HOURS = 1;
7
+ /**
8
+ * Effort critical path analysis and workload plan for a task: its estimate (`getTaskEstimateHours`;
9
+ * 0 for milestones), else the effort logged on it, else `defaultEstimateHours`.
10
+ */
11
+ export function getTaskDurationHours(task, defaultEstimateHours = DEFAULT_ESTIMATE_HOURS) {
12
+ const estimate = getTaskEstimateHours(task);
13
+ if (estimate !== undefined)
14
+ return estimate;
15
+ if (typeof task.actualHours === 'number' && task.actualHours > 0)
15
16
  return task.actualHours;
16
- }
17
- // Default to 1 hour for non-empty tasks without explicit estimate
18
- return 1;
17
+ return defaultEstimateHours;
18
+ }
19
+ /** True when `getTaskDurationHours` falls back to the default: no estimate and no logged effort. */
20
+ function isUnestimated(task) {
21
+ return getTaskEstimateHours(task) === undefined && !(typeof task.actualHours === 'number' && task.actualHours > 0);
19
22
  }
20
23
  /** The task's `allocation`, clamped to (0, 1]; 1 when unset or invalid. */
21
24
  export function getTaskAllocation(task) {
@@ -23,8 +26,8 @@ export function getTaskAllocation(task) {
23
26
  return typeof a === 'number' && a > 0 && a <= 1 ? a : 1;
24
27
  }
25
28
  /** Working hours a task spans: its effort divided by its allocation. */
26
- export function getTaskElapsedHours(task) {
27
- return getTaskDurationHours(task) / getTaskAllocation(task);
29
+ export function getTaskElapsedHours(task, defaultEstimateHours) {
30
+ return getTaskDurationHours(task, defaultEstimateHours) / getTaskAllocation(task);
28
31
  }
29
32
  /** True for completed or canceled tasks (by `semanticStatus`, else the default `done`/`canceled` statuses). */
30
33
  export function isTaskFinished(task) {
@@ -37,13 +40,13 @@ export function isTaskFinished(task) {
37
40
  * more time and occupies nobody), otherwise its effort divided by its allocation. With
38
41
  * `effort: 'remaining'`, an in-progress task's effort is `max(0, estimate - loggedHours)`.
39
42
  */
40
- export function getTaskScheduledHours(task, effort = 'estimate') {
43
+ export function getTaskScheduledHours(task, effort = 'estimate', defaultEstimateHours) {
41
44
  if (isTaskFinished(task))
42
45
  return 0;
43
46
  if (effort === 'remaining' && isTaskInProgress({ status: task.status, semanticStatus: task.semanticStatus })) {
44
- return Math.max(0, getTaskDurationHours(task) - (task.loggedHours ?? 0)) / getTaskAllocation(task);
47
+ return Math.max(0, getTaskDurationHours(task, defaultEstimateHours) - (task.loggedHours ?? 0)) / getTaskAllocation(task);
45
48
  }
46
- return getTaskElapsedHours(task);
49
+ return getTaskElapsedHours(task, defaultEstimateHours);
47
50
  }
48
51
  /** The calendar a task runs on in assignee mode: assignee, then task team, then project. */
49
52
  export function resolveTaskSchedule(task, context) {
@@ -71,8 +74,35 @@ const milestoneFields = (task, deadline, varianceHours) => ({
71
74
  ...(deadline ? { deadlineDate: deadline.toISOString(), deadlineVarianceHours: round2(varianceHours) } : {})
72
75
  });
73
76
  const allocationField = (task) => (getTaskAllocation(task) < 1 ? { allocation: getTaskAllocation(task) } : {});
77
+ const deadlinesOf = (graph, id) => graph.deadlines.get(id) ?? [];
78
+ /**
79
+ * The scheduling graph. Only leaf tasks are scheduled: a summary task (one with subtasks among
80
+ * `tasks`) spans its subtasks, so a dependency on it applies to every leaf under it, and its
81
+ * deadline caps those leaves. Summaries are reported afterwards as roll-ups (`addSummaries`).
82
+ */
74
83
  function buildGraph(tasks, dependencies) {
75
- const taskMap = new Map(tasks.map((t) => [t.id, t]));
84
+ const summaryIds = getSummaryTaskIds(tasks);
85
+ const all = new Map(tasks.map((t) => [t.id, t]));
86
+ const childrenOf = new Map();
87
+ for (const t of tasks) {
88
+ if (t.parentId && summaryIds.has(t.parentId))
89
+ childrenOf.set(t.parentId, [...(childrenOf.get(t.parentId) ?? []), t.id]);
90
+ }
91
+ const leavesOf = (id, seen = new Set()) => {
92
+ if (!summaryIds.has(id) || seen.has(id))
93
+ return [id];
94
+ seen.add(id);
95
+ return childrenOf.get(id).flatMap((child) => leavesOf(child, seen));
96
+ };
97
+ const summaries = new Map([...summaryIds].map((id) => [id, { task: all.get(id), leafIds: leavesOf(id) }]));
98
+ const deadlines = new Map();
99
+ for (const t of tasks) {
100
+ if (!t.deadline)
101
+ continue;
102
+ for (const leaf of leavesOf(t.id))
103
+ deadlines.set(leaf, [...(deadlines.get(leaf) ?? []), t.deadline]);
104
+ }
105
+ const taskMap = new Map(tasks.filter((t) => !summaryIds.has(t.id)).map((t) => [t.id, t]));
76
106
  const taskIds = Array.from(taskMap.keys());
77
107
  // Build adjacency graph:
78
108
  // prerequisites[v] = tasks that v depends on (must finish before v starts)
@@ -85,9 +115,15 @@ function buildGraph(tasks, dependencies) {
85
115
  }
86
116
  for (const dep of dependencies) {
87
117
  const edge = precedenceOf(dep);
88
- if (edge && taskMap.has(edge.before) && taskMap.has(edge.after) && edge.before !== edge.after) {
89
- prerequisites.get(edge.after).add(edge.before);
90
- successors.get(edge.before).add(edge.after);
118
+ if (!edge || !all.has(edge.before) || !all.has(edge.after))
119
+ continue;
120
+ for (const before of leavesOf(edge.before)) {
121
+ for (const after of leavesOf(edge.after)) {
122
+ if (before === after)
123
+ continue;
124
+ prerequisites.get(after).add(before);
125
+ successors.get(before).add(after);
126
+ }
91
127
  }
92
128
  }
93
129
  // Topological sorting via Kahn's algorithm
@@ -118,7 +154,58 @@ function buildGraph(tasks, dependencies) {
118
154
  const sorted = new Set(topoOrder);
119
155
  const cyclicTaskIds = taskIds.filter((id) => !sorted.has(id));
120
156
  topoOrder.push(...cyclicTaskIds);
121
- return { taskMap, taskIds, prerequisites, successors, topoOrder, cyclicTaskIds };
157
+ return { taskMap, taskIds, prerequisites, successors, topoOrder, cyclicTaskIds, summaries, deadlines };
158
+ }
159
+ /**
160
+ * Adds each summary task to `analysis` as a roll-up of the leaf tasks under it: earliest start,
161
+ * latest finish, least slack, critical when any leaf is. Summaries are not added to
162
+ * `criticalTaskIds`, whose tasks are the scheduled (leaf) ones. A summary's own deadline is
163
+ * reported against its latest finish.
164
+ */
165
+ function addSummaries(analysis, graph, schedule) {
166
+ const byId = new Map(analysis.tasks.map((t) => [t.taskId, t]));
167
+ const rollups = [];
168
+ const missed = [];
169
+ for (const [id, { task, leafIds }] of graph.summaries) {
170
+ const leaves = leafIds.map((leaf) => byId.get(leaf)).filter((t) => Boolean(t));
171
+ if (!leaves.length)
172
+ continue;
173
+ const min = (pick) => Math.min(...leaves.map(pick));
174
+ const max = (pick) => Math.max(...leaves.map(pick));
175
+ const dates = (pick) => leaves.map(pick).filter((d) => Boolean(d)).sort();
176
+ const [starts, finishes, lateStarts, lateFinishes] = [dates((t) => t.earlyStartDate), dates((t) => t.earlyFinishDate), dates((t) => t.lateStartDate), dates((t) => t.lateFinishDate)];
177
+ const slack = min((t) => t.totalSlack);
178
+ const earlyFinishDate = finishes[finishes.length - 1];
179
+ const deadline = task.deadline && earlyFinishDate ? getDeadlineInstant(task.deadline, schedule) : undefined;
180
+ const variance = deadline ? slackHours(new Date(earlyFinishDate), deadline, schedule) : undefined;
181
+ if (variance !== undefined && variance < -0.001)
182
+ missed.push(id);
183
+ rollups.push({
184
+ taskId: id,
185
+ isSummary: true,
186
+ earlyStart: min((t) => t.earlyStart),
187
+ earlyFinish: max((t) => t.earlyFinish),
188
+ lateStart: min((t) => t.lateStart),
189
+ lateFinish: max((t) => t.lateFinish),
190
+ totalSlack: slack,
191
+ slackWorkingHours: slack,
192
+ isCritical: leaves.some((t) => t.isCritical),
193
+ durationHours: round2(max((t) => t.earlyFinish) - min((t) => t.earlyStart)),
194
+ ...(starts.length ? { earlyStartDate: starts[0], earlyFinishDate, lateStartDate: lateStarts[0], lateFinishDate: lateFinishes[lateFinishes.length - 1] } : {}),
195
+ ...milestoneFields({ ...task, isMilestone: false }, deadline, variance)
196
+ });
197
+ }
198
+ if (!rollups.length)
199
+ return analysis;
200
+ return { ...analysis, tasks: [...analysis.tasks, ...rollups], missedDeadlineTaskIds: [...analysis.missedDeadlineTaskIds, ...missed] };
201
+ }
202
+ /** Completes a project's analysis: unestimated tasks and summary roll-ups. */
203
+ function finishAnalysis(analysis, graph, schedule, taskIds) {
204
+ const unestimatedTaskIds = taskIds.filter((id) => {
205
+ const task = graph.taskMap.get(id);
206
+ return !isTaskFinished(task) && isUnestimated(task);
207
+ });
208
+ return addSummaries({ ...analysis, ...(unestimatedTaskIds.length ? { unestimatedTaskIds } : {}) }, graph, schedule);
122
209
  }
123
210
  /**
124
211
  * Assignee-calendar CPM. Passes run on dates, because working-hour offsets mean different
@@ -160,12 +247,17 @@ function scheduleOnDates(graph, segments, options, calculatedAt) {
160
247
  const task = taskMap.get(id);
161
248
  return [id, options.calendars === 'assignee' ? resolveTaskSchedule(task, { ...options, schedule: seg.schedule }) : seg.schedule];
162
249
  }));
163
- const durationOf = new Map(taskIds.map((id) => [id, getTaskScheduledHours(taskMap.get(id), options.effort)]));
250
+ const durationOf = new Map(taskIds.map((id) => [id, getTaskScheduledHours(taskMap.get(id), options.effort, options.defaultEstimateHours)]));
251
+ // A task's late finish is capped by its own deadline and by those of the summary tasks above it
164
252
  const deadlineOf = new Map(taskIds.flatMap((id) => {
165
- const deadline = taskMap.get(id).deadline;
166
- return deadline ? [[id, getDeadlineInstant(deadline, calendarOf.get(id))]] : [];
253
+ const instants = deadlinesOf(graph, id).map((d) => getDeadlineInstant(d, calendarOf.get(id)));
254
+ return instants.length ? [[id, new Date(Math.min(...instants.map((d) => d.getTime())))]] : [];
167
255
  }));
168
- const ctx = { taskMap, calendarOf, durationOf, deadlineOf, startOf: (id) => segmentOf.get(id).start };
256
+ const ownDeadlineOf = (id) => {
257
+ const deadline = taskMap.get(id).deadline;
258
+ return deadline ? getDeadlineInstant(deadline, calendarOf.get(id)) : undefined;
259
+ };
260
+ const ctx = { taskMap, calendarOf, durationOf, deadlineOf, ownDeadlineOf, startOf: (id) => segmentOf.get(id).start };
169
261
  const endOfProjects = (dates) => {
170
262
  const ends = new Map(segments.map((seg) => [seg.projectId, latest(seg.taskIds.map((id) => dates.finish.get(id)), seg.start)]));
171
263
  return (id) => ends.get(segmentOf.get(id).projectId);
@@ -176,7 +268,7 @@ function scheduleOnDates(graph, segments, options, calculatedAt) {
176
268
  const cyclic = new Set(graph.cyclicTaskIds);
177
269
  const analyse = (seg, e, l, extra) => {
178
270
  const cyclicTaskIds = seg.taskIds.filter((id) => cyclic.has(id));
179
- return { ...buildAnalysis(seg, calculatedAt, e, l, ctx, extra), ...(cyclicTaskIds.length ? { cyclicTaskIds } : {}) };
271
+ return finishAnalysis({ ...buildAnalysis(seg, calculatedAt, e, l, ctx, extra), ...(cyclicTaskIds.length ? { cyclicTaskIds } : {}) }, graph, seg.schedule, seg.taskIds);
180
272
  };
181
273
  if (!options.levelResources) {
182
274
  const overallocations = findOverallocations(taskMap, early, resourcesOf);
@@ -490,7 +582,7 @@ function findOverallocations(taskMap, dates, resourcesOf) {
490
582
  return result;
491
583
  }
492
584
  function buildAnalysis({ projectId, start: projectStart, schedule: projectSchedule, taskIds }, calculatedAt, early, late, ctx, extra = () => ({})) {
493
- const { calendarOf, durationOf, deadlineOf, taskMap } = ctx;
585
+ const { calendarOf, durationOf, ownDeadlineOf, taskMap } = ctx;
494
586
  const offset = (date) => date >= projectStart
495
587
  ? getWorkingHoursBetween(projectStart, date, projectSchedule)
496
588
  : -getWorkingHoursBetween(date, projectStart, projectSchedule);
@@ -520,7 +612,7 @@ function buildAnalysis({ projectId, start: projectStart, schedule: projectSchedu
520
612
  lateFinishDate: lf.toISOString(),
521
613
  ...(calendar.id ? { scheduleId: calendar.id } : {}),
522
614
  ...allocationField(taskMap.get(id)),
523
- ...milestoneFields(taskMap.get(id), deadlineOf.get(id), deadlineOf.has(id) ? slackHours(ef, deadlineOf.get(id), calendar) : undefined),
615
+ ...milestoneFields(taskMap.get(id), ownDeadlineOf(id), ownDeadlineOf(id) ? slackHours(ef, ownDeadlineOf(id), calendar) : undefined),
524
616
  ...extra(id)
525
617
  });
526
618
  }
@@ -555,7 +647,8 @@ export function calculatePortfolioCPM(projects, dependencies, options = {}) {
555
647
  projectId: p.projectId,
556
648
  start: startOrToday(p.projectStartDate),
557
649
  schedule: p.schedule || DEFAULT_WORK_SCHEDULE,
558
- taskIds: p.tasks.map((t) => t.id)
650
+ // Summary tasks are not scheduled; they are added back as roll-ups
651
+ taskIds: p.tasks.map((t) => t.id).filter((id) => graph.taskMap.has(id))
559
652
  }));
560
653
  const result = scheduleOnDates(graph, segments, options, calculatedAt);
561
654
  // Background projects are dropped from the results; hidden ones also have their task ids redacted.
@@ -614,16 +707,21 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
614
707
  return calculateCalendarCPM(projectId, calculatedAt, graph, options);
615
708
  }
616
709
  // Deadlines as project-calendar hours from the start. Without a start date there is nothing to
617
- // place them against, so they are ignored.
618
- const deadlineHours = new Map();
710
+ // place them against, so they are ignored. A task's late finish is capped by its own deadline and
711
+ // by those of the summary tasks above it; its schedule reports its own.
712
+ const capHours = new Map();
713
+ const ownDeadline = new Map();
714
+ const toHours = (deadline) => deadline >= projectStartDate
715
+ ? getWorkingHoursBetween(projectStartDate, deadline, schedule)
716
+ : -getWorkingHoursBetween(deadline, projectStartDate, schedule);
619
717
  if (projectStartDate) {
620
- for (const task of tasks) {
621
- if (!task.deadline)
622
- continue;
623
- const deadline = getDeadlineInstant(task.deadline, schedule);
624
- deadlineHours.set(task.id, deadline >= projectStartDate
625
- ? getWorkingHoursBetween(projectStartDate, deadline, schedule)
626
- : -getWorkingHoursBetween(deadline, projectStartDate, schedule));
718
+ for (const id of taskIds) {
719
+ const caps = deadlinesOf(graph, id).map((d) => toHours(getDeadlineInstant(d, schedule)));
720
+ if (caps.length)
721
+ capHours.set(id, Math.min(...caps));
722
+ const own = taskMap.get(id).deadline;
723
+ if (own)
724
+ ownDeadline.set(id, getDeadlineInstant(own, schedule));
627
725
  }
628
726
  }
629
727
  // --- FORWARD PASS ---
@@ -631,7 +729,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
631
729
  const earlyFinish = new Map();
632
730
  for (const id of topoOrder) {
633
731
  const task = taskMap.get(id);
634
- const duration = getTaskScheduledHours(task, options.effort);
732
+ const duration = getTaskScheduledHours(task, options.effort, options.defaultEstimateHours);
635
733
  const prereqs = prerequisites.get(id) || new Set();
636
734
  let maxPrereqFinish = 0;
637
735
  for (const p of prereqs) {
@@ -659,7 +757,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
659
757
  for (let i = topoOrder.length - 1; i >= 0; i--) {
660
758
  const id = topoOrder[i];
661
759
  const task = taskMap.get(id);
662
- const duration = getTaskScheduledHours(task, options.effort);
760
+ const duration = getTaskScheduledHours(task, options.effort, options.defaultEstimateHours);
663
761
  const succs = successors.get(id) || new Set();
664
762
  let minSuccLateStart = totalDurationHours;
665
763
  if (succs.size > 0) {
@@ -674,7 +772,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
674
772
  minSuccLateStart = totalDurationHours;
675
773
  }
676
774
  }
677
- const lf = Math.min(minSuccLateStart, deadlineHours.get(id) ?? Infinity);
775
+ const lf = Math.min(minSuccLateStart, capHours.get(id) ?? Infinity);
678
776
  const ls = lf - duration;
679
777
  lateFinish.set(id, lf);
680
778
  lateStart.set(id, ls);
@@ -694,7 +792,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
694
792
  criticalTaskIds.push(id);
695
793
  }
696
794
  const task = taskMap.get(id);
697
- const duration = getTaskScheduledHours(task, options.effort);
795
+ const duration = getTaskScheduledHours(task, options.effort, options.defaultEstimateHours);
698
796
  const earlyStartDate = projectStartDate
699
797
  ? addWorkingHours(projectStartDate, es, schedule).toISOString()
700
798
  : undefined;
@@ -717,7 +815,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
717
815
  isCritical,
718
816
  durationHours: duration,
719
817
  ...allocationField(task),
720
- ...milestoneFields(task, deadlineHours.has(id) ? getDeadlineInstant(task.deadline, schedule) : undefined, deadlineHours.has(id) ? deadlineHours.get(id) - ef : undefined),
818
+ ...milestoneFields(task, ownDeadline.get(id), ownDeadline.has(id) ? toHours(ownDeadline.get(id)) - ef : undefined),
721
819
  slackWorkingHours: totalSlack,
722
820
  earlyStartDate,
723
821
  earlyFinishDate,
@@ -728,7 +826,7 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
728
826
  const projectEndDate = projectStartDate
729
827
  ? addWorkingHours(projectStartDate, totalDurationHours, schedule).toISOString()
730
828
  : undefined;
731
- return {
829
+ return finishAnalysis({
732
830
  projectId,
733
831
  calculatedAt,
734
832
  totalDurationHours: Math.round(totalDurationHours * 100) / 100,
@@ -739,6 +837,6 @@ export function calculateCPM(projectId, tasks, dependencies, options = {}) {
739
837
  missedDeadlineTaskIds: missedDeadlines(schedules),
740
838
  ...(graph.cyclicTaskIds.length ? { cyclicTaskIds: graph.cyclicTaskIds } : {}),
741
839
  tasks: schedules
742
- };
840
+ }, graph, schedule, taskIds);
743
841
  }
744
842
  //# sourceMappingURL=cpm.js.map