@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
@@ -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
  });
@@ -33,12 +33,40 @@ export type TaskLifecycleState = TaskDerivedStatus;
33
33
 
34
34
  export type Role = 'admin' | 'project_manager' | 'contributor' | 'viewer';
35
35
 
36
+ export interface WorkingHoursRange {
37
+ start: string; // "09:00" (HH:MM 24-hour format)
38
+ end: string; // "17:00"
39
+ }
40
+
41
+ export interface DaySchedule {
42
+ dayOfWeek: number; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
43
+ isWorkingDay: boolean;
44
+ hours?: WorkingHoursRange[];
45
+ }
46
+
47
+ export interface Holiday {
48
+ date: string; // "YYYY-MM-DD"
49
+ name?: string;
50
+ halfDay?: boolean; // if true, counts as 50% working hours
51
+ }
52
+
53
+ export interface WorkSchedule {
54
+ id?: string;
55
+ name?: string;
56
+ timezone?: string; // Informational (e.g. "UTC", "America/New_York")
57
+ defaultHoursPerDay?: number; // Default: 8
58
+ days: DaySchedule[];
59
+ holidays?: Holiday[];
60
+ }
61
+
36
62
  export interface User {
37
63
  id: string;
38
64
  name: string;
39
65
  email: string;
40
66
  avatarUrl?: string;
41
67
  role: Role;
68
+ weeklyCapacityHours?: number;
69
+ schedule?: WorkSchedule;
42
70
  createdAt: string;
43
71
  }
44
72
 
@@ -48,6 +76,8 @@ export interface Team {
48
76
  description?: string;
49
77
  leaderId?: string;
50
78
  memberIds: string[];
79
+ weeklyCapacityHours?: number;
80
+ schedule?: WorkSchedule;
51
81
  createdAt: string;
52
82
  updatedAt: string;
53
83
  }
@@ -105,6 +135,9 @@ export interface Project {
105
135
  statusDefinitions?: StatusDefinition[];
106
136
  priorityDefinitions?: Array<{ key: string; label: string; level?: number }>;
107
137
  customFieldDefinitions?: CustomFieldDefinition[];
138
+ schedule?: WorkSchedule;
139
+ startDate?: string;
140
+ targetEndDate?: string;
108
141
  createdAt: string;
109
142
  updatedAt: string;
110
143
  }
@@ -183,6 +216,8 @@ export interface Task {
183
216
  billableDurationMinutes?: number;
184
217
  // Progress (0 to 100 percentage)
185
218
  progress?: number;
219
+ isBlocked?: boolean;
220
+ blockedReason?: string | null;
186
221
  tags?: string[];
187
222
  todos?: TaskTodoItem[];
188
223
  customFields?: Record<string, unknown>;
@@ -369,6 +404,7 @@ export type WebhookEvent =
369
404
  | 'task.updated'
370
405
  | 'task.deleted'
371
406
  | 'task.status_changed'
407
+ | 'task.blocked'
372
408
  | 'task.unblocked'
373
409
  | 'comment.created'
374
410
  | 'comment.updated'
@@ -413,6 +449,7 @@ export interface CriticalPathConfig {
413
449
  fileStorage?: FileStorageAdapter;
414
450
  plugins?: CriticalPathPlugin[];
415
451
  webhooks?: Omit<Webhook, 'id' | 'createdAt'>[];
452
+ defaultSchedule?: WorkSchedule;
416
453
  initialData?: {
417
454
  projects?: Project[];
418
455
  tasks?: Task[];
@@ -481,12 +518,21 @@ export interface TaskCriticalPathSchedule {
481
518
  lateFinish: number;
482
519
  totalSlack: number;
483
520
  isCritical: boolean;
521
+ durationHours?: number;
522
+ earlyStartDate?: string;
523
+ earlyFinishDate?: string;
524
+ lateStartDate?: string;
525
+ lateFinishDate?: string;
526
+ slackWorkingHours?: number;
484
527
  }
485
528
 
486
529
  export interface CriticalPathAnalysis {
487
530
  projectId: string;
488
531
  calculatedAt: string;
489
532
  totalDurationHours: number;
533
+ totalWorkingHours?: number;
534
+ projectStartDate?: string;
535
+ projectEndDate?: string;
490
536
  criticalTaskIds: string[];
491
537
  tasks: TaskCriticalPathSchedule[];
492
538
  }
@@ -521,6 +567,7 @@ export interface RealityDelta {
521
567
  effortVarianceHours?: number; // loggedHours - estimatedHours (effort variance)
522
568
  durationVarianceHours?: number; // actual duration hours - planned duration hours
523
569
  scheduleVarianceDays?: number; // days difference between planned/due vs actual
570
+ scheduleVarianceWorkingDays?: number; // working days difference between planned/due vs actual
524
571
  accuracyRatio?: number; // loggedHours / estimatedHours (estimation accuracy ratio)
525
572
  isOverdue: boolean;
526
573
  isOverEstimate: boolean;
@@ -630,3 +677,45 @@ export interface TaskLadderView {
630
677
  metrics?: TaskMetrics;
631
678
  }
632
679
 
680
+ export type WorkloadInterval = 'day' | 'week' | 'month';
681
+ export type WorkloadGroupBy = 'assignee' | 'team' | 'taskType' | 'priority' | 'status';
682
+ export type WorkloadMetric = 'scheduled' | 'logged' | 'remaining' | 'blended';
683
+
684
+ export interface WorkloadBucket {
685
+ date: string; // ISO date string e.g. "2026-09-14" (start of bucket)
686
+ timestamp: number; // Unix timestamp in ms
687
+ totalHours: number;
688
+ values: Record<string, number>; // Dimension key to hours: { [dimensionKey]: hours }
689
+ capacity?: Record<string, number>; // Dimension key to available capacity hours: { [dimensionKey]: capacityHours }
690
+ totalCapacity?: number;
691
+ utilizationRatio?: number; // totalHours / totalCapacity
692
+ }
693
+
694
+ export interface WorkloadDistribution {
695
+ projectId?: string;
696
+ startDate: string;
697
+ endDate: string;
698
+ interval: WorkloadInterval;
699
+ groupBy: WorkloadGroupBy;
700
+ metric: WorkloadMetric;
701
+ seriesKeys: string[]; // Unique series keys sorted across all buckets
702
+ seriesLabels: Record<string, string>; // Human-readable labels e.g. { [key]: "Alice" }
703
+ buckets: WorkloadBucket[];
704
+ totalHours: number;
705
+ totalCapacity?: number;
706
+ averageUtilization?: number;
707
+ }
708
+
709
+ export interface WorkloadDistributionOptions {
710
+ startDate?: string;
711
+ endDate?: string;
712
+ interval?: WorkloadInterval; // default: 'week'
713
+ groupBy?: WorkloadGroupBy; // default: 'assignee'
714
+ metric?: WorkloadMetric; // default: 'blended'
715
+ defaultWeeklyCapacityHours?: number; // default: 40
716
+ capacityOverrides?: Record<string, number>; // e.g. { [assigneeOrTeamId]: weeklyCapacityHours }
717
+ schedule?: WorkSchedule;
718
+ userSchedules?: Record<string, WorkSchedule>;
719
+ teamSchedules?: Record<string, WorkSchedule>;
720
+ }
721
+
@@ -88,8 +88,13 @@ export function deriveTaskLifecycleState(
88
88
  return upstreamDef.category !== 'completed';
89
89
  });
90
90
  const blockingTaskIds = blockingTasks.map((t) => t.id);
91
- const isBlocked = !isDone && blockingTaskIds.length > 0;
92
- const isReady = semanticStatus === 'not_started' && blockingTaskIds.length === 0;
91
+ const isExplicitlyBlocked = Boolean(
92
+ task.isBlocked === true ||
93
+ task.customFields?.isBlocked === true ||
94
+ task.status === 'blocked'
95
+ );
96
+ const isBlocked = !isDone && (blockingTaskIds.length > 0 || isExplicitlyBlocked);
97
+ const isReady = semanticStatus === 'not_started' && !isBlocked;
93
98
 
94
99
  // Time & Schedule Indicators
95
100
  let isOverdue = false;
@@ -1 +1 @@
1
- {"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
1
+ {"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}