@critical-path/core 0.20.0 → 0.20.2

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.
@@ -89,7 +89,11 @@ export class TaskEntity extends BaseEntity {
89
89
  public billableDurationMinutes?: number;
90
90
  public actualDurationSeconds?: number;
91
91
  public inProgressSince?: string | null;
92
+ public blockedDurationSeconds?: number;
93
+ public blockedSince?: string | null;
92
94
  public progress?: number;
95
+ public isBlocked?: boolean;
96
+ public blockedReason?: string | null;
93
97
  public tags?: string[];
94
98
  public todos?: TaskTodoItem[];
95
99
  public customFields?: Record<string, unknown>;
@@ -125,7 +129,11 @@ export class TaskEntity extends BaseEntity {
125
129
  this.billableDurationMinutes = data.billableDurationMinutes;
126
130
  this.actualDurationSeconds = data.actualDurationSeconds;
127
131
  this.inProgressSince = data.inProgressSince;
132
+ this.blockedDurationSeconds = data.blockedDurationSeconds;
133
+ this.blockedSince = data.blockedSince;
128
134
  this.progress = data.progress ?? 0;
135
+ this.isBlocked = data.isBlocked;
136
+ this.blockedReason = data.blockedReason;
129
137
  this.tags = data.tags ? [...data.tags] : [];
130
138
  this.todos = data.todos ? data.todos.map((t) => ({ ...t })) : [];
131
139
  this.customFields = data.customFields ? { ...data.customFields } : {};
@@ -149,6 +157,8 @@ export class TaskEntity extends BaseEntity {
149
157
  const actualStartDate = input.actualStartDate ?? (semanticStatus === 'in_progress' ? now : undefined);
150
158
  const inProgressSince = input.inProgressSince ?? (semanticStatus === 'in_progress' ? now : undefined);
151
159
  const actualEndDate = input.actualEndDate ?? ((semanticStatus === 'completed' || semanticStatus === 'canceled') ? now : undefined);
160
+ const isBlocked = input.isBlocked ?? false;
161
+ const blockedSince = input.blockedSince ?? (isBlocked && semanticStatus === 'in_progress' ? now : undefined);
152
162
 
153
163
  const task = new TaskEntity({
154
164
  ...input,
@@ -161,6 +171,10 @@ export class TaskEntity extends BaseEntity {
161
171
  inProgressSince,
162
172
  actualEndDate,
163
173
  actualDurationSeconds: input.actualDurationSeconds,
174
+ blockedDurationSeconds: input.blockedDurationSeconds,
175
+ blockedSince,
176
+ isBlocked,
177
+ blockedReason: input.blockedReason ?? null,
164
178
  priority: input.priority || 'medium',
165
179
  loggedHours: input.loggedHours ?? 0,
166
180
  progress: input.progress ?? 0,
@@ -216,6 +230,15 @@ export class TaskEntity extends BaseEntity {
216
230
  }
217
231
  this.inProgressSince = null;
218
232
  }
233
+ if (this.blockedSince) {
234
+ const startMs = new Date(this.blockedSince).getTime();
235
+ const endMs = new Date(now).getTime();
236
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
237
+ const blockedSeconds = Math.max(0, (endMs - startMs) / 1000);
238
+ this.blockedDurationSeconds = (this.blockedDurationSeconds || 0) + blockedSeconds;
239
+ }
240
+ this.blockedSince = null;
241
+ }
219
242
  }
220
243
 
221
244
  // 2. Entering in_progress: retain earliest start date & start session timer
@@ -226,6 +249,9 @@ export class TaskEntity extends BaseEntity {
226
249
  if (!this.inProgressSince) {
227
250
  this.inProgressSince = now;
228
251
  }
252
+ if (this.isBlocked && !this.blockedSince) {
253
+ this.blockedSince = now;
254
+ }
229
255
  if (prevStatusDef.category === 'completed' || prevStatusDef.category === 'canceled') {
230
256
  this.actualEndDate = undefined;
231
257
  }
@@ -394,6 +420,32 @@ export class TaskEntity extends BaseEntity {
394
420
  });
395
421
  }
396
422
 
423
+ const wasBlocked = Boolean(this.isBlocked);
424
+ const isNowBlocked = updates.isBlocked !== undefined ? Boolean(updates.isBlocked) : wasBlocked;
425
+ const now = new Date().toISOString();
426
+ const effectiveSemantic = updates.semanticStatus ?? this.semanticStatus;
427
+
428
+ if (effectiveSemantic === 'in_progress') {
429
+ if (!wasBlocked && isNowBlocked) {
430
+ if (!updates.blockedSince) {
431
+ updates.blockedSince = now;
432
+ }
433
+ } else if (wasBlocked && !isNowBlocked) {
434
+ if (this.blockedSince) {
435
+ const startMs = new Date(this.blockedSince).getTime();
436
+ const endMs = new Date(now).getTime();
437
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
438
+ const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
439
+ updates.blockedDurationSeconds =
440
+ (updates.blockedDurationSeconds ?? this.blockedDurationSeconds ?? 0) + sessionSeconds;
441
+ }
442
+ updates.blockedSince = null;
443
+ }
444
+ }
445
+ } else if (!isNowBlocked && this.blockedSince) {
446
+ updates.blockedSince = null;
447
+ }
448
+
397
449
  Object.assign(this, updates);
398
450
  if (updates.actualDurationSeconds !== undefined && updates.actualHours === undefined) {
399
451
  this.actualHours = Math.round((updates.actualDurationSeconds / 3600) * 100) / 100;
@@ -451,7 +503,11 @@ export class TaskEntity extends BaseEntity {
451
503
  billableDurationMinutes: this.billableDurationMinutes,
452
504
  actualDurationSeconds: this.actualDurationSeconds,
453
505
  inProgressSince: this.inProgressSince,
506
+ blockedDurationSeconds: this.blockedDurationSeconds,
507
+ blockedSince: this.blockedSince,
454
508
  progress: this.progress,
509
+ isBlocked: this.isBlocked,
510
+ blockedReason: this.blockedReason,
455
511
  tags: this.tags ? [...this.tags] : [],
456
512
  todos: this.todos ? this.todos.map((t) => ({ ...t })) : [],
457
513
  customFields: this.customFields ? { ...this.customFields } : {},
@@ -478,7 +478,8 @@ export function calculateTaskMetrics(
478
478
  isEndDateInferred: Boolean(task.actualEndDate && task.semanticStatus === 'completed'),
479
479
  activeWorkingHours,
480
480
  calendarDurationHours,
481
- actualDurationSeconds: task.actualDurationSeconds
481
+ actualDurationSeconds: task.actualDurationSeconds,
482
+ blockedDurationSeconds: task.blockedDurationSeconds
482
483
  };
483
484
 
484
485
  const progress = inferTaskProgress(task, options);
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
2
2
  import { TaskEntity } from './entities.js';
3
3
  import { CriticalPathEngine } from '../engine/index.js';
4
4
  import { InMemoryStore } from '../store/index.js';
5
+ import { SQLiteStore } from '../store/sqlite.js';
5
6
  import { DEFAULT_SOFTWARE_WORKFLOW } from '../utils/workflow.js';
6
7
 
7
8
  describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
@@ -167,4 +168,151 @@ describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
167
168
  vi.useRealTimers();
168
169
  }
169
170
  });
171
+
172
+ it('tracks blocked duration while task is in_progress', async () => {
173
+ vi.useFakeTimers();
174
+ try {
175
+ const store = new InMemoryStore();
176
+ const engine = new CriticalPathEngine({ store });
177
+
178
+ const project = await engine.createProject({
179
+ name: 'Blocked Project'
180
+ });
181
+
182
+ vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
183
+ const task = await engine.createTask({
184
+ projectId: project.id,
185
+ title: 'Blocked Tracking Feature'
186
+ });
187
+
188
+ // 1. Being blocked in 'todo' does NOT accumulate blocked duration or stamp blockedSince
189
+ await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'Waiting for spec' });
190
+ const todoBlocked = await engine.getTask(task.id);
191
+ expect(todoBlocked?.isBlocked).toBe(true);
192
+ expect(todoBlocked?.blockedSince).toBeUndefined();
193
+ expect(todoBlocked?.blockedDurationSeconds).toBeUndefined();
194
+
195
+ // 2. Unblock while still in todo
196
+ await engine.updateTask(task.id, { isBlocked: false, blockedReason: null });
197
+
198
+ // 3. Start task at 10:00:00 (in_progress)
199
+ const started = await engine.updateTask(task.id, { status: 'in_progress' });
200
+ expect(started?.inProgressSince).toBe('2026-09-24T10:00:00.000Z');
201
+ expect(started?.blockedSince).toBeUndefined();
202
+
203
+ // 4. Block task at 10:00:30 (30 seconds in)
204
+ vi.setSystemTime(new Date('2026-09-24T10:00:30Z'));
205
+ const blocked = await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'API down' });
206
+ expect(blocked?.isBlocked).toBe(true);
207
+ expect(blocked?.blockedSince).toBe('2026-09-24T10:00:30.000Z');
208
+
209
+ // 5. Unblock task at 10:01:00 (30 seconds blocked)
210
+ vi.setSystemTime(new Date('2026-09-24T10:01:00Z'));
211
+ const unblocked = await engine.updateTask(task.id, { isBlocked: false, blockedReason: null });
212
+ expect(unblocked?.isBlocked).toBe(false);
213
+ expect(unblocked?.blockedSince).toBeNull();
214
+ expect(unblocked?.blockedDurationSeconds).toBe(30);
215
+
216
+ // 6. Block again at 10:01:10
217
+ vi.setSystemTime(new Date('2026-09-24T10:01:10Z'));
218
+ const blockedAgain = await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'Review pending' });
219
+ expect(blockedAgain?.blockedSince).toBe('2026-09-24T10:01:10.000Z');
220
+
221
+ // 7. Complete task at 10:01:24 while still blocked (14 seconds blocked)
222
+ // Total gross in-progress: 84s (1m 24s)
223
+ // Total blocked duration: 30s + 14s = 44s
224
+ vi.setSystemTime(new Date('2026-09-24T10:01:24Z'));
225
+ const completed = await engine.updateTask(task.id, { status: 'done' });
226
+ expect(completed?.status).toBe('done');
227
+ expect(completed?.actualDurationSeconds).toBe(84);
228
+ expect(completed?.blockedDurationSeconds).toBe(44);
229
+ expect(completed?.blockedSince).toBeNull();
230
+
231
+ // 8. Verify metrics
232
+ const metrics = await engine.getTaskMetrics(task.id);
233
+ expect(metrics?.inferredActuals.actualDurationSeconds).toBe(84);
234
+ expect(metrics?.inferredActuals.blockedDurationSeconds).toBe(44);
235
+ } finally {
236
+ vi.useRealTimers();
237
+ }
238
+ });
239
+
240
+ it('persists and retrieves blocked duration fields in SQLiteStore', async () => {
241
+ const store = new SQLiteStore({ filename: ':memory:' });
242
+ const project = await store.createProject({ key: 'SQL', name: 'SQLite Project' });
243
+
244
+ const task = await store.createTask({
245
+ projectId: project.id,
246
+ title: 'SQLite Blocked Task',
247
+ status: 'in_progress',
248
+ priority: 'medium',
249
+ isBlocked: true,
250
+ blockedReason: 'Third party dependency',
251
+ blockedSince: '2026-09-24T12:00:00.000Z',
252
+ blockedDurationSeconds: 120
253
+ });
254
+
255
+ const retrieved = await store.getTask(task.id);
256
+ expect(retrieved?.isBlocked).toBe(true);
257
+ expect(retrieved?.blockedReason).toBe('Third party dependency');
258
+ expect(retrieved?.blockedSince).toBe('2026-09-24T12:00:00.000Z');
259
+ expect(retrieved?.blockedDurationSeconds).toBe(120);
260
+
261
+ const updated = await store.updateTask(task.id, {
262
+ isBlocked: false,
263
+ blockedReason: null,
264
+ blockedSince: null,
265
+ blockedDurationSeconds: 180
266
+ });
267
+ expect(updated?.isBlocked).toBe(false);
268
+ expect(updated?.blockedReason).toBeFalsy();
269
+ expect(updated?.blockedSince).toBeFalsy();
270
+ expect(updated?.blockedDurationSeconds).toBe(180);
271
+
272
+ const reloaded = await store.getTask(task.id);
273
+ expect(reloaded?.isBlocked).toBe(false);
274
+ expect(reloaded?.blockedReason).toBeUndefined();
275
+ expect(reloaded?.blockedSince).toBeUndefined();
276
+ expect(reloaded?.blockedDurationSeconds).toBe(180);
277
+ });
278
+
279
+ it('manages blocked state directly on TaskEntity transitions and updates', () => {
280
+ vi.useFakeTimers();
281
+ try {
282
+ vi.setSystemTime(new Date('2026-09-24T12:00:00Z'));
283
+ const task = TaskEntity.create({
284
+ projectId: 'proj_blk',
285
+ title: 'Entity Blocked Test',
286
+ status: 'in_progress'
287
+ });
288
+
289
+ // Block while in progress
290
+ vi.setSystemTime(new Date('2026-09-24T12:00:30Z'));
291
+ task.update({ isBlocked: true, blockedReason: 'Database migration' });
292
+ expect(task.isBlocked).toBe(true);
293
+ expect(task.blockedSince).toBe('2026-09-24T12:00:30.000Z');
294
+
295
+ // Unblock while in progress
296
+ vi.setSystemTime(new Date('2026-09-24T12:01:00Z'));
297
+ task.update({ isBlocked: false, blockedReason: null });
298
+ expect(task.isBlocked).toBe(false);
299
+ expect(task.blockedSince).toBeNull();
300
+ expect(task.blockedDurationSeconds).toBe(30);
301
+
302
+ // Block again
303
+ vi.setSystemTime(new Date('2026-09-24T12:01:10Z'));
304
+ task.update({ isBlocked: true, blockedReason: 'Code review' });
305
+ expect(task.blockedSince).toBe('2026-09-24T12:01:10.000Z');
306
+
307
+ // Transition to completed (done) while blocked
308
+ vi.setSystemTime(new Date('2026-09-24T12:01:30Z'));
309
+ task.transitionTo('done', DEFAULT_SOFTWARE_WORKFLOW);
310
+ expect(task.status).toBe('done');
311
+ expect(task.actualDurationSeconds).toBe(90);
312
+ expect(task.blockedDurationSeconds).toBe(50); // 30s + 20s
313
+ expect(task.blockedSince).toBeNull();
314
+ } finally {
315
+ vi.useRealTimers();
316
+ }
317
+ });
170
318
  });
@@ -374,6 +374,10 @@ export class CriticalPathEngine {
374
374
  const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
375
375
  const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
376
376
 
377
+ const isInitialBlocked = processedInput.isBlocked ?? taskInput.isBlocked ?? false;
378
+ const blockedSince = processedInput.blockedSince ?? taskInput.blockedSince ?? (isInitialBlocked && statusDef.category === 'in_progress' ? now : undefined);
379
+ const blockedDurationSeconds = processedInput.blockedDurationSeconds ?? taskInput.blockedDurationSeconds;
380
+
377
381
  const created = await this.store.createTask({
378
382
  projectId,
379
383
  title: processedInput.title || taskInput.title,
@@ -403,7 +407,11 @@ export class CriticalPathEngine {
403
407
  billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
404
408
  actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
405
409
  inProgressSince,
410
+ blockedDurationSeconds,
411
+ blockedSince,
406
412
  progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
413
+ isBlocked: isInitialBlocked,
414
+ blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
407
415
  tags: processedInput.tags ?? taskInput.tags ?? [],
408
416
  customFields: processedInput.customFields ?? taskInput.customFields ?? {},
409
417
  parentId: processedInput.parentId ?? taskInput.parentId
@@ -505,22 +513,25 @@ export class CriticalPathEngine {
505
513
 
506
514
  const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
507
515
 
508
- // Auto-update execution/completion timestamps if status changes
509
- if (processedUpdates.status && processedUpdates.status !== existing.status) {
510
- const statusDef = resolveStatusDefinition(
511
- processedUpdates.status,
512
- project?.statusDefinitions || workflow?.statuses
513
- );
514
- processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? statusDef.category;
515
- const now = new Date().toISOString();
516
+ const now = new Date().toISOString();
517
+ const existingStatusDef = resolveStatusDefinition(
518
+ existing.status,
519
+ project?.statusDefinitions || workflow?.statuses
520
+ );
521
+ const newStatusDef = processedUpdates.status
522
+ ? resolveStatusDefinition(
523
+ processedUpdates.status,
524
+ project?.statusDefinitions || workflow?.statuses
525
+ )
526
+ : undefined;
527
+ const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
516
528
 
517
- const existingStatusDef = resolveStatusDefinition(
518
- existing.status,
519
- project?.statusDefinitions || workflow?.statuses
520
- );
529
+ // Auto-update execution/completion timestamps if status changes
530
+ if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
531
+ processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? newStatusDef.category;
521
532
 
522
- // 1. Leaving in_progress: finalize active session duration
523
- if (existingStatusDef.category === 'in_progress' && statusDef.category !== 'in_progress') {
533
+ // 1. Leaving in_progress: finalize active session duration and blocked duration
534
+ if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
524
535
  const inProgressSince = existing.inProgressSince;
525
536
  if (inProgressSince) {
526
537
  const startMs = new Date(inProgressSince).getTime();
@@ -538,16 +549,34 @@ export class CriticalPathEngine {
538
549
  }
539
550
  processedUpdates.inProgressSince = null;
540
551
  }
552
+
553
+ const blockedSince = processedUpdates.blockedSince ?? existing.blockedSince;
554
+ if (blockedSince) {
555
+ const startMs = new Date(blockedSince).getTime();
556
+ const endMs = new Date(now).getTime();
557
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
558
+ const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
559
+ processedUpdates.blockedDurationSeconds =
560
+ (processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
561
+ }
562
+ processedUpdates.blockedSince = null;
563
+ }
541
564
  }
542
565
 
543
- // 2. Entering in_progress: retain earliest start date & start session timer
544
- if (statusDef.category === 'in_progress') {
566
+ // 2. Entering in_progress: retain earliest start date & start session timer (and blocked timer if blocked)
567
+ if (newStatusDef.category === 'in_progress') {
545
568
  if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
546
569
  processedUpdates.actualStartDate = now;
547
570
  }
548
571
  if (!processedUpdates.inProgressSince) {
549
572
  processedUpdates.inProgressSince = now;
550
573
  }
574
+ const isTaskBlocked = processedUpdates.isBlocked !== undefined
575
+ ? Boolean(processedUpdates.isBlocked)
576
+ : Boolean(existing.isBlocked);
577
+ if (isTaskBlocked && !processedUpdates.blockedSince) {
578
+ processedUpdates.blockedSince = now;
579
+ }
551
580
  if (
552
581
  (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
553
582
  processedUpdates.actualEndDate === undefined
@@ -556,21 +585,49 @@ export class CriticalPathEngine {
556
585
  }
557
586
  } else if (
558
587
  (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
559
- statusDef.category === 'not_started' &&
588
+ newStatusDef.category === 'not_started' &&
560
589
  processedUpdates.actualEndDate === undefined
561
590
  ) {
562
591
  processedUpdates.actualEndDate = undefined;
563
592
  }
564
593
 
565
594
  // 3. Entering completed or canceled
566
- if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
595
+ if ((newStatusDef.category === 'completed' || newStatusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
567
596
  processedUpdates.actualEndDate = now;
568
- if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
597
+ if (newStatusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
569
598
  processedUpdates.progress = 100;
570
599
  }
571
600
  }
572
601
  }
573
602
 
603
+ // Handle isBlocked transitions while in_progress (when in_progress, whether status changed or not)
604
+ if (effectiveCategory === 'in_progress') {
605
+ const wasBlocked = Boolean(existing.isBlocked);
606
+ const isNowBlocked = processedUpdates.isBlocked !== undefined
607
+ ? Boolean(processedUpdates.isBlocked)
608
+ : wasBlocked;
609
+
610
+ if (!wasBlocked && isNowBlocked) {
611
+ if (!processedUpdates.blockedSince) {
612
+ processedUpdates.blockedSince = now;
613
+ }
614
+ } else if (wasBlocked && !isNowBlocked) {
615
+ const blockedSince = existing.blockedSince ?? processedUpdates.blockedSince;
616
+ if (blockedSince) {
617
+ const startMs = new Date(blockedSince).getTime();
618
+ const endMs = new Date(now).getTime();
619
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
620
+ const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
621
+ processedUpdates.blockedDurationSeconds =
622
+ (processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
623
+ }
624
+ processedUpdates.blockedSince = null;
625
+ }
626
+ }
627
+ } else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
628
+ processedUpdates.blockedSince = null;
629
+ }
630
+
574
631
  // Mutual sync for manual duration/effort edits
575
632
  if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
576
633
  processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
@@ -589,7 +646,6 @@ export class CriticalPathEngine {
589
646
 
590
647
  await this.plugins.runAfterTaskUpdate(updated, existing);
591
648
 
592
- const now = new Date().toISOString();
593
649
  const isStatusChange = existing.status !== updated.status;
594
650
 
595
651
  // Publish typed Domain Events
@@ -636,11 +692,11 @@ export class CriticalPathEngine {
636
692
  await this.events.publish(updateEvent);
637
693
  }
638
694
 
639
- const wasBlocked = Boolean(existing.isBlocked || existing.customFields?.isBlocked || existing.status === 'blocked');
640
- const isNowBlocked = Boolean(updated.isBlocked || updated.customFields?.isBlocked || updated.status === 'blocked');
695
+ const wasBlocked = Boolean(existing.isBlocked);
696
+ const isNowBlocked = Boolean(updated.isBlocked);
641
697
 
642
698
  if (!wasBlocked && isNowBlocked) {
643
- const blockedReason = updated.blockedReason ?? (updated.customFields?.blockedReason as string | undefined) ?? null;
699
+ const blockedReason = updated.blockedReason ?? null;
644
700
  const blockedEvent: TaskBlockedEvent = {
645
701
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
646
702
  name: 'task.blocked',
@@ -739,22 +795,18 @@ export class CriticalPathEngine {
739
795
  let updatedDownstream = downstream;
740
796
  const updates: Partial<Task> = {};
741
797
 
742
- if (downstream.status === 'blocked') {
743
- const defaultStatus = downstreamWorkflow?.defaultStatusKey || 'todo';
744
- updates.status = defaultStatus;
745
- }
746
-
747
- if (downstream.isBlocked || downstream.blockedReason) {
798
+ if (downstream.isBlocked || downstream.blockedReason || downstream.blockedSince) {
748
799
  updates.isBlocked = false;
749
800
  updates.blockedReason = null;
750
- }
751
-
752
- if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
753
- updates.customFields = {
754
- ...downstream.customFields,
755
- isBlocked: false,
756
- blockedReason: null
757
- };
801
+ if (downstream.blockedSince) {
802
+ const startMs = new Date(downstream.blockedSince).getTime();
803
+ const endMs = new Date().getTime();
804
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
805
+ const blockedSeconds = Math.max(0, (endMs - startMs) / 1000);
806
+ updates.blockedDurationSeconds = (downstream.blockedDurationSeconds ?? 0) + blockedSeconds;
807
+ }
808
+ updates.blockedSince = null;
809
+ }
758
810
  }
759
811
 
760
812
  if (Object.keys(updates).length > 0) {
package/src/index.test.ts CHANGED
@@ -386,8 +386,9 @@ describe('CriticalPathEngine Core Tests', () => {
386
386
  const downstream = await engine.createTask({
387
387
  projectId: proj.id,
388
388
  title: 'Downstream Task',
389
- status: 'blocked',
390
- customFields: { isBlocked: true, blockedReason: 'Waiting on upstream' }
389
+ status: 'todo',
390
+ isBlocked: true,
391
+ blockedReason: 'Waiting on upstream'
391
392
  });
392
393
 
393
394
  await engine.addDependency({
@@ -412,7 +413,7 @@ describe('CriticalPathEngine Core Tests', () => {
412
413
 
413
414
  const refreshedDownstream = await engine.getTask(downstream.id);
414
415
  expect(refreshedDownstream?.status).toBe('todo');
415
- expect(refreshedDownstream?.customFields?.isBlocked).toBe(false);
416
+ expect(refreshedDownstream?.isBlocked).toBe(false);
416
417
  });
417
418
 
418
419
  it('allows VFX workflow transitions from revision_requested to in_production', async () => {
@@ -133,6 +133,8 @@ export class SQLiteStore implements StorageAdapter {
133
133
  billableDurationMinutes REAL,
134
134
  actualDurationSeconds REAL,
135
135
  inProgressSince TEXT,
136
+ blockedDurationSeconds REAL,
137
+ blockedSince TEXT,
136
138
  progress REAL,
137
139
  isBlocked INTEGER,
138
140
  blockedReason TEXT,
@@ -663,18 +665,18 @@ export class SQLiteStore implements StorageAdapter {
663
665
  reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
664
666
  dueDate, estimatedHours, loggedHours, actualHours, billableHours,
665
667
  estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
666
- actualDurationSeconds, inProgressSince, progress,
668
+ actualDurationSeconds, inProgressSince, blockedDurationSeconds, blockedSince, progress,
667
669
  isBlocked, blockedReason,
668
670
  tags, customFields, parentId, createdAt, updatedAt
669
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
671
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
670
672
  `);
671
673
  stmt.run(
672
674
  newTask.id,
673
675
  newTask.projectId,
674
676
  newTask.title,
675
677
  newTask.description || null,
676
- newTask.status,
677
- newTask.priority,
678
+ newTask.status || 'todo',
679
+ newTask.priority || 'medium',
678
680
  newTask.taskType || null,
679
681
  newTask.assigneeId || null,
680
682
  newTask.assignees ? JSON.stringify(newTask.assignees) : null,
@@ -697,6 +699,8 @@ export class SQLiteStore implements StorageAdapter {
697
699
  newTask.billableDurationMinutes ?? null,
698
700
  newTask.actualDurationSeconds ?? null,
699
701
  newTask.inProgressSince ?? null,
702
+ newTask.blockedDurationSeconds ?? null,
703
+ newTask.blockedSince ?? null,
700
704
  newTask.progress ?? null,
701
705
  newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
702
706
  newTask.blockedReason ?? null,
@@ -726,7 +730,7 @@ export class SQLiteStore implements StorageAdapter {
726
730
  plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
727
731
  estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
728
732
  estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
729
- actualDurationSeconds = ?, inProgressSince = ?, progress = ?,
733
+ actualDurationSeconds = ?, inProgressSince = ?, blockedDurationSeconds = ?, blockedSince = ?, progress = ?,
730
734
  isBlocked = ?, blockedReason = ?,
731
735
  tags = ?, customFields = ?, parentId = ?, updatedAt = ?
732
736
  WHERE id = ?
@@ -759,6 +763,8 @@ export class SQLiteStore implements StorageAdapter {
759
763
  updated.billableDurationMinutes ?? null,
760
764
  updated.actualDurationSeconds ?? null,
761
765
  updated.inProgressSince ?? null,
766
+ updated.blockedDurationSeconds ?? null,
767
+ updated.blockedSince ?? null,
762
768
  updated.progress ?? null,
763
769
  updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
764
770
  updated.blockedReason ?? null,
@@ -1204,6 +1210,10 @@ export class SQLiteStore implements StorageAdapter {
1204
1210
  private mapTask(row: any): Task {
1205
1211
  return {
1206
1212
  ...row,
1213
+ actualDurationSeconds: row.actualDurationSeconds ?? undefined,
1214
+ inProgressSince: row.inProgressSince || undefined,
1215
+ blockedDurationSeconds: row.blockedDurationSeconds ?? undefined,
1216
+ blockedSince: row.blockedSince || undefined,
1207
1217
  isBlocked: row.isBlocked !== null && row.isBlocked !== undefined ? Boolean(row.isBlocked) : undefined,
1208
1218
  blockedReason: row.blockedReason || undefined,
1209
1219
  taskType: row.taskType || undefined,
@@ -353,12 +353,13 @@ describe('Task State Behaviour & Dependency Validation', () => {
353
353
  status: 'todo'
354
354
  });
355
355
 
356
- // Create downstream task with status: 'blocked' and customFields.isBlocked = true
356
+ // Create downstream task with isBlocked = true
357
357
  const taskDownstream = await engine.createTask({
358
358
  projectId: project.id,
359
359
  title: 'Run Integration Tests',
360
- status: 'blocked',
361
- customFields: { isBlocked: true, blockedReason: 'Waiting on compilation' }
360
+ status: 'todo',
361
+ isBlocked: true,
362
+ blockedReason: 'Waiting on compilation'
362
363
  });
363
364
 
364
365
  await engine.addDependency({
@@ -382,11 +383,11 @@ describe('Task State Behaviour & Dependency Validation', () => {
382
383
  expect(unblockedEvents[0].aggregateId).toBe(taskDownstream.id);
383
384
  expect(unblockedEvents[0].payload.upstreamTaskId).toBe(taskUpstream.id);
384
385
 
385
- // Verify downstream task was automatically transitioned from 'blocked' to 'todo'
386
+ // Verify downstream task is unblocked
386
387
  const updatedDownstream = await engine.getTask(taskDownstream.id);
387
388
  expect(updatedDownstream?.status).toBe('todo');
388
- expect(updatedDownstream?.customFields?.isBlocked).toBe(false);
389
- expect(updatedDownstream?.customFields?.blockedReason).toBeNull();
389
+ expect(updatedDownstream?.isBlocked).toBe(false);
390
+ expect(updatedDownstream?.blockedReason).toBeNull();
390
391
 
391
392
  // Verify lifecycle state is ready and unblocked
392
393
  const lifecycle = await engine.getTaskLifecycleState(taskDownstream.id);
@@ -404,8 +405,8 @@ describe('Task State Behaviour & Dependency Validation', () => {
404
405
  const downstream = await engine.createTask({
405
406
  projectId: project.id,
406
407
  title: 'Final Assembly',
407
- status: 'blocked',
408
- customFields: { isBlocked: true }
408
+ status: 'todo',
409
+ isBlocked: true
409
410
  });
410
411
 
411
412
  await engine.addDependency({ taskId: downstream.id, dependsOnTaskId: up1.id, type: 'blocking' });
@@ -423,8 +424,7 @@ describe('Task State Behaviour & Dependency Validation', () => {
423
424
  expect(unblockedEvents).toHaveLength(0);
424
425
 
425
426
  let taskState = await engine.getTask(downstream.id);
426
- expect(taskState?.status).toBe('blocked');
427
- expect(taskState?.customFields?.isBlocked).toBe(true);
427
+ expect(taskState?.isBlocked).toBe(true);
428
428
 
429
429
  let lifecycle = await engine.getTaskLifecycleState(downstream.id);
430
430
  expect(lifecycle?.isBlocked).toBe(true);
@@ -437,7 +437,7 @@ describe('Task State Behaviour & Dependency Validation', () => {
437
437
  expect(unblockedEvents).toHaveLength(1);
438
438
  taskState = await engine.getTask(downstream.id);
439
439
  expect(taskState?.status).toBe('todo');
440
- expect(taskState?.customFields?.isBlocked).toBe(false);
440
+ expect(taskState?.isBlocked).toBe(false);
441
441
 
442
442
  lifecycle = await engine.getTaskLifecycleState(downstream.id);
443
443
  expect(lifecycle?.isBlocked).toBe(false);
@@ -218,6 +218,10 @@ export interface Task {
218
218
  actualDurationSeconds?: number;
219
219
  /** Transient ISO timestamp marking the start of current 'in_progress' session (null when not in_progress) */
220
220
  inProgressSince?: string | null;
221
+ /** Cumulative duration spent in blocked state while in progress (in seconds) */
222
+ blockedDurationSeconds?: number;
223
+ /** Transient ISO timestamp marking when the task became blocked while in progress (null when not blocked or not in_progress) */
224
+ blockedSince?: string | null;
221
225
  // Progress (0 to 100 percentage)
222
226
  progress?: number;
223
227
  isBlocked?: boolean;
@@ -634,6 +638,8 @@ export interface TaskInferredActuals {
634
638
  calendarDurationHours?: number;
635
639
  /** Cumulative active execution duration in seconds */
636
640
  actualDurationSeconds?: number;
641
+ /** Cumulative duration spent in blocked state while in progress (in seconds) */
642
+ blockedDurationSeconds?: number;
637
643
  }
638
644
 
639
645
  export interface TaskMetrics {