@critical-path/core 0.35.0 → 0.36.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 (187) hide show
  1. package/README.md +11 -0
  2. package/dist/engine/index.d.ts +2 -0
  3. package/dist/engine/index.d.ts.map +1 -1
  4. package/dist/engine/index.js +25 -2
  5. package/dist/engine/index.js.map +1 -1
  6. package/dist/types/index.d.ts +11 -0
  7. package/dist/types/index.d.ts.map +1 -1
  8. package/package.json +5 -1
  9. package/CHANGELOG.md +0 -584
  10. package/dist/deliverables.test.d.ts +0 -2
  11. package/dist/deliverables.test.d.ts.map +0 -1
  12. package/dist/deliverables.test.js +0 -232
  13. package/dist/deliverables.test.js.map +0 -1
  14. package/dist/domain/calendar.test.d.ts +0 -2
  15. package/dist/domain/calendar.test.d.ts.map +0 -1
  16. package/dist/domain/calendar.test.js +0 -181
  17. package/dist/domain/calendar.test.js.map +0 -1
  18. package/dist/domain/cpm.test.d.ts +0 -2
  19. package/dist/domain/cpm.test.d.ts.map +0 -1
  20. package/dist/domain/cpm.test.js +0 -166
  21. package/dist/domain/cpm.test.js.map +0 -1
  22. package/dist/domain/ddd.test.d.ts +0 -2
  23. package/dist/domain/ddd.test.d.ts.map +0 -1
  24. package/dist/domain/ddd.test.js +0 -349
  25. package/dist/domain/ddd.test.js.map +0 -1
  26. package/dist/domain/ladder.test.d.ts +0 -2
  27. package/dist/domain/ladder.test.d.ts.map +0 -1
  28. package/dist/domain/ladder.test.js +0 -213
  29. package/dist/domain/ladder.test.js.map +0 -1
  30. package/dist/domain/metrics.test.d.ts +0 -2
  31. package/dist/domain/metrics.test.d.ts.map +0 -1
  32. package/dist/domain/metrics.test.js +0 -252
  33. package/dist/domain/metrics.test.js.map +0 -1
  34. package/dist/domain/task-duration.test.d.ts +0 -2
  35. package/dist/domain/task-duration.test.d.ts.map +0 -1
  36. package/dist/domain/task-duration.test.js +0 -307
  37. package/dist/domain/task-duration.test.js.map +0 -1
  38. package/dist/domain/workload.test.d.ts +0 -2
  39. package/dist/domain/workload.test.d.ts.map +0 -1
  40. package/dist/domain/workload.test.js +0 -261
  41. package/dist/domain/workload.test.js.map +0 -1
  42. package/dist/engine/actor.test.d.ts +0 -2
  43. package/dist/engine/actor.test.d.ts.map +0 -1
  44. package/dist/engine/actor.test.js +0 -54
  45. package/dist/engine/actor.test.js.map +0 -1
  46. package/dist/engine/authorization.test.d.ts +0 -2
  47. package/dist/engine/authorization.test.d.ts.map +0 -1
  48. package/dist/engine/authorization.test.js +0 -154
  49. package/dist/engine/authorization.test.js.map +0 -1
  50. package/dist/engine/cascade.test.d.ts +0 -2
  51. package/dist/engine/cascade.test.d.ts.map +0 -1
  52. package/dist/engine/cascade.test.js +0 -99
  53. package/dist/engine/cascade.test.js.map +0 -1
  54. package/dist/engine/config.test.d.ts +0 -2
  55. package/dist/engine/config.test.d.ts.map +0 -1
  56. package/dist/engine/config.test.js +0 -46
  57. package/dist/engine/config.test.js.map +0 -1
  58. package/dist/engine/entity-events.test.d.ts +0 -2
  59. package/dist/engine/entity-events.test.d.ts.map +0 -1
  60. package/dist/engine/entity-events.test.js +0 -61
  61. package/dist/engine/entity-events.test.js.map +0 -1
  62. package/dist/index.test.d.ts +0 -2
  63. package/dist/index.test.d.ts.map +0 -1
  64. package/dist/index.test.js +0 -429
  65. package/dist/index.test.js.map +0 -1
  66. package/dist/plugins/plugins.test.d.ts +0 -2
  67. package/dist/plugins/plugins.test.d.ts.map +0 -1
  68. package/dist/plugins/plugins.test.js +0 -137
  69. package/dist/plugins/plugins.test.js.map +0 -1
  70. package/dist/schemas/schemas.test.d.ts +0 -2
  71. package/dist/schemas/schemas.test.d.ts.map +0 -1
  72. package/dist/schemas/schemas.test.js +0 -58
  73. package/dist/schemas/schemas.test.js.map +0 -1
  74. package/dist/status.test.d.ts +0 -2
  75. package/dist/status.test.d.ts.map +0 -1
  76. package/dist/status.test.js +0 -295
  77. package/dist/status.test.js.map +0 -1
  78. package/dist/storage/storage.test.d.ts +0 -2
  79. package/dist/storage/storage.test.d.ts.map +0 -1
  80. package/dist/storage/storage.test.js +0 -236
  81. package/dist/storage/storage.test.js.map +0 -1
  82. package/dist/store/conformance.test.d.ts +0 -2
  83. package/dist/store/conformance.test.d.ts.map +0 -1
  84. package/dist/store/conformance.test.js +0 -15
  85. package/dist/store/conformance.test.js.map +0 -1
  86. package/dist/store/firebase.test.d.ts +0 -2
  87. package/dist/store/firebase.test.d.ts.map +0 -1
  88. package/dist/store/firebase.test.js +0 -103
  89. package/dist/store/firebase.test.js.map +0 -1
  90. package/dist/store/query.test.d.ts +0 -2
  91. package/dist/store/query.test.d.ts.map +0 -1
  92. package/dist/store/query.test.js +0 -89
  93. package/dist/store/query.test.js.map +0 -1
  94. package/dist/store/sqlite.test.d.ts +0 -2
  95. package/dist/store/sqlite.test.d.ts.map +0 -1
  96. package/dist/store/sqlite.test.js +0 -177
  97. package/dist/store/sqlite.test.js.map +0 -1
  98. package/dist/task-state.test.d.ts +0 -2
  99. package/dist/task-state.test.d.ts.map +0 -1
  100. package/dist/task-state.test.js +0 -423
  101. package/dist/task-state.test.js.map +0 -1
  102. package/dist/use-case-validation.test.d.ts +0 -2
  103. package/dist/use-case-validation.test.d.ts.map +0 -1
  104. package/dist/use-case-validation.test.js +0 -281
  105. package/dist/use-case-validation.test.js.map +0 -1
  106. package/dist/utils/fractional-index.test.d.ts +0 -2
  107. package/dist/utils/fractional-index.test.d.ts.map +0 -1
  108. package/dist/utils/fractional-index.test.js +0 -182
  109. package/dist/utils/fractional-index.test.js.map +0 -1
  110. package/dist/utils/key.test.d.ts +0 -2
  111. package/dist/utils/key.test.d.ts.map +0 -1
  112. package/dist/utils/key.test.js +0 -65
  113. package/dist/utils/key.test.js.map +0 -1
  114. package/dist/utils/mentions.test.d.ts +0 -2
  115. package/dist/utils/mentions.test.d.ts.map +0 -1
  116. package/dist/utils/mentions.test.js +0 -114
  117. package/dist/utils/mentions.test.js.map +0 -1
  118. package/dist/webhooks/webhooks.test.d.ts +0 -2
  119. package/dist/webhooks/webhooks.test.d.ts.map +0 -1
  120. package/dist/webhooks/webhooks.test.js +0 -154
  121. package/dist/webhooks/webhooks.test.js.map +0 -1
  122. package/src/deliverables.test.ts +0 -284
  123. package/src/domain/authorization.ts +0 -82
  124. package/src/domain/calendar.test.ts +0 -216
  125. package/src/domain/calendar.ts +0 -502
  126. package/src/domain/cpm.test.ts +0 -189
  127. package/src/domain/cpm.ts +0 -258
  128. package/src/domain/custom-fields.ts +0 -172
  129. package/src/domain/ddd.test.ts +0 -442
  130. package/src/domain/entities.ts +0 -798
  131. package/src/domain/errors.ts +0 -38
  132. package/src/domain/events.ts +0 -369
  133. package/src/domain/graph.ts +0 -70
  134. package/src/domain/index.ts +0 -12
  135. package/src/domain/ladder.test.ts +0 -251
  136. package/src/domain/ladder.ts +0 -474
  137. package/src/domain/metrics.test.ts +0 -288
  138. package/src/domain/metrics.ts +0 -497
  139. package/src/domain/task-duration.test.ts +0 -354
  140. package/src/domain/workload.test.ts +0 -311
  141. package/src/domain/workload.ts +0 -457
  142. package/src/engine/actor.test.ts +0 -63
  143. package/src/engine/authorization.test.ts +0 -199
  144. package/src/engine/cascade.test.ts +0 -121
  145. package/src/engine/config.test.ts +0 -51
  146. package/src/engine/entity-events.test.ts +0 -67
  147. package/src/engine/index.ts +0 -2472
  148. package/src/index.test.ts +0 -514
  149. package/src/index.ts +0 -9
  150. package/src/plugins/index.ts +0 -103
  151. package/src/plugins/plugins.test.ts +0 -161
  152. package/src/schemas/index.ts +0 -402
  153. package/src/schemas/schemas.test.ts +0 -116
  154. package/src/status.test.ts +0 -354
  155. package/src/storage/file-storage.ts +0 -200
  156. package/src/storage/firebase-storage.ts +0 -209
  157. package/src/storage/index.ts +0 -3
  158. package/src/storage/s3.ts +0 -151
  159. package/src/storage/storage.test.ts +0 -274
  160. package/src/store/conformance.test.ts +0 -15
  161. package/src/store/firebase.test.ts +0 -131
  162. package/src/store/firebase.ts +0 -658
  163. package/src/store/index.ts +0 -623
  164. package/src/store/query.test.ts +0 -110
  165. package/src/store/query.ts +0 -113
  166. package/src/store/sqlite.test.ts +0 -218
  167. package/src/store/sqlite.ts +0 -1480
  168. package/src/task-state.test.ts +0 -506
  169. package/src/testing/conformance.ts +0 -319
  170. package/src/testing/index.ts +0 -1
  171. package/src/types/index.ts +0 -859
  172. package/src/use-case-validation.test.ts +0 -324
  173. package/src/utils/fractional-index.test.ts +0 -228
  174. package/src/utils/fractional-index.ts +0 -254
  175. package/src/utils/index.ts +0 -7
  176. package/src/utils/key.test.ts +0 -76
  177. package/src/utils/key.ts +0 -62
  178. package/src/utils/mentions.test.ts +0 -132
  179. package/src/utils/mentions.ts +0 -131
  180. package/src/utils/status.ts +0 -419
  181. package/src/utils/workflow.ts +0 -306
  182. package/src/webhooks/dispatcher.ts +0 -190
  183. package/src/webhooks/index.ts +0 -2
  184. package/src/webhooks/signature.ts +0 -57
  185. package/src/webhooks/webhooks.test.ts +0 -201
  186. package/tsconfig.json +0 -8
  187. package/tsconfig.tsbuildinfo +0 -1
@@ -1,502 +0,0 @@
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
- }
458
-
459
- export interface FormatDurationOptions {
460
- /** Optional custom work schedule to determine working hours in a day */
461
- schedule?: WorkSchedule;
462
- /** 'working_days' uses schedule.defaultHoursPerDay (default 8h). 'calendar_days' uses 24h. Default: 'working_days' */
463
- dayBasis?: 'working_days' | 'calendar_days';
464
- }
465
-
466
- /**
467
- * Formats task execution duration dynamically based on magnitude:
468
- * - < 120s: Seconds with 1 decimal place (e.g. '110.3s', '45s')
469
- * - < 1 day (based on dayBasis): Hours and minutes (e.g. '0h 44m', '2h 15m')
470
- * - >= 1 day: Days with 1 decimal place (e.g. '1.5 days', '3.2 days')
471
- */
472
- export function formatTaskDuration(
473
- durationSeconds: number | null | undefined,
474
- options?: FormatDurationOptions
475
- ): string {
476
- if (durationSeconds === null || durationSeconds === undefined || isNaN(durationSeconds) || durationSeconds <= 0) {
477
- return '0s';
478
- }
479
-
480
- // Under 120 seconds: return seconds with 1 decimal place (or whole if integer)
481
- if (durationSeconds < 120) {
482
- const formatted = durationSeconds.toFixed(1);
483
- return `${formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted}s`;
484
- }
485
-
486
- const hoursPerDay = options?.dayBasis === 'calendar_days'
487
- ? 24
488
- : (options?.schedule?.defaultHoursPerDay || DEFAULT_WORK_SCHEDULE.defaultHoursPerDay || 8);
489
-
490
- const totalHours = durationSeconds / 3600;
491
- if (totalHours >= hoursPerDay) {
492
- const days = totalHours / hoursPerDay;
493
- const formatted = days.toFixed(1);
494
- const cleanDays = formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
495
- return `${cleanDays} ${cleanDays === '1' ? 'day' : 'days'}`;
496
- }
497
-
498
- const hours = Math.floor(totalHours);
499
- const minutes = Math.floor((durationSeconds % 3600) / 60);
500
- return `${hours}h ${minutes}m`;
501
- }
502
-
@@ -1,189 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { calculateCPM, getTaskDurationHours } from './cpm.js';
3
- import type { Task, TaskDependency } from '../types/index.js';
4
-
5
- describe('Critical Path Method (CPM)', () => {
6
- it('returns empty analysis for empty tasks array', () => {
7
- const analysis = calculateCPM('proj-1', [], []);
8
- expect(analysis.projectId).toBe('proj-1');
9
- expect(analysis.totalDurationHours).toBe(0);
10
- expect(analysis.criticalTaskIds).toEqual([]);
11
- expect(analysis.tasks).toEqual([]);
12
- });
13
-
14
- it('calculates duration, slack, and critical path for sequential linear tasks', () => {
15
- const now = new Date().toISOString();
16
- const tasks: Task[] = [
17
- {
18
- id: 'task-1',
19
- projectId: 'proj-1',
20
- title: 'Task 1',
21
- status: 'todo',
22
- priority: 'medium',
23
- estimatedHours: 2,
24
- createdAt: now,
25
- updatedAt: now
26
- },
27
- {
28
- id: 'task-2',
29
- projectId: 'proj-1',
30
- title: 'Task 2',
31
- status: 'todo',
32
- priority: 'medium',
33
- estimatedHours: 3,
34
- createdAt: now,
35
- updatedAt: now
36
- },
37
- {
38
- id: 'task-3',
39
- projectId: 'proj-1',
40
- title: 'Task 3',
41
- status: 'todo',
42
- priority: 'medium',
43
- estimatedHours: 1,
44
- createdAt: now,
45
- updatedAt: now
46
- }
47
- ];
48
-
49
- const dependencies: TaskDependency[] = [
50
- { id: 'dep-1', taskId: 'task-2', dependsOnTaskId: 'task-1', type: 'blocking' },
51
- { id: 'dep-2', taskId: 'task-3', dependsOnTaskId: 'task-2', type: 'blocking' }
52
- ];
53
-
54
- const result = calculateCPM('proj-1', tasks, dependencies);
55
-
56
- expect(result.totalDurationHours).toBe(6);
57
- expect(result.criticalTaskIds).toEqual(['task-1', 'task-2', 'task-3']);
58
-
59
- const sched1 = result.tasks.find((t) => t.taskId === 'task-1')!;
60
- expect(sched1.earlyStart).toBe(0);
61
- expect(sched1.earlyFinish).toBe(2);
62
- expect(sched1.lateStart).toBe(0);
63
- expect(sched1.lateFinish).toBe(2);
64
- expect(sched1.totalSlack).toBe(0);
65
- expect(sched1.isCritical).toBe(true);
66
-
67
- const sched2 = result.tasks.find((t) => t.taskId === 'task-2')!;
68
- expect(sched2.earlyStart).toBe(2);
69
- expect(sched2.earlyFinish).toBe(5);
70
- expect(sched2.totalSlack).toBe(0);
71
- expect(sched2.isCritical).toBe(true);
72
-
73
- const sched3 = result.tasks.find((t) => t.taskId === 'task-3')!;
74
- expect(sched3.earlyStart).toBe(5);
75
- expect(sched3.earlyFinish).toBe(6);
76
- expect(sched3.totalSlack).toBe(0);
77
- expect(sched3.isCritical).toBe(true);
78
- });
79
-
80
- it('correctly identifies bottleneck critical path vs non-critical slack in parallel branches', () => {
81
- // A (1h) -> B (4h) -> D (1h) => Path length = 6h (CRITICAL)
82
- // A (1h) -> C (2h) -> D (1h) => Path length = 4h (Non-critical, C has 2h slack)
83
- const now = new Date().toISOString();
84
- const tasks: Task[] = [
85
- { id: 'A', projectId: 'p1', title: 'A', status: 'todo', priority: 'medium', estimatedHours: 1, createdAt: now, updatedAt: now },
86
- { id: 'B', projectId: 'p1', title: 'B', status: 'todo', priority: 'medium', estimatedHours: 4, createdAt: now, updatedAt: now },
87
- { id: 'C', projectId: 'p1', title: 'C', status: 'todo', priority: 'medium', estimatedHours: 2, createdAt: now, updatedAt: now },
88
- { id: 'D', projectId: 'p1', title: 'D', status: 'todo', priority: 'medium', estimatedHours: 1, createdAt: now, updatedAt: now }
89
- ];
90
-
91
- const dependencies: TaskDependency[] = [
92
- { id: 'd1', taskId: 'B', dependsOnTaskId: 'A', type: 'blocking' },
93
- { id: 'd2', taskId: 'C', dependsOnTaskId: 'A', type: 'blocking' },
94
- { id: 'd3', taskId: 'D', dependsOnTaskId: 'B', type: 'blocking' },
95
- { id: 'd4', taskId: 'D', dependsOnTaskId: 'C', type: 'blocking' }
96
- ];
97
-
98
- const result = calculateCPM('p1', tasks, dependencies);
99
-
100
- expect(result.totalDurationHours).toBe(6);
101
- expect(result.criticalTaskIds).toEqual(['A', 'B', 'D']);
102
-
103
- const schedC = result.tasks.find((t) => t.taskId === 'C')!;
104
- expect(schedC.earlyStart).toBe(1);
105
- expect(schedC.earlyFinish).toBe(3);
106
- expect(schedC.lateStart).toBe(3);
107
- expect(schedC.lateFinish).toBe(5);
108
- expect(schedC.totalSlack).toBe(2);
109
- expect(schedC.isCritical).toBe(false);
110
-
111
- const schedB = result.tasks.find((t) => t.taskId === 'B')!;
112
- expect(schedB.totalSlack).toBe(0);
113
- expect(schedB.isCritical).toBe(true);
114
- });
115
-
116
- it('falls back gracefully to 1 hour duration if no estimate or minutes are set', () => {
117
- const task: Task = {
118
- id: 'no-est',
119
- projectId: 'p1',
120
- title: 'Unestimated Task',
121
- status: 'todo',
122
- priority: 'none',
123
- createdAt: '',
124
- updatedAt: ''
125
- };
126
- expect(getTaskDurationHours(task)).toBe(1);
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
- });
189
- });