@critical-path/core 0.20.1 → 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.
@@ -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,8 +407,10 @@ 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),
407
- isBlocked: processedInput.isBlocked ?? taskInput.isBlocked ?? false,
413
+ isBlocked: isInitialBlocked,
408
414
  blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
409
415
  tags: processedInput.tags ?? taskInput.tags ?? [],
410
416
  customFields: processedInput.customFields ?? taskInput.customFields ?? {},
@@ -507,22 +513,25 @@ export class CriticalPathEngine {
507
513
 
508
514
  const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
509
515
 
510
- // Auto-update execution/completion timestamps if status changes
511
- if (processedUpdates.status && processedUpdates.status !== existing.status) {
512
- const statusDef = resolveStatusDefinition(
513
- processedUpdates.status,
514
- project?.statusDefinitions || workflow?.statuses
515
- );
516
- processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? statusDef.category;
517
- 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;
518
528
 
519
- const existingStatusDef = resolveStatusDefinition(
520
- existing.status,
521
- project?.statusDefinitions || workflow?.statuses
522
- );
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;
523
532
 
524
- // 1. Leaving in_progress: finalize active session duration
525
- 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') {
526
535
  const inProgressSince = existing.inProgressSince;
527
536
  if (inProgressSince) {
528
537
  const startMs = new Date(inProgressSince).getTime();
@@ -540,16 +549,34 @@ export class CriticalPathEngine {
540
549
  }
541
550
  processedUpdates.inProgressSince = null;
542
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
+ }
543
564
  }
544
565
 
545
- // 2. Entering in_progress: retain earliest start date & start session timer
546
- 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') {
547
568
  if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
548
569
  processedUpdates.actualStartDate = now;
549
570
  }
550
571
  if (!processedUpdates.inProgressSince) {
551
572
  processedUpdates.inProgressSince = now;
552
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
+ }
553
580
  if (
554
581
  (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
555
582
  processedUpdates.actualEndDate === undefined
@@ -558,21 +585,49 @@ export class CriticalPathEngine {
558
585
  }
559
586
  } else if (
560
587
  (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
561
- statusDef.category === 'not_started' &&
588
+ newStatusDef.category === 'not_started' &&
562
589
  processedUpdates.actualEndDate === undefined
563
590
  ) {
564
591
  processedUpdates.actualEndDate = undefined;
565
592
  }
566
593
 
567
594
  // 3. Entering completed or canceled
568
- if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
595
+ if ((newStatusDef.category === 'completed' || newStatusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
569
596
  processedUpdates.actualEndDate = now;
570
- if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
597
+ if (newStatusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
571
598
  processedUpdates.progress = 100;
572
599
  }
573
600
  }
574
601
  }
575
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
+
576
631
  // Mutual sync for manual duration/effort edits
577
632
  if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
578
633
  processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
@@ -591,7 +646,6 @@ export class CriticalPathEngine {
591
646
 
592
647
  await this.plugins.runAfterTaskUpdate(updated, existing);
593
648
 
594
- const now = new Date().toISOString();
595
649
  const isStatusChange = existing.status !== updated.status;
596
650
 
597
651
  // Publish typed Domain Events
@@ -741,9 +795,18 @@ export class CriticalPathEngine {
741
795
  let updatedDownstream = downstream;
742
796
  const updates: Partial<Task> = {};
743
797
 
744
- if (downstream.isBlocked || downstream.blockedReason) {
798
+ if (downstream.isBlocked || downstream.blockedReason || downstream.blockedSince) {
745
799
  updates.isBlocked = false;
746
800
  updates.blockedReason = null;
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
+ }
747
810
  }
748
811
 
749
812
  if (Object.keys(updates).length > 0) {
@@ -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,
@@ -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 {