@critical-path/core 0.18.0 → 0.19.1

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 +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 +38 -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 +22 -2
  36. package/dist/engine/index.d.ts.map +1 -1
  37. package/dist/engine/index.js +119 -15
  38. package/dist/engine/index.js.map +1 -1
  39. package/dist/index.test.js +41 -0
  40. package/dist/index.test.js.map +1 -1
  41. package/dist/store/sqlite.d.ts.map +1 -1
  42. package/dist/store/sqlite.js +9 -3
  43. package/dist/store/sqlite.js.map +1 -1
  44. package/dist/store/sqlite.test.js +15 -1
  45. package/dist/store/sqlite.test.js.map +1 -1
  46. package/dist/task-state.test.js +49 -0
  47. package/dist/task-state.test.js.map +1 -1
  48. package/dist/types/index.d.ts +44 -1
  49. package/dist/types/index.d.ts.map +1 -1
  50. package/dist/utils/status.d.ts.map +1 -1
  51. package/dist/utils/status.js +5 -2
  52. package/dist/utils/status.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/domain/calendar.test.ts +164 -0
  55. package/src/domain/calendar.ts +457 -0
  56. package/src/domain/cpm.test.ts +61 -0
  57. package/src/domain/cpm.ts +47 -4
  58. package/src/domain/events.ts +27 -2
  59. package/src/domain/index.ts +1 -0
  60. package/src/domain/ladder.ts +25 -4
  61. package/src/domain/metrics.test.ts +6 -4
  62. package/src/domain/workload.test.ts +73 -0
  63. package/src/domain/workload.ts +73 -19
  64. package/src/engine/index.ts +152 -16
  65. package/src/index.test.ts +48 -0
  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 +48 -0
  70. package/src/utils/status.ts +7 -2
  71. package/tsconfig.tsbuildinfo +1 -1
@@ -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
  });
@@ -33,6 +33,32 @@ 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;
@@ -40,6 +66,7 @@ export interface User {
40
66
  avatarUrl?: string;
41
67
  role: Role;
42
68
  weeklyCapacityHours?: number;
69
+ schedule?: WorkSchedule;
43
70
  createdAt: string;
44
71
  }
45
72
 
@@ -50,6 +77,7 @@ export interface Team {
50
77
  leaderId?: string;
51
78
  memberIds: string[];
52
79
  weeklyCapacityHours?: number;
80
+ schedule?: WorkSchedule;
53
81
  createdAt: string;
54
82
  updatedAt: string;
55
83
  }
@@ -107,6 +135,9 @@ export interface Project {
107
135
  statusDefinitions?: StatusDefinition[];
108
136
  priorityDefinitions?: Array<{ key: string; label: string; level?: number }>;
109
137
  customFieldDefinitions?: CustomFieldDefinition[];
138
+ schedule?: WorkSchedule;
139
+ startDate?: string;
140
+ targetEndDate?: string;
110
141
  createdAt: string;
111
142
  updatedAt: string;
112
143
  }
@@ -185,6 +216,8 @@ export interface Task {
185
216
  billableDurationMinutes?: number;
186
217
  // Progress (0 to 100 percentage)
187
218
  progress?: number;
219
+ isBlocked?: boolean;
220
+ blockedReason?: string | null;
188
221
  tags?: string[];
189
222
  todos?: TaskTodoItem[];
190
223
  customFields?: Record<string, unknown>;
@@ -371,6 +404,7 @@ export type WebhookEvent =
371
404
  | 'task.updated'
372
405
  | 'task.deleted'
373
406
  | 'task.status_changed'
407
+ | 'task.blocked'
374
408
  | 'task.unblocked'
375
409
  | 'comment.created'
376
410
  | 'comment.updated'
@@ -415,6 +449,7 @@ export interface CriticalPathConfig {
415
449
  fileStorage?: FileStorageAdapter;
416
450
  plugins?: CriticalPathPlugin[];
417
451
  webhooks?: Omit<Webhook, 'id' | 'createdAt'>[];
452
+ defaultSchedule?: WorkSchedule;
418
453
  initialData?: {
419
454
  projects?: Project[];
420
455
  tasks?: Task[];
@@ -483,12 +518,21 @@ export interface TaskCriticalPathSchedule {
483
518
  lateFinish: number;
484
519
  totalSlack: number;
485
520
  isCritical: boolean;
521
+ durationHours?: number;
522
+ earlyStartDate?: string;
523
+ earlyFinishDate?: string;
524
+ lateStartDate?: string;
525
+ lateFinishDate?: string;
526
+ slackWorkingHours?: number;
486
527
  }
487
528
 
488
529
  export interface CriticalPathAnalysis {
489
530
  projectId: string;
490
531
  calculatedAt: string;
491
532
  totalDurationHours: number;
533
+ totalWorkingHours?: number;
534
+ projectStartDate?: string;
535
+ projectEndDate?: string;
492
536
  criticalTaskIds: string[];
493
537
  tasks: TaskCriticalPathSchedule[];
494
538
  }
@@ -523,6 +567,7 @@ export interface RealityDelta {
523
567
  effortVarianceHours?: number; // loggedHours - estimatedHours (effort variance)
524
568
  durationVarianceHours?: number; // actual duration hours - planned duration hours
525
569
  scheduleVarianceDays?: number; // days difference between planned/due vs actual
570
+ scheduleVarianceWorkingDays?: number; // working days difference between planned/due vs actual
526
571
  accuracyRatio?: number; // loggedHours / estimatedHours (estimation accuracy ratio)
527
572
  isOverdue: boolean;
528
573
  isOverEstimate: boolean;
@@ -669,5 +714,8 @@ export interface WorkloadDistributionOptions {
669
714
  metric?: WorkloadMetric; // default: 'blended'
670
715
  defaultWeeklyCapacityHours?: number; // default: 40
671
716
  capacityOverrides?: Record<string, number>; // e.g. { [assigneeOrTeamId]: weeklyCapacityHours }
717
+ schedule?: WorkSchedule;
718
+ userSchedules?: Record<string, WorkSchedule>;
719
+ teamSchedules?: Record<string, WorkSchedule>;
672
720
  }
673
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/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"}
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"}