@critical-path/core 0.18.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 (68) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +43 -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 +1 -0
  21. package/dist/domain/index.d.ts.map +1 -1
  22. package/dist/domain/index.js +1 -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.map +1 -1
  31. package/dist/domain/workload.js +60 -17
  32. package/dist/domain/workload.js.map +1 -1
  33. package/dist/domain/workload.test.js +60 -0
  34. package/dist/domain/workload.test.js.map +1 -1
  35. package/dist/engine/index.d.ts +3 -1
  36. package/dist/engine/index.d.ts.map +1 -1
  37. package/dist/engine/index.js +71 -4
  38. package/dist/engine/index.js.map +1 -1
  39. package/dist/store/sqlite.d.ts.map +1 -1
  40. package/dist/store/sqlite.js +9 -3
  41. package/dist/store/sqlite.js.map +1 -1
  42. package/dist/store/sqlite.test.js +15 -1
  43. package/dist/store/sqlite.test.js.map +1 -1
  44. package/dist/task-state.test.js +49 -0
  45. package/dist/task-state.test.js.map +1 -1
  46. package/dist/types/index.d.ts +44 -1
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/utils/status.d.ts.map +1 -1
  49. package/dist/utils/status.js +5 -2
  50. package/dist/utils/status.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/domain/calendar.test.ts +164 -0
  53. package/src/domain/calendar.ts +457 -0
  54. package/src/domain/cpm.test.ts +61 -0
  55. package/src/domain/cpm.ts +47 -4
  56. package/src/domain/events.ts +11 -1
  57. package/src/domain/index.ts +1 -0
  58. package/src/domain/ladder.ts +25 -4
  59. package/src/domain/metrics.test.ts +6 -4
  60. package/src/domain/workload.test.ts +73 -0
  61. package/src/domain/workload.ts +73 -19
  62. package/src/engine/index.ts +79 -5
  63. package/src/store/sqlite.test.ts +17 -1
  64. package/src/store/sqlite.ts +11 -1
  65. package/src/task-state.test.ts +60 -1
  66. package/src/types/index.ts +48 -0
  67. package/src/utils/status.ts +7 -2
  68. package/tsconfig.tsbuildinfo +1 -1
@@ -17,9 +17,11 @@ import type {
17
17
  ConcreteTaskEvidence,
18
18
  ConcreteEvidenceSummary,
19
19
  RealityDelta,
20
- TaskLadderView
20
+ TaskLadderView,
21
+ WorkSchedule
21
22
  } from '../types/index.js';
22
23
  import { calculateCPM, getTaskDurationHours } from './cpm.js';
24
+ import { DEFAULT_WORK_SCHEDULE, getWorkingDaysBetween } from './calendar.js';
23
25
 
24
26
  export interface LadderContext {
25
27
  project: Project;
@@ -31,6 +33,7 @@ export interface LadderContext {
31
33
  attachments?: Attachment[];
32
34
  timeEntries?: TimeEntry[];
33
35
  activities?: Activity[];
36
+ schedule?: WorkSchedule;
34
37
  }
35
38
 
36
39
  export function aggregateConcreteEvidenceForTask(
@@ -38,7 +41,8 @@ export function aggregateConcreteEvidenceForTask(
38
41
  attachments: Attachment[] = [],
39
42
  deliverables: Deliverable[] = [],
40
43
  timeEntries: TimeEntry[] = [],
41
- activities: Activity[] = []
44
+ activities: Activity[] = [],
45
+ schedule?: WorkSchedule
42
46
  ): ConcreteTaskEvidence {
43
47
  const taskAttachments = attachments.filter((a) => a.taskId === task.id);
44
48
  const taskDeliverables = deliverables.filter(
@@ -70,10 +74,19 @@ export function aggregateConcreteEvidenceForTask(
70
74
  const isOverEstimate = Boolean(estimatedHours > 0 && effectiveLoggedHours > estimatedHours);
71
75
 
72
76
  let scheduleVarianceDays: number | undefined;
77
+ let scheduleVarianceWorkingDays: number | undefined;
73
78
  if (task.dueDate && task.actualEndDate) {
74
79
  const dueTime = new Date(task.dueDate).getTime();
75
80
  const actualTime = new Date(task.actualEndDate).getTime();
76
81
  scheduleVarianceDays = Math.round((actualTime - dueTime) / (1000 * 60 * 60 * 24));
82
+ const effectiveSched = schedule || DEFAULT_WORK_SCHEDULE;
83
+ if (actualTime > dueTime) {
84
+ scheduleVarianceWorkingDays = Math.max(0, getWorkingDaysBetween(task.dueDate, task.actualEndDate, effectiveSched) - 1);
85
+ } else if (actualTime < dueTime) {
86
+ scheduleVarianceWorkingDays = -Math.max(0, getWorkingDaysBetween(task.actualEndDate, task.dueDate, effectiveSched) - 1);
87
+ } else {
88
+ scheduleVarianceWorkingDays = 0;
89
+ }
77
90
  }
78
91
 
79
92
  let durationVarianceHours: number | undefined;
@@ -100,6 +113,7 @@ export function aggregateConcreteEvidenceForTask(
100
113
  effortVarianceHours: varianceHours,
101
114
  durationVarianceHours,
102
115
  scheduleVarianceDays,
116
+ scheduleVarianceWorkingDays,
103
117
  accuracyRatio,
104
118
  isOverdue,
105
119
  isOverEstimate
@@ -309,8 +323,14 @@ export function buildTimelineLadder(
309
323
  const targetLevel = options.level || 'all';
310
324
  const generatedAt = new Date().toISOString();
311
325
 
326
+ const effectiveSchedule = context.schedule || project.schedule || DEFAULT_WORK_SCHEDULE;
327
+ const projectStartDate = project.startDate || (tasks.length > 0 ? tasks.find((t) => t.plannedStartDate)?.plannedStartDate : undefined);
328
+
312
329
  // Run Critical Path Method (CPM)
313
- const cpm = calculateCPM(project.id, tasks, dependencies);
330
+ const cpm = calculateCPM(project.id, tasks, dependencies, {
331
+ schedule: effectiveSchedule,
332
+ projectStartDate
333
+ });
314
334
  const criticalSet = new Set(cpm.criticalTaskIds);
315
335
  const scheduleMap = new Map(cpm.tasks.map((s) => [s.taskId, s]));
316
336
 
@@ -352,7 +372,8 @@ export function buildTimelineLadder(
352
372
  attachments,
353
373
  deliverables,
354
374
  timeEntries,
355
- activities
375
+ activities,
376
+ effectiveSchedule
356
377
  );
357
378
  }
358
379
 
@@ -148,6 +148,8 @@ describe('Task Metrics Domain', () => {
148
148
  });
149
149
 
150
150
  describe('reconstructTaskProgressHistory & Curve Profile', () => {
151
+ const referenceDate = new Date('2026-09-09T12:00:00Z');
152
+
151
153
  it('detects linear progress', () => {
152
154
  const activities: Activity[] = [
153
155
  { id: 'a1', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 25 }, createdAt: '2026-09-02T10:00:00Z' },
@@ -156,7 +158,7 @@ describe('Task Metrics Domain', () => {
156
158
  { id: 'a4', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 100 }, createdAt: '2026-09-08T10:00:00Z' }
157
159
  ];
158
160
  const task: Task = { ...baseTask, progress: 100, updatedAt: '2026-09-08T10:00:00Z' };
159
- const history = reconstructTaskProgressHistory(task, activities);
161
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
160
162
 
161
163
  expect(history.points.length).toBeGreaterThanOrEqual(4);
162
164
  expect(history.curveProfile).toBe('linear');
@@ -170,7 +172,7 @@ describe('Task Metrics Domain', () => {
170
172
  { id: 'a4', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 95 }, createdAt: '2026-09-09T10:00:00Z' }
171
173
  ];
172
174
  const task: Task = { ...baseTask, progress: 95, updatedAt: '2026-09-09T10:00:00Z' };
173
- const history = reconstructTaskProgressHistory(task, activities);
175
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
174
176
 
175
177
  expect(history.curveProfile).toBe('s_curve');
176
178
  });
@@ -182,7 +184,7 @@ describe('Task Metrics Domain', () => {
182
184
  { id: 'a3', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 78 }, createdAt: '2026-09-08T10:00:00Z' }
183
185
  ];
184
186
  const task: Task = { ...baseTask, progress: 78, updatedAt: '2026-09-08T10:00:00Z' };
185
- const history = reconstructTaskProgressHistory(task, activities);
187
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
186
188
 
187
189
  expect(history.curveProfile).toBe('early_surge');
188
190
  });
@@ -194,7 +196,7 @@ describe('Task Metrics Domain', () => {
194
196
  { id: 'a3', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 90 }, createdAt: '2026-09-08T10:00:00Z' }
195
197
  ];
196
198
  const task: Task = { ...baseTask, progress: 90, updatedAt: '2026-09-08T10:00:00Z' };
197
- const history = reconstructTaskProgressHistory(task, activities);
199
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
198
200
 
199
201
  expect(history.curveProfile).toBe('late_rush');
200
202
  });
@@ -235,4 +235,77 @@ describe('calculateWorkloadDistribution', () => {
235
235
  expect(distribution.buckets.length).toBeGreaterThan(0);
236
236
  expect(distribution.seriesKeys).toContain('u1');
237
237
  });
238
+
239
+ it('distributes task effort only on working days and reduces capacity on holidays', () => {
240
+ // Friday 2026-09-18 to Tuesday 2026-09-22 (5 calendar days: Fri, Sat, Sun, Mon, Tue)
241
+ // Monday 2026-09-21 is a Holiday!
242
+ // Active working days: Friday 2026-09-18 and Tuesday 2026-09-22 (2 days total)
243
+ // 16 estimated hours should be divided into 8h on Fri, 0h on Sat, 0h on Sun, 0h on Mon, 8h on Tue!
244
+ const customSchedule = {
245
+ name: 'Holiday Calendar',
246
+ defaultHoursPerDay: 8,
247
+ days: [
248
+ { dayOfWeek: 0, isWorkingDay: false },
249
+ { dayOfWeek: 1, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
250
+ { dayOfWeek: 2, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
251
+ { dayOfWeek: 3, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
252
+ { dayOfWeek: 4, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
253
+ { dayOfWeek: 5, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
254
+ { dayOfWeek: 6, isWorkingDay: false }
255
+ ],
256
+ holidays: [{ date: '2026-09-21', name: 'Monday Holiday' }]
257
+ };
258
+
259
+ const tasks: Task[] = [
260
+ {
261
+ id: 't_holiday',
262
+ projectId: 'p1',
263
+ title: 'Holiday Spanning Task',
264
+ status: 'todo',
265
+ priority: 'high',
266
+ assigneeId: 'u2',
267
+ estimatedHours: 16,
268
+ plannedStartDate: '2026-09-18', // Friday
269
+ dueDate: '2026-09-22', // Tuesday
270
+ createdAt: '2026-09-01',
271
+ updatedAt: '2026-09-01'
272
+ }
273
+ ];
274
+
275
+ const result = calculateWorkloadDistribution(
276
+ { tasks, users },
277
+ {
278
+ startDate: '2026-09-18',
279
+ endDate: '2026-09-22',
280
+ interval: 'day',
281
+ groupBy: 'assignee',
282
+ metric: 'scheduled',
283
+ schedule: customSchedule
284
+ }
285
+ );
286
+
287
+ const fri = result.buckets.find((b) => b.date === '2026-09-18');
288
+ const sat = result.buckets.find((b) => b.date === '2026-09-19');
289
+ const sun = result.buckets.find((b) => b.date === '2026-09-20');
290
+ const mon = result.buckets.find((b) => b.date === '2026-09-21');
291
+ const tue = result.buckets.find((b) => b.date === '2026-09-22');
292
+
293
+ expect(fri?.values.u2).toBe(8);
294
+ expect(fri?.capacity?.u2).toBe(8);
295
+
296
+ expect(sat?.values.u2).toBe(0);
297
+ expect(sat?.capacity?.u2).toBe(0);
298
+
299
+ expect(sun?.values.u2).toBe(0);
300
+ expect(sun?.capacity?.u2).toBe(0);
301
+
302
+ // Monday is holiday: 0 scheduled effort, 0 capacity
303
+ expect(mon?.values.u2).toBe(0);
304
+ expect(mon?.capacity?.u2).toBe(0);
305
+
306
+ expect(tue?.values.u2).toBe(8);
307
+ expect(tue?.capacity?.u2).toBe(8);
308
+
309
+ expect(result.totalHours).toBe(16);
310
+ });
238
311
  });
@@ -10,6 +10,12 @@ import type {
10
10
  WorkloadDistribution,
11
11
  WorkloadDistributionOptions
12
12
  } from '../types/index.js';
13
+ import {
14
+ DEFAULT_WORK_SCHEDULE,
15
+ getWorkingDaysList,
16
+ getNetAvailableCapacity,
17
+ getWorkingHoursInDay
18
+ } from './calendar.js';
13
19
 
14
20
  export interface WorkloadContext {
15
21
  tasks: Task[];
@@ -260,21 +266,53 @@ export function calculateWorkloadDistribution(
260
266
  taskEnd = taskStart;
261
267
  }
262
268
 
263
- // Calculate days in task window
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);
264
279
  const msPerDay = 1000 * 60 * 60 * 24;
265
- const totalDays = Math.max(1, Math.round((taskEnd.getTime() - taskStart.getTime()) / msPerDay) + 1);
266
- const hoursPerDay = hoursToDistribute / totalDays;
267
-
268
- // Distribute across overlapping buckets
269
- for (const bucket of bucketList) {
270
- const bucketLastDay = addDays(bucket.endDate, -1);
271
- const overlapStart = new Date(Math.max(taskStart.getTime(), bucket.startDate.getTime()));
272
- const overlapEnd = new Date(Math.min(taskEnd.getTime(), bucketLastDay.getTime()));
273
-
274
- if (overlapStart <= overlapEnd) {
275
- const overlapDays = Math.round((overlapEnd.getTime() - overlapStart.getTime()) / msPerDay) + 1;
276
- const bucketEffort = overlapDays * hoursPerDay;
277
- addValue(bucket, dimKey, bucketEffort);
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
+ }
278
316
  }
279
317
  }
280
318
  }
@@ -325,6 +363,12 @@ export function calculateWorkloadDistribution(
325
363
  bucketTotalHours += roundedHours;
326
364
 
327
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
+
328
372
  let weeklyCap = defaultWeeklyCapacity;
329
373
  if (options.capacityOverrides && typeof options.capacityOverrides[key] === 'number') {
330
374
  weeklyCap = options.capacityOverrides[key];
@@ -344,13 +388,23 @@ export function calculateWorkloadDistribution(
344
388
  }
345
389
 
346
390
  let bucketCap = 0;
347
- if (interval === 'week') {
348
- bucketCap = weeklyCap;
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;
349
398
  } else if (interval === 'day') {
350
- bucketCap = Math.round((weeklyCap / 5) * 100) / 100;
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;
351
403
  } else if (interval === 'month') {
352
- const daysInMonth = new Date(Date.UTC(ib.startDate.getUTCFullYear(), ib.startDate.getUTCMonth() + 1, 0)).getUTCDate();
353
- bucketCap = Math.round((weeklyCap * (daysInMonth / 7)) * 100) / 100;
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;
354
408
  }
355
409
 
356
410
  capacity[key] = bucketCap;
@@ -51,6 +51,7 @@ import {
51
51
  TaskCreatedEvent,
52
52
  TaskUpdatedEvent,
53
53
  TaskStatusChangedEvent,
54
+ TaskBlockedEvent,
54
55
  TaskUnblockedEvent,
55
56
  TaskDeletedEvent,
56
57
  ProjectCreatedEvent,
@@ -79,7 +80,7 @@ import {
79
80
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
80
81
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
81
82
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
82
- import { calculateCPM } from '../domain/cpm.js';
83
+ import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
83
84
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
84
85
  import {
85
86
  calculateTaskMetrics,
@@ -89,6 +90,7 @@ import {
89
90
  import { calculateWorkloadDistribution } from '../domain/workload.js';
90
91
 
91
92
  export class CriticalPathEngine {
93
+ public readonly config: CriticalPathConfig;
92
94
  public readonly store: StorageAdapter;
93
95
  public readonly fileStorage?: FileStorageAdapter;
94
96
  public readonly plugins: PluginRegistry;
@@ -96,6 +98,7 @@ export class CriticalPathEngine {
96
98
  public readonly ready: Promise<void> = Promise.resolve();
97
99
 
98
100
  constructor(config: CriticalPathConfig = {}) {
101
+ this.config = config;
99
102
  this.store = typeof config.store === 'object' && config.store !== null
100
103
  ? (config.store as StorageAdapter)
101
104
  : new InMemoryStore();
@@ -531,6 +534,58 @@ export class CriticalPathEngine {
531
534
  await this.events.publish(updateEvent);
532
535
  }
533
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
+
534
589
  await this.store.logActivity({
535
590
  projectId: updated.projectId,
536
591
  taskId: updated.id,
@@ -577,6 +632,11 @@ export class CriticalPathEngine {
577
632
  updates.status = defaultStatus;
578
633
  }
579
634
 
635
+ if (downstream.isBlocked || downstream.blockedReason) {
636
+ updates.isBlocked = false;
637
+ updates.blockedReason = null;
638
+ }
639
+
580
640
  if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
581
641
  updates.customFields = {
582
642
  ...downstream.customFields,
@@ -1379,7 +1439,11 @@ export class CriticalPathEngine {
1379
1439
  }
1380
1440
 
1381
1441
  // --- Ladder of Abstraction & Critical Path Method ---
1382
- 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);
1383
1447
  const tasks = await this.store.getTasks(projectId);
1384
1448
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
1385
1449
  const seenDepIds = new Set<string>();
@@ -1392,7 +1456,9 @@ export class CriticalPathEngine {
1392
1456
  }
1393
1457
  }
1394
1458
  }
1395
- 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 });
1396
1462
  }
1397
1463
 
1398
1464
  async getTimelineLadder(
@@ -1435,6 +1501,8 @@ export class CriticalPathEngine {
1435
1501
  const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
1436
1502
  const timeEntries: TimeEntry[] = timeEntriesNested.flat();
1437
1503
 
1504
+ const schedule = project.schedule || this.config.defaultSchedule;
1505
+
1438
1506
  return buildTimelineLadder(
1439
1507
  {
1440
1508
  project,
@@ -1445,7 +1513,8 @@ export class CriticalPathEngine {
1445
1513
  deliverables,
1446
1514
  attachments,
1447
1515
  timeEntries,
1448
- activities
1516
+ activities,
1517
+ schedule
1449
1518
  },
1450
1519
  options
1451
1520
  );
@@ -1518,6 +1587,8 @@ export class CriticalPathEngine {
1518
1587
  ): Promise<WorkloadDistribution> {
1519
1588
  const tasks = await this.store.getTasks(projectId);
1520
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;
1521
1592
 
1522
1593
  const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
1523
1594
  const timeEntries: TimeEntry[] = timeEntriesNested.flat();
@@ -1529,7 +1600,10 @@ export class CriticalPathEngine {
1529
1600
  teams,
1530
1601
  projectId
1531
1602
  },
1532
- options
1603
+ {
1604
+ ...options,
1605
+ schedule
1606
+ }
1533
1607
  );
1534
1608
  }
1535
1609
 
@@ -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);
@@ -132,6 +132,8 @@ export class SQLiteStore implements StorageAdapter {
132
132
  actualDurationMinutes REAL,
133
133
  billableDurationMinutes REAL,
134
134
  progress REAL,
135
+ isBlocked INTEGER,
136
+ blockedReason TEXT,
135
137
  tags TEXT,
136
138
  customFields TEXT,
137
139
  parentId TEXT,
@@ -659,8 +661,9 @@ export class SQLiteStore implements StorageAdapter {
659
661
  reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
660
662
  dueDate, estimatedHours, loggedHours, actualHours, billableHours,
661
663
  estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes, progress,
664
+ isBlocked, blockedReason,
662
665
  tags, customFields, parentId, createdAt, updatedAt
663
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
666
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
664
667
  `);
665
668
  stmt.run(
666
669
  newTask.id,
@@ -690,6 +693,8 @@ export class SQLiteStore implements StorageAdapter {
690
693
  newTask.actualDurationMinutes ?? null,
691
694
  newTask.billableDurationMinutes ?? null,
692
695
  newTask.progress ?? null,
696
+ newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
697
+ newTask.blockedReason ?? null,
693
698
  JSON.stringify(newTask.tags || []),
694
699
  JSON.stringify(newTask.customFields || {}),
695
700
  newTask.parentId || null,
@@ -716,6 +721,7 @@ export class SQLiteStore implements StorageAdapter {
716
721
  plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
717
722
  estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
718
723
  estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?, progress = ?,
724
+ isBlocked = ?, blockedReason = ?,
719
725
  tags = ?, customFields = ?, parentId = ?, updatedAt = ?
720
726
  WHERE id = ?
721
727
  `);
@@ -746,6 +752,8 @@ export class SQLiteStore implements StorageAdapter {
746
752
  updated.actualDurationMinutes ?? null,
747
753
  updated.billableDurationMinutes ?? null,
748
754
  updated.progress ?? null,
755
+ updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
756
+ updated.blockedReason ?? null,
749
757
  JSON.stringify(updated.tags || []),
750
758
  JSON.stringify(updated.customFields || {}),
751
759
  updated.parentId || null,
@@ -1188,6 +1196,8 @@ export class SQLiteStore implements StorageAdapter {
1188
1196
  private mapTask(row: any): Task {
1189
1197
  return {
1190
1198
  ...row,
1199
+ isBlocked: row.isBlocked !== null && row.isBlocked !== undefined ? Boolean(row.isBlocked) : undefined,
1200
+ blockedReason: row.blockedReason || undefined,
1191
1201
  taskType: row.taskType || undefined,
1192
1202
  deliverableId: row.deliverableId || undefined,
1193
1203
  tags: row.tags ? JSON.parse(row.tags) : [],
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { CriticalPathEngine } from './engine/index.js';
3
3
  import { deriveTaskLifecycleState } from './utils/status.js';
4
- import type { Task, StatusDefinition, TaskUnblockedEvent } from './index.js';
4
+ import type { Task, StatusDefinition, TaskBlockedEvent, TaskUnblockedEvent } from './index.js';
5
5
 
6
6
  describe('Task State Behaviour & Dependency Validation', () => {
7
7
  const baseTask: Task = {
@@ -443,5 +443,64 @@ describe('Task State Behaviour & Dependency Validation', () => {
443
443
  expect(lifecycle?.isBlocked).toBe(false);
444
444
  expect(lifecycle?.isReady).toBe(true);
445
445
  });
446
+
447
+ it('manages first-class isBlocked and blockedReason independently of workflow status', async () => {
448
+ const engine = new CriticalPathEngine();
449
+ const project = await engine.createProject({ key: 'BLOCK', name: 'First Class Blocking' });
450
+
451
+ const blockedEvents: TaskBlockedEvent[] = [];
452
+ const unblockedEvents: TaskUnblockedEvent[] = [];
453
+ engine.events.subscribe<TaskBlockedEvent>('task.blocked', (evt) => {
454
+ blockedEvents.push(evt);
455
+ });
456
+ engine.events.subscribe<TaskUnblockedEvent>('task.unblocked', (evt) => {
457
+ unblockedEvents.push(evt);
458
+ });
459
+
460
+ // 1. Create a task in 'in_progress' status
461
+ const task = await engine.createTask({
462
+ projectId: project.id,
463
+ title: 'Work on feature',
464
+ status: 'in_progress'
465
+ });
466
+
467
+ let lifecycle = await engine.getTaskLifecycleState(task.id);
468
+ expect(lifecycle?.isBlocked).toBe(false);
469
+
470
+ // 2. Explicitly block the task while keeping status in_progress
471
+ const blockedTask = await engine.updateTask(task.id, {
472
+ isBlocked: true,
473
+ blockedReason: 'Awaiting third-party API key'
474
+ });
475
+ expect(blockedTask?.isBlocked).toBe(true);
476
+ expect(blockedTask?.blockedReason).toBe('Awaiting third-party API key');
477
+ expect(blockedTask?.status).toBe('in_progress');
478
+
479
+ // Verify domain event
480
+ expect(blockedEvents).toHaveLength(1);
481
+ expect(blockedEvents[0].aggregateId).toBe(task.id);
482
+ expect(blockedEvents[0].payload.reason).toBe('Awaiting third-party API key');
483
+
484
+ // Verify lifecycle state
485
+ lifecycle = await engine.getTaskLifecycleState(task.id);
486
+ expect(lifecycle?.isBlocked).toBe(true);
487
+ expect(lifecycle?.isReady).toBe(false);
488
+
489
+ // 3. Unblock the task
490
+ const unblockedTask = await engine.updateTask(task.id, {
491
+ isBlocked: false,
492
+ blockedReason: null
493
+ });
494
+ expect(unblockedTask?.isBlocked).toBe(false);
495
+ expect(unblockedTask?.blockedReason).toBeNull();
496
+ expect(unblockedTask?.status).toBe('in_progress');
497
+
498
+ // Verify domain event
499
+ expect(unblockedEvents).toHaveLength(1);
500
+ expect(unblockedEvents[0].aggregateId).toBe(task.id);
501
+
502
+ lifecycle = await engine.getTaskLifecycleState(task.id);
503
+ expect(lifecycle?.isBlocked).toBe(false);
504
+ });
446
505
  });
447
506
  });