@critical-path/core 0.17.0 → 0.19.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 (71) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +71 -0
  3. package/dist/domain/calendar.d.ts +53 -0
  4. package/dist/domain/calendar.d.ts.map +1 -0
  5. package/dist/domain/calendar.js +333 -0
  6. package/dist/domain/calendar.js.map +1 -0
  7. package/dist/domain/calendar.test.d.ts +2 -0
  8. package/dist/domain/calendar.test.d.ts.map +1 -0
  9. package/dist/domain/calendar.test.js +136 -0
  10. package/dist/domain/calendar.test.js.map +1 -0
  11. package/dist/domain/cpm.d.ts +8 -3
  12. package/dist/domain/cpm.d.ts.map +1 -1
  13. package/dist/domain/cpm.js +36 -3
  14. package/dist/domain/cpm.js.map +1 -1
  15. package/dist/domain/cpm.test.js +53 -0
  16. package/dist/domain/cpm.test.js.map +1 -1
  17. package/dist/domain/events.d.ts +10 -2
  18. package/dist/domain/events.d.ts.map +1 -1
  19. package/dist/domain/events.js.map +1 -1
  20. package/dist/domain/index.d.ts +2 -0
  21. package/dist/domain/index.d.ts.map +1 -1
  22. package/dist/domain/index.js +2 -0
  23. package/dist/domain/index.js.map +1 -1
  24. package/dist/domain/ladder.d.ts +3 -2
  25. package/dist/domain/ladder.d.ts.map +1 -1
  26. package/dist/domain/ladder.js +21 -3
  27. package/dist/domain/ladder.js.map +1 -1
  28. package/dist/domain/metrics.test.js +5 -4
  29. package/dist/domain/metrics.test.js.map +1 -1
  30. package/dist/domain/workload.d.ts +10 -0
  31. package/dist/domain/workload.d.ts.map +1 -0
  32. package/dist/domain/workload.js +392 -0
  33. package/dist/domain/workload.js.map +1 -0
  34. package/dist/domain/workload.test.d.ts +2 -0
  35. package/dist/domain/workload.test.d.ts.map +1 -0
  36. package/dist/domain/workload.test.js +261 -0
  37. package/dist/domain/workload.test.js.map +1 -0
  38. package/dist/engine/index.d.ts +5 -2
  39. package/dist/engine/index.d.ts.map +1 -1
  40. package/dist/engine/index.js +83 -3
  41. package/dist/engine/index.js.map +1 -1
  42. package/dist/store/sqlite.d.ts.map +1 -1
  43. package/dist/store/sqlite.js +9 -3
  44. package/dist/store/sqlite.js.map +1 -1
  45. package/dist/store/sqlite.test.js +15 -1
  46. package/dist/store/sqlite.test.js.map +1 -1
  47. package/dist/task-state.test.js +49 -0
  48. package/dist/task-state.test.js.map +1 -1
  49. package/dist/types/index.d.ts +81 -1
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/dist/utils/status.d.ts.map +1 -1
  52. package/dist/utils/status.js +5 -2
  53. package/dist/utils/status.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/domain/calendar.test.ts +164 -0
  56. package/src/domain/calendar.ts +457 -0
  57. package/src/domain/cpm.test.ts +61 -0
  58. package/src/domain/cpm.ts +47 -4
  59. package/src/domain/events.ts +11 -1
  60. package/src/domain/index.ts +2 -0
  61. package/src/domain/ladder.ts +25 -4
  62. package/src/domain/metrics.test.ts +6 -4
  63. package/src/domain/workload.test.ts +311 -0
  64. package/src/domain/workload.ts +457 -0
  65. package/src/engine/index.ts +103 -5
  66. package/src/store/sqlite.test.ts +17 -1
  67. package/src/store/sqlite.ts +11 -1
  68. package/src/task-state.test.ts +60 -1
  69. package/src/types/index.ts +89 -0
  70. package/src/utils/status.ts +7 -2
  71. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,457 @@
1
+ import type {
2
+ Task,
3
+ TimeEntry,
4
+ User,
5
+ Team,
6
+ WorkloadInterval,
7
+ WorkloadGroupBy,
8
+ WorkloadMetric,
9
+ WorkloadBucket,
10
+ WorkloadDistribution,
11
+ WorkloadDistributionOptions
12
+ } from '../types/index.js';
13
+ import {
14
+ DEFAULT_WORK_SCHEDULE,
15
+ getWorkingDaysList,
16
+ getNetAvailableCapacity,
17
+ getWorkingHoursInDay
18
+ } from './calendar.js';
19
+
20
+ export interface WorkloadContext {
21
+ tasks: Task[];
22
+ timeEntries?: TimeEntry[];
23
+ users?: User[];
24
+ teams?: Team[];
25
+ projectId?: string;
26
+ }
27
+
28
+ function toDateString(d: Date): string {
29
+ return d.toISOString().split('T')[0];
30
+ }
31
+
32
+ function parseDateOnly(isoString: string): Date {
33
+ const parts = isoString.split('T')[0].split('-');
34
+ return new Date(Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)));
35
+ }
36
+
37
+ function addDays(d: Date, days: number): Date {
38
+ const res = new Date(d.getTime());
39
+ res.setUTCDate(res.getUTCDate() + days);
40
+ return res;
41
+ }
42
+
43
+ function startOfWeek(d: Date): Date {
44
+ const date = new Date(d.getTime());
45
+ const day = date.getUTCDay();
46
+ // Monday is day 1, Sunday is day 0 -> shift Sunday to 7
47
+ const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
48
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), diff));
49
+ }
50
+
51
+ function startOfMonth(d: Date): Date {
52
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
53
+ }
54
+
55
+ function nextBucketDate(d: Date, interval: WorkloadInterval): Date {
56
+ if (interval === 'day') {
57
+ return addDays(d, 1);
58
+ }
59
+ if (interval === 'week') {
60
+ return addDays(d, 7);
61
+ }
62
+ // month: next month 1st
63
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
64
+ }
65
+
66
+ function getBucketKey(d: Date, interval: WorkloadInterval): string {
67
+ if (interval === 'day') {
68
+ return toDateString(d);
69
+ }
70
+ if (interval === 'week') {
71
+ return toDateString(startOfWeek(d));
72
+ }
73
+ return toDateString(startOfMonth(d));
74
+ }
75
+
76
+ function getTaskDurationHours(task: Task): number {
77
+ if (typeof task.estimatedHours === 'number' && task.estimatedHours >= 0) {
78
+ return task.estimatedHours;
79
+ }
80
+ if (typeof task.estimatedDurationMinutes === 'number' && task.estimatedDurationMinutes >= 0) {
81
+ return task.estimatedDurationMinutes / 60;
82
+ }
83
+ if (typeof task.actualHours === 'number' && task.actualHours > 0) {
84
+ return task.actualHours;
85
+ }
86
+ return 1;
87
+ }
88
+
89
+ export function calculateWorkloadDistribution(
90
+ context: WorkloadContext,
91
+ options: WorkloadDistributionOptions = {}
92
+ ): WorkloadDistribution {
93
+ const { tasks, timeEntries = [], users = [], teams = [], projectId } = context;
94
+ const interval: WorkloadInterval = options.interval || 'week';
95
+ const groupBy: WorkloadGroupBy = options.groupBy || 'assignee';
96
+ const metric: WorkloadMetric = options.metric || 'blended';
97
+ const defaultWeeklyCapacity = options.defaultWeeklyCapacityHours ?? 40;
98
+
99
+ // Build lookups for human-readable labels
100
+ const userMap = new Map<string, User>(users.map((u) => [u.id, u]));
101
+ const teamMap = new Map<string, Team>(teams.map((t) => [t.id, t]));
102
+
103
+ // 1. Determine date range
104
+ let minDate: Date | null = options.startDate ? parseDateOnly(options.startDate) : null;
105
+ let maxDate: Date | null = options.endDate ? parseDateOnly(options.endDate) : null;
106
+
107
+ if (!minDate || !maxDate) {
108
+ const dates: number[] = [];
109
+ for (const t of tasks) {
110
+ if (t.plannedStartDate) dates.push(parseDateOnly(t.plannedStartDate).getTime());
111
+ if (t.actualStartDate) dates.push(parseDateOnly(t.actualStartDate).getTime());
112
+ if (t.dueDate) dates.push(parseDateOnly(t.dueDate).getTime());
113
+ if (t.actualEndDate) dates.push(parseDateOnly(t.actualEndDate).getTime());
114
+ if (t.createdAt) dates.push(parseDateOnly(t.createdAt).getTime());
115
+ }
116
+ for (const te of timeEntries) {
117
+ if (te.loggedAt) dates.push(parseDateOnly(te.loggedAt).getTime());
118
+ }
119
+
120
+ if (dates.length > 0) {
121
+ if (!minDate) minDate = new Date(Math.min(...dates));
122
+ if (!maxDate) maxDate = new Date(Math.max(...dates));
123
+ } else {
124
+ const now = new Date();
125
+ if (!minDate) minDate = addDays(now, -7);
126
+ if (!maxDate) maxDate = addDays(now, 28);
127
+ }
128
+ }
129
+
130
+ // Ensure minDate <= maxDate
131
+ if (minDate > maxDate) {
132
+ maxDate = new Date(minDate.getTime());
133
+ }
134
+
135
+ // Align start to bucket boundary
136
+ let cursor = interval === 'week' ? startOfWeek(minDate) : interval === 'month' ? startOfMonth(minDate) : minDate;
137
+ const endLimit = interval === 'week' ? startOfWeek(maxDate) : interval === 'month' ? startOfMonth(maxDate) : maxDate;
138
+
139
+ // Generate contiguous buckets
140
+ interface InternalBucket {
141
+ date: string;
142
+ startDate: Date;
143
+ endDate: Date;
144
+ timestamp: number;
145
+ values: Record<string, number>;
146
+ }
147
+
148
+ const bucketList: InternalBucket[] = [];
149
+ const bucketMap = new Map<string, InternalBucket>();
150
+
151
+ while (cursor <= endLimit || bucketList.length === 0) {
152
+ const dateStr = toDateString(cursor);
153
+ const nextDate = nextBucketDate(cursor, interval);
154
+ const b: InternalBucket = {
155
+ date: dateStr,
156
+ startDate: new Date(cursor.getTime()),
157
+ endDate: nextDate,
158
+ timestamp: cursor.getTime(),
159
+ values: {}
160
+ };
161
+ bucketList.push(b);
162
+ bucketMap.set(dateStr, b);
163
+ cursor = nextDate;
164
+ }
165
+
166
+ const seriesKeySet = new Set<string>();
167
+
168
+ const getDimensionKey = (task: Task, timeEntry?: TimeEntry): string => {
169
+ switch (groupBy) {
170
+ case 'assignee':
171
+ if (timeEntry?.userId) return timeEntry.userId;
172
+ return task.assigneeId || 'unassigned';
173
+ case 'team':
174
+ return task.teamId || (task.customFields?.teamId as string) || 'unassigned';
175
+ case 'taskType':
176
+ return task.taskType || (task.customFields?.type as string) || 'standard';
177
+ case 'priority':
178
+ return task.priority || 'none';
179
+ case 'status':
180
+ return task.status || 'todo';
181
+ default:
182
+ return 'unassigned';
183
+ }
184
+ };
185
+
186
+ const addValue = (bucket: InternalBucket, key: string, hours: number) => {
187
+ if (hours <= 0) return;
188
+ seriesKeySet.add(key);
189
+ bucket.values[key] = (bucket.values[key] || 0) + hours;
190
+ };
191
+
192
+ const todayStr = toDateString(new Date());
193
+ const taskMap = new Map<string, Task>(tasks.map((t) => [t.id, t]));
194
+
195
+ // 2. Process Logged Hours from TimeEntries
196
+ if (metric === 'logged' || metric === 'blended') {
197
+ for (const entry of timeEntries) {
198
+ if (!entry.hours || entry.hours <= 0) continue;
199
+ const entryDate = parseDateOnly(entry.loggedAt);
200
+ const bKey = getBucketKey(entryDate, interval);
201
+ const bucket = bucketMap.get(bKey);
202
+ if (bucket) {
203
+ const associatedTask = taskMap.get(entry.taskId) || ({
204
+ id: entry.taskId,
205
+ projectId: projectId || '',
206
+ title: '',
207
+ status: 'in_progress',
208
+ priority: 'medium',
209
+ createdAt: entry.loggedAt,
210
+ updatedAt: entry.loggedAt
211
+ } as Task);
212
+ const dimKey = getDimensionKey(associatedTask, entry);
213
+ addValue(bucket, dimKey, entry.hours);
214
+ }
215
+ }
216
+ }
217
+
218
+ // 3. Process Scheduled / Remaining / Blended Hours from Tasks
219
+ if (metric === 'scheduled' || metric === 'remaining' || metric === 'blended') {
220
+ for (const task of tasks) {
221
+ const estimated = getTaskDurationHours(task);
222
+ const logged = task.loggedHours || 0;
223
+ let hoursToDistribute = 0;
224
+
225
+ if (metric === 'scheduled') {
226
+ hoursToDistribute = estimated;
227
+ } else if (metric === 'remaining' || metric === 'blended') {
228
+ if (task.semanticStatus === 'completed' || task.semanticStatus === 'canceled') {
229
+ hoursToDistribute = 0;
230
+ } else {
231
+ hoursToDistribute = Math.max(0, estimated - logged);
232
+ }
233
+ }
234
+
235
+ if (hoursToDistribute <= 0) continue;
236
+
237
+ const dimKey = getDimensionKey(task);
238
+
239
+ // Determine task date range
240
+ let taskStart = task.plannedStartDate
241
+ ? parseDateOnly(task.plannedStartDate)
242
+ : task.actualStartDate
243
+ ? parseDateOnly(task.actualStartDate)
244
+ : task.createdAt
245
+ ? parseDateOnly(task.createdAt)
246
+ : minDate;
247
+
248
+ let taskEnd = task.dueDate
249
+ ? parseDateOnly(task.dueDate)
250
+ : taskStart
251
+ ? addDays(taskStart, Math.max(1, Math.ceil(hoursToDistribute / 8)))
252
+ : maxDate;
253
+
254
+ // In blended mode, uncompleted tasks' remaining hours start from today or planned start (whichever is later)
255
+ if (metric === 'blended') {
256
+ const today = parseDateOnly(todayStr);
257
+ if (taskStart < today) {
258
+ taskStart = today;
259
+ }
260
+ if (taskEnd < taskStart) {
261
+ taskEnd = addDays(taskStart, 1);
262
+ }
263
+ }
264
+
265
+ if (taskEnd < taskStart) {
266
+ taskEnd = taskStart;
267
+ }
268
+
269
+ // Resolve schedule for task assignee / team / project
270
+ const taskSchedule =
271
+ (task.assigneeId && options.userSchedules?.[task.assigneeId]) ||
272
+ (task.assigneeId && userMap.get(task.assigneeId)?.schedule) ||
273
+ (task.teamId && options.teamSchedules?.[task.teamId]) ||
274
+ (task.teamId && teamMap.get(task.teamId)?.schedule) ||
275
+ options.schedule ||
276
+ DEFAULT_WORK_SCHEDULE;
277
+
278
+ const workingDays = getWorkingDaysList(taskStart, taskEnd, taskSchedule);
279
+ const msPerDay = 1000 * 60 * 60 * 24;
280
+
281
+ if (workingDays.length > 0) {
282
+ const hoursPerWorkingDay = hoursToDistribute / workingDays.length;
283
+
284
+ for (const bucket of bucketList) {
285
+ const bucketStart = bucket.startDate.getTime();
286
+ const bucketEnd = bucket.endDate.getTime();
287
+
288
+ let workingDaysInBucket = 0;
289
+ for (const wdStr of workingDays) {
290
+ const wdTime = parseDateOnly(wdStr).getTime();
291
+ if (wdTime >= bucketStart && wdTime < bucketEnd) {
292
+ workingDaysInBucket++;
293
+ }
294
+ }
295
+
296
+ if (workingDaysInBucket > 0) {
297
+ const bucketEffort = workingDaysInBucket * hoursPerWorkingDay;
298
+ addValue(bucket, dimKey, bucketEffort);
299
+ }
300
+ }
301
+ } else {
302
+ // Fallback if no working days in range (e.g. task scheduled on an all-holiday window)
303
+ const totalDays = Math.max(1, Math.round((taskEnd.getTime() - taskStart.getTime()) / msPerDay) + 1);
304
+ const hoursPerDay = hoursToDistribute / totalDays;
305
+
306
+ for (const bucket of bucketList) {
307
+ const bucketLastDay = addDays(bucket.endDate, -1);
308
+ const overlapStart = new Date(Math.max(taskStart.getTime(), bucket.startDate.getTime()));
309
+ const overlapEnd = new Date(Math.min(taskEnd.getTime(), bucketLastDay.getTime()));
310
+
311
+ if (overlapStart <= overlapEnd) {
312
+ const overlapDays = Math.round((overlapEnd.getTime() - overlapStart.getTime()) / msPerDay) + 1;
313
+ const bucketEffort = overlapDays * hoursPerDay;
314
+ addValue(bucket, dimKey, bucketEffort);
315
+ }
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ // Ensure all registered users are in series keys if grouping by assignee
322
+ if (groupBy === 'assignee' && users.length > 0) {
323
+ for (const u of users) {
324
+ seriesKeySet.add(u.id);
325
+ }
326
+ }
327
+ if (groupBy === 'team' && teams.length > 0) {
328
+ for (const t of teams) {
329
+ seriesKeySet.add(t.id);
330
+ }
331
+ }
332
+
333
+ const seriesKeys = Array.from(seriesKeySet).sort();
334
+
335
+ // Create human-readable series labels
336
+ const seriesLabels: Record<string, string> = {};
337
+ for (const key of seriesKeys) {
338
+ if (groupBy === 'assignee') {
339
+ const u = userMap.get(key);
340
+ seriesLabels[key] = u ? u.name : key === 'unassigned' ? 'Unassigned' : key;
341
+ } else if (groupBy === 'team') {
342
+ const t = teamMap.get(key);
343
+ seriesLabels[key] = t ? t.name : key === 'unassigned' ? 'Unassigned' : key;
344
+ } else {
345
+ seriesLabels[key] = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
346
+ }
347
+ }
348
+
349
+ // 4. Calculate Capacity & Normalize Buckets
350
+ let totalDistributedHours = 0;
351
+ let sumCapacityAcrossBuckets = 0;
352
+
353
+ const buckets: WorkloadBucket[] = bucketList.map((ib) => {
354
+ const values: Record<string, number> = {};
355
+ const capacity: Record<string, number> = {};
356
+ let bucketTotalHours = 0;
357
+ let bucketTotalCapacity = 0;
358
+
359
+ for (const key of seriesKeys) {
360
+ const rawHours = ib.values[key] || 0;
361
+ const roundedHours = Math.round(rawHours * 100) / 100;
362
+ values[key] = roundedHours;
363
+ bucketTotalHours += roundedHours;
364
+
365
+ // Capacity calculation
366
+ const entitySchedule =
367
+ (groupBy === 'assignee' && (options.userSchedules?.[key] || userMap.get(key)?.schedule)) ||
368
+ (groupBy === 'team' && (options.teamSchedules?.[key] || teamMap.get(key)?.schedule)) ||
369
+ options.schedule ||
370
+ DEFAULT_WORK_SCHEDULE;
371
+
372
+ let weeklyCap = defaultWeeklyCapacity;
373
+ if (options.capacityOverrides && typeof options.capacityOverrides[key] === 'number') {
374
+ weeklyCap = options.capacityOverrides[key];
375
+ } else if (groupBy === 'assignee') {
376
+ const u = userMap.get(key);
377
+ if (u?.weeklyCapacityHours) weeklyCap = u.weeklyCapacityHours;
378
+ else if (key === 'unassigned') weeklyCap = 0;
379
+ } else if (groupBy === 'team') {
380
+ const t = teamMap.get(key);
381
+ if (t?.weeklyCapacityHours) {
382
+ weeklyCap = t.weeklyCapacityHours;
383
+ } else if (t?.memberIds?.length) {
384
+ weeklyCap = t.memberIds.length * defaultWeeklyCapacity;
385
+ } else if (key === 'unassigned') {
386
+ weeklyCap = 0;
387
+ }
388
+ }
389
+
390
+ let bucketCap = 0;
391
+ if (key === 'unassigned') {
392
+ bucketCap = 0;
393
+ } else if (interval === 'week') {
394
+ const netWeekCap = getNetAvailableCapacity(ib.startDate, addDays(ib.endDate, -1), entitySchedule);
395
+ const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
396
+ const scale = baseline > 0 ? weeklyCap / baseline : 1;
397
+ bucketCap = Math.round(netWeekCap * scale * 100) / 100;
398
+ } else if (interval === 'day') {
399
+ const dayHours = getWorkingHoursInDay(ib.startDate, entitySchedule);
400
+ const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
401
+ const scale = baseline > 0 ? weeklyCap / baseline : 1;
402
+ bucketCap = Math.round(dayHours * scale * 100) / 100;
403
+ } else if (interval === 'month') {
404
+ const netMonthCap = getNetAvailableCapacity(ib.startDate, addDays(ib.endDate, -1), entitySchedule);
405
+ const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
406
+ const scale = baseline > 0 ? weeklyCap / baseline : 1;
407
+ bucketCap = Math.round(netMonthCap * scale * 100) / 100;
408
+ }
409
+
410
+ capacity[key] = bucketCap;
411
+ bucketTotalCapacity += bucketCap;
412
+ }
413
+
414
+ bucketTotalHours = Math.round(bucketTotalHours * 100) / 100;
415
+ bucketTotalCapacity = Math.round(bucketTotalCapacity * 100) / 100;
416
+ totalDistributedHours += bucketTotalHours;
417
+ sumCapacityAcrossBuckets += bucketTotalCapacity;
418
+
419
+ const utilizationRatio =
420
+ bucketTotalCapacity > 0
421
+ ? Math.round((bucketTotalHours / bucketTotalCapacity) * 1000) / 1000
422
+ : undefined;
423
+
424
+ return {
425
+ date: ib.date,
426
+ timestamp: ib.timestamp,
427
+ totalHours: bucketTotalHours,
428
+ values,
429
+ capacity,
430
+ totalCapacity: bucketTotalCapacity,
431
+ utilizationRatio
432
+ };
433
+ });
434
+
435
+ totalDistributedHours = Math.round(totalDistributedHours * 100) / 100;
436
+ sumCapacityAcrossBuckets = Math.round(sumCapacityAcrossBuckets * 100) / 100;
437
+
438
+ const averageUtilization =
439
+ sumCapacityAcrossBuckets > 0
440
+ ? Math.round((totalDistributedHours / sumCapacityAcrossBuckets) * 1000) / 1000
441
+ : undefined;
442
+
443
+ return {
444
+ projectId,
445
+ startDate: toDateString(minDate),
446
+ endDate: toDateString(maxDate),
447
+ interval,
448
+ groupBy,
449
+ metric,
450
+ seriesKeys,
451
+ seriesLabels,
452
+ buckets,
453
+ totalHours: totalDistributedHours,
454
+ totalCapacity: sumCapacityAcrossBuckets,
455
+ averageUtilization
456
+ };
457
+ }
@@ -30,7 +30,9 @@ import type {
30
30
  StandardTaskTimelineItem,
31
31
  ConcreteTaskEvidence,
32
32
  TaskMetrics,
33
- TaskProgressHistory
33
+ TaskProgressHistory,
34
+ WorkloadDistribution,
35
+ WorkloadDistributionOptions
34
36
  } from '../types/index.js';
35
37
  import { StorageAdapter, InMemoryStore } from '../store/index.js';
36
38
  import { PluginRegistry } from '../plugins/index.js';
@@ -49,6 +51,7 @@ import {
49
51
  TaskCreatedEvent,
50
52
  TaskUpdatedEvent,
51
53
  TaskStatusChangedEvent,
54
+ TaskBlockedEvent,
52
55
  TaskUnblockedEvent,
53
56
  TaskDeletedEvent,
54
57
  ProjectCreatedEvent,
@@ -77,15 +80,17 @@ import {
77
80
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
78
81
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
79
82
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
80
- import { calculateCPM } from '../domain/cpm.js';
83
+ import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
81
84
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
82
85
  import {
83
86
  calculateTaskMetrics,
84
87
  reconstructTaskProgressHistory,
85
88
  type MetricOptions
86
89
  } from '../domain/metrics.js';
90
+ import { calculateWorkloadDistribution } from '../domain/workload.js';
87
91
 
88
92
  export class CriticalPathEngine {
93
+ public readonly config: CriticalPathConfig;
89
94
  public readonly store: StorageAdapter;
90
95
  public readonly fileStorage?: FileStorageAdapter;
91
96
  public readonly plugins: PluginRegistry;
@@ -93,6 +98,7 @@ export class CriticalPathEngine {
93
98
  public readonly ready: Promise<void> = Promise.resolve();
94
99
 
95
100
  constructor(config: CriticalPathConfig = {}) {
101
+ this.config = config;
96
102
  this.store = typeof config.store === 'object' && config.store !== null
97
103
  ? (config.store as StorageAdapter)
98
104
  : new InMemoryStore();
@@ -528,6 +534,58 @@ export class CriticalPathEngine {
528
534
  await this.events.publish(updateEvent);
529
535
  }
530
536
 
537
+ const wasBlocked = Boolean(existing.isBlocked || existing.customFields?.isBlocked || existing.status === 'blocked');
538
+ const isNowBlocked = Boolean(updated.isBlocked || updated.customFields?.isBlocked || updated.status === 'blocked');
539
+
540
+ if (!wasBlocked && isNowBlocked) {
541
+ const blockedReason = updated.blockedReason ?? (updated.customFields?.blockedReason as string | undefined) ?? null;
542
+ const blockedEvent: TaskBlockedEvent = {
543
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
544
+ name: 'task.blocked',
545
+ aggregateId: updated.id,
546
+ aggregateType: 'Task',
547
+ occurredAt: now,
548
+ payload: {
549
+ task: updated,
550
+ reason: blockedReason
551
+ }
552
+ };
553
+ await this.events.publish(blockedEvent);
554
+ await this.store.logActivity({
555
+ projectId: updated.projectId,
556
+ taskId: updated.id,
557
+ actorId: updated.assigneeId || 'system',
558
+ action: 'task.blocked',
559
+ details: { reason: blockedReason }
560
+ });
561
+ this.dispatchWebhook('task.blocked', {
562
+ task: updated,
563
+ reason: blockedReason
564
+ });
565
+ } else if (wasBlocked && !isNowBlocked) {
566
+ const unblockedEvent: TaskUnblockedEvent = {
567
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
568
+ name: 'task.unblocked',
569
+ aggregateId: updated.id,
570
+ aggregateType: 'Task',
571
+ occurredAt: now,
572
+ payload: {
573
+ task: updated
574
+ }
575
+ };
576
+ await this.events.publish(unblockedEvent);
577
+ await this.store.logActivity({
578
+ projectId: updated.projectId,
579
+ taskId: updated.id,
580
+ actorId: updated.assigneeId || 'system',
581
+ action: 'task.unblocked',
582
+ details: {}
583
+ });
584
+ this.dispatchWebhook('task.unblocked', {
585
+ task: updated
586
+ });
587
+ }
588
+
531
589
  await this.store.logActivity({
532
590
  projectId: updated.projectId,
533
591
  taskId: updated.id,
@@ -574,6 +632,11 @@ export class CriticalPathEngine {
574
632
  updates.status = defaultStatus;
575
633
  }
576
634
 
635
+ if (downstream.isBlocked || downstream.blockedReason) {
636
+ updates.isBlocked = false;
637
+ updates.blockedReason = null;
638
+ }
639
+
577
640
  if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
578
641
  updates.customFields = {
579
642
  ...downstream.customFields,
@@ -1376,7 +1439,11 @@ export class CriticalPathEngine {
1376
1439
  }
1377
1440
 
1378
1441
  // --- Ladder of Abstraction & Critical Path Method ---
1379
- async calculateCriticalPath(projectId: string): Promise<CriticalPathAnalysis> {
1442
+ async calculateCriticalPath(
1443
+ projectId: string,
1444
+ options: CPMOptions = {}
1445
+ ): Promise<CriticalPathAnalysis> {
1446
+ const project = await this.store.getProject(projectId);
1380
1447
  const tasks = await this.store.getTasks(projectId);
1381
1448
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
1382
1449
  const seenDepIds = new Set<string>();
@@ -1389,7 +1456,9 @@ export class CriticalPathEngine {
1389
1456
  }
1390
1457
  }
1391
1458
  }
1392
- return calculateCPM(projectId, tasks, dependencies);
1459
+ const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1460
+ const projectStartDate = options.projectStartDate || project?.startDate;
1461
+ return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
1393
1462
  }
1394
1463
 
1395
1464
  async getTimelineLadder(
@@ -1432,6 +1501,8 @@ export class CriticalPathEngine {
1432
1501
  const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
1433
1502
  const timeEntries: TimeEntry[] = timeEntriesNested.flat();
1434
1503
 
1504
+ const schedule = project.schedule || this.config.defaultSchedule;
1505
+
1435
1506
  return buildTimelineLadder(
1436
1507
  {
1437
1508
  project,
@@ -1442,7 +1513,8 @@ export class CriticalPathEngine {
1442
1513
  deliverables,
1443
1514
  attachments,
1444
1515
  timeEntries,
1445
- activities
1516
+ activities,
1517
+ schedule
1446
1518
  },
1447
1519
  options
1448
1520
  );
@@ -1509,6 +1581,32 @@ export class CriticalPathEngine {
1509
1581
  });
1510
1582
  }
1511
1583
 
1584
+ async getWorkloadDistribution(
1585
+ projectId?: string,
1586
+ options: WorkloadDistributionOptions = {}
1587
+ ): Promise<WorkloadDistribution> {
1588
+ const tasks = await this.store.getTasks(projectId);
1589
+ const teams = await this.store.getTeams();
1590
+ const project = projectId ? await this.store.getProject(projectId) : undefined;
1591
+ const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1592
+
1593
+ const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
1594
+ const timeEntries: TimeEntry[] = timeEntriesNested.flat();
1595
+
1596
+ return calculateWorkloadDistribution(
1597
+ {
1598
+ tasks,
1599
+ timeEntries,
1600
+ teams,
1601
+ projectId
1602
+ },
1603
+ {
1604
+ ...options,
1605
+ schedule
1606
+ }
1607
+ );
1608
+ }
1609
+
1512
1610
  private async dispatchWebhook(event: WebhookEvent, payload: Record<string, unknown>): Promise<void> {
1513
1611
  const webhooks = await this.store.getWebhooks();
1514
1612
  const active = webhooks.filter((w) => w.active && w.events.includes(event));
@@ -40,8 +40,24 @@ describe('SQLiteStore', () => {
40
40
  expect(task.priority).toBe('high');
41
41
  expect(task.tags).toContain('sqlite');
42
42
 
43
- const updated = await store.updateTask(task.id, { status: 'in_progress' });
43
+ const updated = await store.updateTask(task.id, {
44
+ status: 'in_progress',
45
+ isBlocked: true,
46
+ blockedReason: 'Waiting for SQLite lock'
47
+ });
44
48
  expect(updated?.status).toBe('in_progress');
49
+ expect(updated?.isBlocked).toBe(true);
50
+ expect(updated?.blockedReason).toBe('Waiting for SQLite lock');
51
+
52
+ const fetchedTask = await store.getTask(task.id);
53
+ expect(fetchedTask?.isBlocked).toBe(true);
54
+ expect(fetchedTask?.blockedReason).toBe('Waiting for SQLite lock');
55
+
56
+ const unblocked = await store.updateTask(task.id, {
57
+ isBlocked: false,
58
+ blockedReason: null
59
+ });
60
+ expect(unblocked?.isBlocked).toBe(false);
45
61
 
46
62
  const projectTasks = await store.getTasks(project.id);
47
63
  expect(projectTasks).toHaveLength(1);