@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
@@ -0,0 +1,457 @@
1
+ import type {
2
+ DaySchedule,
3
+ Holiday,
4
+ Project,
5
+ Team,
6
+ User,
7
+ WorkingHoursRange,
8
+ WorkSchedule
9
+ } from '../types/index.js';
10
+
11
+ export const DEFAULT_WORK_SCHEDULE: WorkSchedule = {
12
+ name: 'Standard 40-Hour Work Week',
13
+ defaultHoursPerDay: 8,
14
+ days: [
15
+ { dayOfWeek: 0, isWorkingDay: false }, // Sunday
16
+ { dayOfWeek: 1, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Monday
17
+ { dayOfWeek: 2, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Tuesday
18
+ { dayOfWeek: 3, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Wednesday
19
+ { dayOfWeek: 4, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Thursday
20
+ { dayOfWeek: 5, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }, // Friday
21
+ { dayOfWeek: 6, isWorkingDay: false } // Saturday
22
+ ],
23
+ holidays: []
24
+ };
25
+
26
+ export interface ScheduleResolutionContext {
27
+ user?: User | null;
28
+ team?: Team | null;
29
+ project?: Project | null;
30
+ defaultSchedule?: WorkSchedule | null;
31
+ }
32
+
33
+ /**
34
+ * Resolves the effective work schedule following the hierarchy:
35
+ * 1. User schedule (if present)
36
+ * 2. Team schedule (if present)
37
+ * 3. Project schedule (if present)
38
+ * 4. Engine / default schedule (fallback to DEFAULT_WORK_SCHEDULE)
39
+ */
40
+ export function resolveEffectiveSchedule(context?: ScheduleResolutionContext): WorkSchedule {
41
+ if (context?.user?.schedule) return context.user.schedule;
42
+ if (context?.team?.schedule) return context.team.schedule;
43
+ if (context?.project?.schedule) return context.project.schedule;
44
+ if (context?.defaultSchedule) return context.defaultSchedule;
45
+ return DEFAULT_WORK_SCHEDULE;
46
+ }
47
+
48
+ export function toDateString(d: Date): string {
49
+ return d.toISOString().split('T')[0];
50
+ }
51
+
52
+ export function parseDate(input: Date | string): Date {
53
+ if (input instanceof Date) {
54
+ return new Date(input.getTime());
55
+ }
56
+ if (!input.includes('T')) {
57
+ // Treat date-only string as UTC midnight
58
+ const parts = input.split('-');
59
+ return new Date(Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)));
60
+ }
61
+ return new Date(input);
62
+ }
63
+
64
+ function parseTimeToMinutes(timeStr: string): number {
65
+ const parts = timeStr.split(':');
66
+ const h = parseInt(parts[0], 10) || 0;
67
+ const m = parseInt(parts[1], 10) || 0;
68
+ return h * 60 + m;
69
+ }
70
+
71
+ function getDayConfig(dayOfWeek: number, schedule: WorkSchedule): DaySchedule | undefined {
72
+ return schedule.days.find((d) => d.dayOfWeek === dayOfWeek);
73
+ }
74
+
75
+ function getHoliday(dateStr: string, schedule: WorkSchedule): Holiday | undefined {
76
+ if (!schedule.holidays) return undefined;
77
+ return schedule.holidays.find((h) => h.date === dateStr);
78
+ }
79
+
80
+ /**
81
+ * Checks whether a given calendar date is an active working day under the provided schedule.
82
+ */
83
+ export function isWorkingDay(input: Date | string, schedule: WorkSchedule = DEFAULT_WORK_SCHEDULE): boolean {
84
+ const d = parseDate(input);
85
+ const dateStr = toDateString(d);
86
+ const holiday = getHoliday(dateStr, schedule);
87
+ if (holiday && !holiday.halfDay) {
88
+ return false;
89
+ }
90
+ const dayOfWeek = d.getUTCDay();
91
+ const dayConfig = getDayConfig(dayOfWeek, schedule);
92
+ if (!dayConfig || !dayConfig.isWorkingDay) {
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * Returns the effective working hours available on a specific calendar day.
100
+ */
101
+ export function getWorkingHoursInDay(input: Date | string, schedule: WorkSchedule = DEFAULT_WORK_SCHEDULE): number {
102
+ const d = parseDate(input);
103
+ const dateStr = toDateString(d);
104
+ const holiday = getHoliday(dateStr, schedule);
105
+ if (holiday && !holiday.halfDay) {
106
+ return 0;
107
+ }
108
+
109
+ const dayOfWeek = d.getUTCDay();
110
+ const dayConfig = getDayConfig(dayOfWeek, schedule);
111
+ if (!dayConfig || !dayConfig.isWorkingDay) {
112
+ return 0;
113
+ }
114
+
115
+ let baseHours = schedule.defaultHoursPerDay ?? 8;
116
+ if (dayConfig.hours && dayConfig.hours.length > 0) {
117
+ baseHours = dayConfig.hours.reduce((sum, range) => {
118
+ const startMin = parseTimeToMinutes(range.start);
119
+ const endMin = parseTimeToMinutes(range.end);
120
+ return sum + Math.max(0, (endMin - startMin) / 60);
121
+ }, 0);
122
+ }
123
+
124
+ if (holiday && holiday.halfDay) {
125
+ return Math.round((baseHours / 2) * 100) / 100;
126
+ }
127
+
128
+ return baseHours;
129
+ }
130
+
131
+ /**
132
+ * Returns the normalized working time windows (in minutes from UTC midnight) for a given date.
133
+ */
134
+ function getDayWorkingWindows(d: Date, schedule: WorkSchedule): Array<{ start: number; end: number }> {
135
+ const dateStr = toDateString(d);
136
+ const holiday = getHoliday(dateStr, schedule);
137
+ if (holiday && !holiday.halfDay) {
138
+ return [];
139
+ }
140
+
141
+ const dayOfWeek = d.getUTCDay();
142
+ const dayConfig = getDayConfig(dayOfWeek, schedule);
143
+ if (!dayConfig || !dayConfig.isWorkingDay) {
144
+ return [];
145
+ }
146
+
147
+ let ranges = dayConfig.hours && dayConfig.hours.length > 0
148
+ ? dayConfig.hours.map((r) => ({ start: parseTimeToMinutes(r.start), end: parseTimeToMinutes(r.end) }))
149
+ : [{ start: 9 * 60, end: (9 + (schedule.defaultHoursPerDay ?? 8)) * 60 }];
150
+
151
+ if (holiday && holiday.halfDay && ranges.length > 0) {
152
+ // Take the first half of the working hours
153
+ const totalMinutes = ranges.reduce((acc, r) => acc + (r.end - r.start), 0);
154
+ let targetMinutes = totalMinutes / 2;
155
+ const adjustedRanges: Array<{ start: number; end: number }> = [];
156
+ for (const r of ranges) {
157
+ const dur = r.end - r.start;
158
+ if (targetMinutes <= 0) break;
159
+ if (dur <= targetMinutes) {
160
+ adjustedRanges.push(r);
161
+ targetMinutes -= dur;
162
+ } else {
163
+ adjustedRanges.push({ start: r.start, end: r.start + targetMinutes });
164
+ targetMinutes = 0;
165
+ }
166
+ }
167
+ return adjustedRanges;
168
+ }
169
+
170
+ return ranges;
171
+ }
172
+
173
+ /**
174
+ * Advances a start date forward by a given number of working hours,
175
+ * skipping non-working days, holidays, and non-working hours.
176
+ */
177
+ export function addWorkingHours(
178
+ startInput: Date | string,
179
+ hoursToAdd: number,
180
+ schedule: WorkSchedule = DEFAULT_WORK_SCHEDULE
181
+ ): Date {
182
+ if (hoursToAdd <= 0) {
183
+ return parseDate(startInput);
184
+ }
185
+
186
+ let current = parseDate(startInput);
187
+ let remainingMinutes = Math.round(hoursToAdd * 60);
188
+
189
+ // Safety guard against infinite loops
190
+ let daysScanned = 0;
191
+ const maxDays = 3650; // 10 years
192
+
193
+ while (remainingMinutes > 0 && daysScanned < maxDays) {
194
+ const windows = getDayWorkingWindows(current, schedule);
195
+
196
+ if (windows.length === 0) {
197
+ // Non-working day or holiday: move to start of next UTC day
198
+ current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() + 1, 0, 0, 0, 0));
199
+ daysScanned++;
200
+ continue;
201
+ }
202
+
203
+ const currentMinuteOfDay = current.getUTCHours() * 60 + current.getUTCMinutes() + current.getUTCSeconds() / 60;
204
+
205
+ for (let i = 0; i < windows.length && remainingMinutes > 0; i++) {
206
+ const w = windows[i];
207
+ if (currentMinuteOfDay >= w.end) {
208
+ // Already past this window
209
+ continue;
210
+ }
211
+
212
+ // If current time is before the window starts, jump to window start
213
+ const windowStartMin = Math.max(w.start, currentMinuteOfDay);
214
+ const availableMinutes = w.end - windowStartMin;
215
+
216
+ if (remainingMinutes <= availableMinutes) {
217
+ const finalMinuteOfDay = windowStartMin + remainingMinutes;
218
+ const finalHour = Math.floor(finalMinuteOfDay / 60);
219
+ const finalMin = Math.round(finalMinuteOfDay % 60);
220
+ return new Date(Date.UTC(
221
+ current.getUTCFullYear(),
222
+ current.getUTCMonth(),
223
+ current.getUTCDate(),
224
+ finalHour,
225
+ finalMin,
226
+ 0,
227
+ 0
228
+ ));
229
+ } else {
230
+ remainingMinutes -= availableMinutes;
231
+ // Advance current time to end of this window
232
+ current = new Date(Date.UTC(
233
+ current.getUTCFullYear(),
234
+ current.getUTCMonth(),
235
+ current.getUTCDate(),
236
+ Math.floor(w.end / 60),
237
+ Math.round(w.end % 60),
238
+ 0,
239
+ 0
240
+ ));
241
+ }
242
+ }
243
+
244
+ // Finished processing windows for today; advance to tomorrow at 00:00
245
+ current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() + 1, 0, 0, 0, 0));
246
+ daysScanned++;
247
+ }
248
+
249
+ return current;
250
+ }
251
+
252
+ /**
253
+ * Moves backward from an end date by a given number of working hours,
254
+ * skipping non-working days, holidays, and non-working hours.
255
+ */
256
+ export function subtractWorkingHours(
257
+ endInput: Date | string,
258
+ hoursToSubtract: number,
259
+ schedule: WorkSchedule = DEFAULT_WORK_SCHEDULE
260
+ ): Date {
261
+ if (hoursToSubtract <= 0) {
262
+ return parseDate(endInput);
263
+ }
264
+
265
+ let current = parseDate(endInput);
266
+ let remainingMinutes = Math.round(hoursToSubtract * 60);
267
+
268
+ let daysScanned = 0;
269
+ const maxDays = 3650;
270
+
271
+ while (remainingMinutes > 0 && daysScanned < maxDays) {
272
+ const windows = getDayWorkingWindows(current, schedule);
273
+
274
+ if (windows.length === 0) {
275
+ // Non-working day: move to end of previous UTC day (23:59:59)
276
+ current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() - 1, 23, 59, 59, 999));
277
+ daysScanned++;
278
+ continue;
279
+ }
280
+
281
+ const currentMinuteOfDay = current.getUTCHours() * 60 + current.getUTCMinutes() + current.getUTCSeconds() / 60;
282
+
283
+ // Traverse windows backwards
284
+ for (let i = windows.length - 1; i >= 0 && remainingMinutes > 0; i--) {
285
+ const w = windows[i];
286
+ if (currentMinuteOfDay <= w.start) {
287
+ continue;
288
+ }
289
+
290
+ const windowEndMin = Math.min(w.end, currentMinuteOfDay);
291
+ const availableMinutes = windowEndMin - w.start;
292
+
293
+ if (remainingMinutes <= availableMinutes) {
294
+ const finalMinuteOfDay = windowEndMin - remainingMinutes;
295
+ const finalHour = Math.floor(finalMinuteOfDay / 60);
296
+ const finalMin = Math.round(finalMinuteOfDay % 60);
297
+ return new Date(Date.UTC(
298
+ current.getUTCFullYear(),
299
+ current.getUTCMonth(),
300
+ current.getUTCDate(),
301
+ finalHour,
302
+ finalMin,
303
+ 0,
304
+ 0
305
+ ));
306
+ } else {
307
+ remainingMinutes -= availableMinutes;
308
+ current = new Date(Date.UTC(
309
+ current.getUTCFullYear(),
310
+ current.getUTCMonth(),
311
+ current.getUTCDate(),
312
+ Math.floor(w.start / 60),
313
+ Math.round(w.start % 60),
314
+ 0,
315
+ 0
316
+ ));
317
+ }
318
+ }
319
+
320
+ // Move to end of previous day
321
+ current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() - 1, 23, 59, 59, 999));
322
+ daysScanned++;
323
+ }
324
+
325
+ return current;
326
+ }
327
+
328
+ /**
329
+ * Calculates the exact number of active working hours between two dates under the schedule.
330
+ */
331
+ export function getWorkingHoursBetween(
332
+ startInput: Date | string,
333
+ endInput: Date | string,
334
+ schedule: WorkSchedule = DEFAULT_WORK_SCHEDULE
335
+ ): number {
336
+ const start = parseDate(startInput);
337
+ const end = parseDate(endInput);
338
+
339
+ if (end <= start) {
340
+ return 0;
341
+ }
342
+
343
+ let totalMinutes = 0;
344
+ let current = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), 0, 0, 0, 0));
345
+ const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate(), 0, 0, 0, 0));
346
+
347
+ let days = 0;
348
+ while (current <= endDay && days < 3650) {
349
+ const windows = getDayWorkingWindows(current, schedule);
350
+ const isFirstDay = current.getTime() === Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), 0, 0, 0, 0);
351
+ const isLastDay = current.getTime() === endDay.getTime();
352
+
353
+ const startMin = isFirstDay ? start.getUTCHours() * 60 + start.getUTCMinutes() : 0;
354
+ const endMin = isLastDay ? end.getUTCHours() * 60 + end.getUTCMinutes() : 24 * 60;
355
+
356
+ for (const w of windows) {
357
+ const activeStart = Math.max(w.start, startMin);
358
+ const activeEnd = Math.min(w.end, endMin);
359
+ if (activeEnd > activeStart) {
360
+ totalMinutes += (activeEnd - activeStart);
361
+ }
362
+ }
363
+
364
+ current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() + 1, 0, 0, 0, 0));
365
+ days++;
366
+ }
367
+
368
+ return Math.round((totalMinutes / 60) * 100) / 100;
369
+ }
370
+
371
+ /**
372
+ * Returns the count of working days between two dates (inclusive of start and end).
373
+ */
374
+ export function getWorkingDaysBetween(
375
+ startInput: Date | string,
376
+ endInput: Date | string,
377
+ schedule: WorkSchedule = DEFAULT_WORK_SCHEDULE
378
+ ): number {
379
+ const start = parseDate(startInput);
380
+ const end = parseDate(endInput);
381
+
382
+ if (end < start) {
383
+ return 0;
384
+ }
385
+
386
+ let current = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), 0, 0, 0, 0));
387
+ const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate(), 0, 0, 0, 0));
388
+
389
+ let count = 0;
390
+ let days = 0;
391
+ while (current <= endDay && days < 3650) {
392
+ if (isWorkingDay(current, schedule)) {
393
+ const holiday = getHoliday(toDateString(current), schedule);
394
+ count += (holiday && holiday.halfDay) ? 0.5 : 1;
395
+ }
396
+ current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() + 1, 0, 0, 0, 0));
397
+ days++;
398
+ }
399
+
400
+ return count;
401
+ }
402
+
403
+ /**
404
+ * Returns an array of YYYY-MM-DD date strings for all active working days between start and end.
405
+ */
406
+ export function getWorkingDaysList(
407
+ startInput: Date | string,
408
+ endInput: Date | string,
409
+ schedule: WorkSchedule = DEFAULT_WORK_SCHEDULE
410
+ ): string[] {
411
+ const start = parseDate(startInput);
412
+ const end = parseDate(endInput);
413
+
414
+ if (end < start) {
415
+ return [];
416
+ }
417
+
418
+ let current = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), 0, 0, 0, 0));
419
+ const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate(), 0, 0, 0, 0));
420
+
421
+ const list: string[] = [];
422
+ let days = 0;
423
+ while (current <= endDay && days < 3650) {
424
+ if (isWorkingDay(current, schedule)) {
425
+ list.push(toDateString(current));
426
+ }
427
+ current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() + 1, 0, 0, 0, 0));
428
+ days++;
429
+ }
430
+
431
+ return list;
432
+ }
433
+
434
+ /**
435
+ * Computes net available capacity (in working hours) over a contiguous date interval.
436
+ */
437
+ export function getNetAvailableCapacity(
438
+ startInput: Date | string,
439
+ endInput: Date | string,
440
+ schedule: WorkSchedule = DEFAULT_WORK_SCHEDULE
441
+ ): number {
442
+ const start = parseDate(startInput);
443
+ const end = parseDate(endInput);
444
+
445
+ let current = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), 0, 0, 0, 0));
446
+ const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate(), 0, 0, 0, 0));
447
+
448
+ let totalCapacity = 0;
449
+ let days = 0;
450
+ while (current <= endDay && days < 3650) {
451
+ totalCapacity += getWorkingHoursInDay(current, schedule);
452
+ current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() + 1, 0, 0, 0, 0));
453
+ days++;
454
+ }
455
+
456
+ return Math.round(totalCapacity * 100) / 100;
457
+ }
@@ -125,4 +125,65 @@ describe('Critical Path Method (CPM)', () => {
125
125
  };
126
126
  expect(getTaskDurationHours(task)).toBe(1);
127
127
  });
128
+
129
+ it('calculates calendar-aware dates skipping weekends and respecting working hours', () => {
130
+ // Friday 2026-09-18 at 09:00
131
+ // Task 1: 16 working hours (Friday 09:00-17:00 = 8h, skips weekend, Monday 2026-09-21 09:00-17:00 = 8h) -> Finishes Mon 17:00
132
+ // Task 2: 8 working hours (depends on Task 1) -> Tuesday 2026-09-22 09:00-17:00
133
+ const tasks: Task[] = [
134
+ { id: 'T1', projectId: 'p1', title: 'Task 1', status: 'todo', priority: 'medium', estimatedHours: 16, createdAt: '', updatedAt: '' },
135
+ { id: 'T2', projectId: 'p1', title: 'Task 2', status: 'todo', priority: 'medium', estimatedHours: 8, createdAt: '', updatedAt: '' }
136
+ ];
137
+ const deps: TaskDependency[] = [
138
+ { id: 'd1', taskId: 'T2', dependsOnTaskId: 'T1', type: 'blocking' }
139
+ ];
140
+
141
+ const projectStartDate = '2026-09-18T09:00:00Z';
142
+ const result = calculateCPM('p1', tasks, deps, { projectStartDate });
143
+
144
+ expect(result.totalDurationHours).toBe(24);
145
+ expect(result.totalWorkingHours).toBe(24);
146
+ expect(result.projectStartDate).toBe('2026-09-18T09:00:00.000Z');
147
+ expect(result.projectEndDate).toBe('2026-09-22T17:00:00.000Z');
148
+
149
+ const s1 = result.tasks.find((t) => t.taskId === 'T1')!;
150
+ expect(s1.earlyStartDate).toBe('2026-09-18T09:00:00.000Z');
151
+ expect(s1.earlyFinishDate).toBe('2026-09-21T17:00:00.000Z');
152
+ expect(s1.durationHours).toBe(16);
153
+
154
+ const s2 = result.tasks.find((t) => t.taskId === 'T2')!;
155
+ expect(s2.earlyStartDate).toBe('2026-09-21T17:00:00.000Z');
156
+ expect(s2.earlyFinishDate).toBe('2026-09-22T17:00:00.000Z');
157
+ expect(s2.durationHours).toBe(8);
158
+ });
159
+
160
+ it('skips configured holidays during CPM scheduling', () => {
161
+ // Friday 2026-09-18 09:00, with Monday 2026-09-21 as a holiday!
162
+ // Task 1: 16 working hours (Friday 8h, Sat/Sun off, Mon off [holiday], Tuesday 2026-09-22 8h) -> Finishes Tuesday 17:00!
163
+ const tasks: Task[] = [
164
+ { id: 'T1', projectId: 'p1', title: 'Task 1', status: 'todo', priority: 'medium', estimatedHours: 16, createdAt: '', updatedAt: '' }
165
+ ];
166
+
167
+ const result = calculateCPM('p1', tasks, [], {
168
+ projectStartDate: '2026-09-18T09:00:00Z',
169
+ schedule: {
170
+ name: 'Custom',
171
+ defaultHoursPerDay: 8,
172
+ days: [
173
+ { dayOfWeek: 0, isWorkingDay: false },
174
+ { dayOfWeek: 1, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
175
+ { dayOfWeek: 2, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
176
+ { dayOfWeek: 3, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
177
+ { dayOfWeek: 4, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
178
+ { dayOfWeek: 5, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
179
+ { dayOfWeek: 6, isWorkingDay: false }
180
+ ],
181
+ holidays: [{ date: '2026-09-21', name: 'Monday Holiday' }]
182
+ }
183
+ });
184
+
185
+ const s1 = result.tasks.find((t) => t.taskId === 'T1')!;
186
+ expect(s1.earlyStartDate).toBe('2026-09-18T09:00:00.000Z');
187
+ expect(s1.earlyFinishDate).toBe('2026-09-22T17:00:00.000Z');
188
+ });
128
189
  });
package/src/domain/cpm.ts CHANGED
@@ -2,8 +2,10 @@ import type {
2
2
  Task,
3
3
  TaskDependency,
4
4
  TaskCriticalPathSchedule,
5
- CriticalPathAnalysis
5
+ CriticalPathAnalysis,
6
+ WorkSchedule
6
7
  } from '../types/index.js';
8
+ import { DEFAULT_WORK_SCHEDULE, addWorkingHours, parseDate } from './calendar.js';
7
9
 
8
10
  export function getTaskDurationHours(task: Task): number {
9
11
  if (typeof task.estimatedHours === 'number' && task.estimatedHours >= 0) {
@@ -19,24 +21,36 @@ export function getTaskDurationHours(task: Task): number {
19
21
  return 1;
20
22
  }
21
23
 
24
+ export interface CPMOptions {
25
+ projectStartDate?: string | Date;
26
+ schedule?: WorkSchedule;
27
+ }
28
+
22
29
  /**
23
30
  * Calculates the Critical Path Method (CPM) schedule for a set of tasks and their dependencies.
24
- * - Forward Pass: computes earlyStart and earlyFinish
31
+ * - Forward Pass: computes earlyStart and earlyFinish (in working hours and calendar dates)
25
32
  * - Backward Pass: computes lateStart, lateFinish, and totalSlack
33
+ * - Respects working days, working hours, and holidays via WorkSchedule
26
34
  * - Identifies critical tasks (totalSlack === 0) that dictate the minimum project duration.
27
35
  */
28
36
  export function calculateCPM(
29
37
  projectId: string,
30
38
  tasks: Task[],
31
- dependencies: TaskDependency[]
39
+ dependencies: TaskDependency[],
40
+ options: CPMOptions = {}
32
41
  ): CriticalPathAnalysis {
33
42
  const calculatedAt = new Date().toISOString();
43
+ const schedule = options.schedule || DEFAULT_WORK_SCHEDULE;
44
+ const projectStartDate = options.projectStartDate ? parseDate(options.projectStartDate) : undefined;
34
45
 
35
46
  if (tasks.length === 0) {
36
47
  return {
37
48
  projectId,
38
49
  calculatedAt,
39
50
  totalDurationHours: 0,
51
+ totalWorkingHours: 0,
52
+ projectStartDate: projectStartDate ? projectStartDate.toISOString() : undefined,
53
+ projectEndDate: projectStartDate ? projectStartDate.toISOString() : undefined,
40
54
  criticalTaskIds: [],
41
55
  tasks: []
42
56
  };
@@ -194,6 +208,22 @@ export function calculateCPM(
194
208
  criticalTaskIds.push(id);
195
209
  }
196
210
 
211
+ const task = taskMap.get(id)!;
212
+ const duration = getTaskDurationHours(task);
213
+
214
+ const earlyStartDate = projectStartDate
215
+ ? addWorkingHours(projectStartDate, es, schedule).toISOString()
216
+ : undefined;
217
+ const earlyFinishDate = projectStartDate
218
+ ? addWorkingHours(projectStartDate, ef, schedule).toISOString()
219
+ : undefined;
220
+ const lateStartDate = projectStartDate
221
+ ? addWorkingHours(projectStartDate, ls, schedule).toISOString()
222
+ : undefined;
223
+ const lateFinishDate = projectStartDate
224
+ ? addWorkingHours(projectStartDate, lf, schedule).toISOString()
225
+ : undefined;
226
+
197
227
  schedules.push({
198
228
  taskId: id,
199
229
  earlyStart: Math.round(es * 100) / 100,
@@ -201,14 +231,27 @@ export function calculateCPM(
201
231
  lateStart: Math.round(ls * 100) / 100,
202
232
  lateFinish: Math.round(lf * 100) / 100,
203
233
  totalSlack,
204
- isCritical
234
+ isCritical,
235
+ durationHours: duration,
236
+ slackWorkingHours: totalSlack,
237
+ earlyStartDate,
238
+ earlyFinishDate,
239
+ lateStartDate,
240
+ lateFinishDate
205
241
  });
206
242
  }
207
243
 
244
+ const projectEndDate = projectStartDate
245
+ ? addWorkingHours(projectStartDate, totalDurationHours, schedule).toISOString()
246
+ : undefined;
247
+
208
248
  return {
209
249
  projectId,
210
250
  calculatedAt,
211
251
  totalDurationHours: Math.round(totalDurationHours * 100) / 100,
252
+ totalWorkingHours: Math.round(totalDurationHours * 100) / 100,
253
+ projectStartDate: projectStartDate ? projectStartDate.toISOString() : undefined,
254
+ projectEndDate,
212
255
  criticalTaskIds,
213
256
  tasks: schedules
214
257
  };
@@ -42,9 +42,18 @@ export interface TaskStatusChangedEvent extends DomainEvent<{
42
42
  readonly aggregateType: 'Task';
43
43
  }
44
44
 
45
+ export interface TaskBlockedEvent extends DomainEvent<{
46
+ task: Task;
47
+ reason?: string | null;
48
+ blockedByTaskId?: string;
49
+ }> {
50
+ readonly name: 'task.blocked';
51
+ readonly aggregateType: 'Task';
52
+ }
53
+
45
54
  export interface TaskUnblockedEvent extends DomainEvent<{
46
55
  task: Task;
47
- upstreamTaskId: string;
56
+ upstreamTaskId?: string;
48
57
  }> {
49
58
  readonly name: 'task.unblocked';
50
59
  readonly aggregateType: 'Task';
@@ -169,6 +178,7 @@ export type CriticalPathDomainEvent =
169
178
  | TaskCreatedEvent
170
179
  | TaskUpdatedEvent
171
180
  | TaskStatusChangedEvent
181
+ | TaskBlockedEvent
172
182
  | TaskUnblockedEvent
173
183
  | TaskDeletedEvent
174
184
  | TimeLoggedEvent
@@ -5,4 +5,6 @@ export * from './entities.js';
5
5
  export * from './cpm.js';
6
6
  export * from './ladder.js';
7
7
  export * from './metrics.js';
8
+ export * from './workload.js';
9
+ export * from './calendar.js';
8
10