@critical-path/core 0.36.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 (185) hide show
  1. package/package.json +5 -1
  2. package/CHANGELOG.md +0 -593
  3. package/dist/deliverables.test.d.ts +0 -2
  4. package/dist/deliverables.test.d.ts.map +0 -1
  5. package/dist/deliverables.test.js +0 -232
  6. package/dist/deliverables.test.js.map +0 -1
  7. package/dist/domain/calendar.test.d.ts +0 -2
  8. package/dist/domain/calendar.test.d.ts.map +0 -1
  9. package/dist/domain/calendar.test.js +0 -181
  10. package/dist/domain/calendar.test.js.map +0 -1
  11. package/dist/domain/cpm.test.d.ts +0 -2
  12. package/dist/domain/cpm.test.d.ts.map +0 -1
  13. package/dist/domain/cpm.test.js +0 -166
  14. package/dist/domain/cpm.test.js.map +0 -1
  15. package/dist/domain/ddd.test.d.ts +0 -2
  16. package/dist/domain/ddd.test.d.ts.map +0 -1
  17. package/dist/domain/ddd.test.js +0 -349
  18. package/dist/domain/ddd.test.js.map +0 -1
  19. package/dist/domain/ladder.test.d.ts +0 -2
  20. package/dist/domain/ladder.test.d.ts.map +0 -1
  21. package/dist/domain/ladder.test.js +0 -213
  22. package/dist/domain/ladder.test.js.map +0 -1
  23. package/dist/domain/metrics.test.d.ts +0 -2
  24. package/dist/domain/metrics.test.d.ts.map +0 -1
  25. package/dist/domain/metrics.test.js +0 -252
  26. package/dist/domain/metrics.test.js.map +0 -1
  27. package/dist/domain/task-duration.test.d.ts +0 -2
  28. package/dist/domain/task-duration.test.d.ts.map +0 -1
  29. package/dist/domain/task-duration.test.js +0 -307
  30. package/dist/domain/task-duration.test.js.map +0 -1
  31. package/dist/domain/workload.test.d.ts +0 -2
  32. package/dist/domain/workload.test.d.ts.map +0 -1
  33. package/dist/domain/workload.test.js +0 -261
  34. package/dist/domain/workload.test.js.map +0 -1
  35. package/dist/engine/actor.test.d.ts +0 -2
  36. package/dist/engine/actor.test.d.ts.map +0 -1
  37. package/dist/engine/actor.test.js +0 -54
  38. package/dist/engine/actor.test.js.map +0 -1
  39. package/dist/engine/authorization.test.d.ts +0 -2
  40. package/dist/engine/authorization.test.d.ts.map +0 -1
  41. package/dist/engine/authorization.test.js +0 -154
  42. package/dist/engine/authorization.test.js.map +0 -1
  43. package/dist/engine/cascade.test.d.ts +0 -2
  44. package/dist/engine/cascade.test.d.ts.map +0 -1
  45. package/dist/engine/cascade.test.js +0 -99
  46. package/dist/engine/cascade.test.js.map +0 -1
  47. package/dist/engine/config.test.d.ts +0 -2
  48. package/dist/engine/config.test.d.ts.map +0 -1
  49. package/dist/engine/config.test.js +0 -46
  50. package/dist/engine/config.test.js.map +0 -1
  51. package/dist/engine/entity-events.test.d.ts +0 -2
  52. package/dist/engine/entity-events.test.d.ts.map +0 -1
  53. package/dist/engine/entity-events.test.js +0 -61
  54. package/dist/engine/entity-events.test.js.map +0 -1
  55. package/dist/index.test.d.ts +0 -2
  56. package/dist/index.test.d.ts.map +0 -1
  57. package/dist/index.test.js +0 -429
  58. package/dist/index.test.js.map +0 -1
  59. package/dist/plugins/plugins.test.d.ts +0 -2
  60. package/dist/plugins/plugins.test.d.ts.map +0 -1
  61. package/dist/plugins/plugins.test.js +0 -137
  62. package/dist/plugins/plugins.test.js.map +0 -1
  63. package/dist/schemas/schemas.test.d.ts +0 -2
  64. package/dist/schemas/schemas.test.d.ts.map +0 -1
  65. package/dist/schemas/schemas.test.js +0 -58
  66. package/dist/schemas/schemas.test.js.map +0 -1
  67. package/dist/status.test.d.ts +0 -2
  68. package/dist/status.test.d.ts.map +0 -1
  69. package/dist/status.test.js +0 -295
  70. package/dist/status.test.js.map +0 -1
  71. package/dist/storage/storage.test.d.ts +0 -2
  72. package/dist/storage/storage.test.d.ts.map +0 -1
  73. package/dist/storage/storage.test.js +0 -236
  74. package/dist/storage/storage.test.js.map +0 -1
  75. package/dist/storage/upload-limits.test.d.ts +0 -2
  76. package/dist/storage/upload-limits.test.d.ts.map +0 -1
  77. package/dist/storage/upload-limits.test.js +0 -31
  78. package/dist/storage/upload-limits.test.js.map +0 -1
  79. package/dist/store/conformance.test.d.ts +0 -2
  80. package/dist/store/conformance.test.d.ts.map +0 -1
  81. package/dist/store/conformance.test.js +0 -15
  82. package/dist/store/conformance.test.js.map +0 -1
  83. package/dist/store/firebase.test.d.ts +0 -2
  84. package/dist/store/firebase.test.d.ts.map +0 -1
  85. package/dist/store/firebase.test.js +0 -103
  86. package/dist/store/firebase.test.js.map +0 -1
  87. package/dist/store/query.test.d.ts +0 -2
  88. package/dist/store/query.test.d.ts.map +0 -1
  89. package/dist/store/query.test.js +0 -89
  90. package/dist/store/query.test.js.map +0 -1
  91. package/dist/store/sqlite.test.d.ts +0 -2
  92. package/dist/store/sqlite.test.d.ts.map +0 -1
  93. package/dist/store/sqlite.test.js +0 -177
  94. package/dist/store/sqlite.test.js.map +0 -1
  95. package/dist/task-state.test.d.ts +0 -2
  96. package/dist/task-state.test.d.ts.map +0 -1
  97. package/dist/task-state.test.js +0 -423
  98. package/dist/task-state.test.js.map +0 -1
  99. package/dist/use-case-validation.test.d.ts +0 -2
  100. package/dist/use-case-validation.test.d.ts.map +0 -1
  101. package/dist/use-case-validation.test.js +0 -281
  102. package/dist/use-case-validation.test.js.map +0 -1
  103. package/dist/utils/fractional-index.test.d.ts +0 -2
  104. package/dist/utils/fractional-index.test.d.ts.map +0 -1
  105. package/dist/utils/fractional-index.test.js +0 -182
  106. package/dist/utils/fractional-index.test.js.map +0 -1
  107. package/dist/utils/key.test.d.ts +0 -2
  108. package/dist/utils/key.test.d.ts.map +0 -1
  109. package/dist/utils/key.test.js +0 -65
  110. package/dist/utils/key.test.js.map +0 -1
  111. package/dist/utils/mentions.test.d.ts +0 -2
  112. package/dist/utils/mentions.test.d.ts.map +0 -1
  113. package/dist/utils/mentions.test.js +0 -114
  114. package/dist/utils/mentions.test.js.map +0 -1
  115. package/dist/webhooks/webhooks.test.d.ts +0 -2
  116. package/dist/webhooks/webhooks.test.d.ts.map +0 -1
  117. package/dist/webhooks/webhooks.test.js +0 -154
  118. package/dist/webhooks/webhooks.test.js.map +0 -1
  119. package/src/deliverables.test.ts +0 -284
  120. package/src/domain/authorization.ts +0 -82
  121. package/src/domain/calendar.test.ts +0 -216
  122. package/src/domain/calendar.ts +0 -502
  123. package/src/domain/cpm.test.ts +0 -189
  124. package/src/domain/cpm.ts +0 -258
  125. package/src/domain/custom-fields.ts +0 -172
  126. package/src/domain/ddd.test.ts +0 -442
  127. package/src/domain/entities.ts +0 -798
  128. package/src/domain/errors.ts +0 -38
  129. package/src/domain/events.ts +0 -369
  130. package/src/domain/graph.ts +0 -70
  131. package/src/domain/index.ts +0 -12
  132. package/src/domain/ladder.test.ts +0 -251
  133. package/src/domain/ladder.ts +0 -474
  134. package/src/domain/metrics.test.ts +0 -288
  135. package/src/domain/metrics.ts +0 -497
  136. package/src/domain/task-duration.test.ts +0 -354
  137. package/src/domain/workload.test.ts +0 -311
  138. package/src/domain/workload.ts +0 -457
  139. package/src/engine/actor.test.ts +0 -63
  140. package/src/engine/authorization.test.ts +0 -199
  141. package/src/engine/cascade.test.ts +0 -121
  142. package/src/engine/config.test.ts +0 -51
  143. package/src/engine/entity-events.test.ts +0 -67
  144. package/src/engine/index.ts +0 -2495
  145. package/src/index.test.ts +0 -514
  146. package/src/index.ts +0 -9
  147. package/src/plugins/index.ts +0 -103
  148. package/src/plugins/plugins.test.ts +0 -161
  149. package/src/schemas/index.ts +0 -402
  150. package/src/schemas/schemas.test.ts +0 -116
  151. package/src/status.test.ts +0 -354
  152. package/src/storage/file-storage.ts +0 -200
  153. package/src/storage/firebase-storage.ts +0 -209
  154. package/src/storage/index.ts +0 -3
  155. package/src/storage/s3.ts +0 -151
  156. package/src/storage/storage.test.ts +0 -274
  157. package/src/storage/upload-limits.test.ts +0 -41
  158. package/src/store/conformance.test.ts +0 -15
  159. package/src/store/firebase.test.ts +0 -131
  160. package/src/store/firebase.ts +0 -658
  161. package/src/store/index.ts +0 -623
  162. package/src/store/query.test.ts +0 -110
  163. package/src/store/query.ts +0 -113
  164. package/src/store/sqlite.test.ts +0 -218
  165. package/src/store/sqlite.ts +0 -1480
  166. package/src/task-state.test.ts +0 -506
  167. package/src/testing/conformance.ts +0 -319
  168. package/src/testing/index.ts +0 -1
  169. package/src/types/index.ts +0 -871
  170. package/src/use-case-validation.test.ts +0 -324
  171. package/src/utils/fractional-index.test.ts +0 -228
  172. package/src/utils/fractional-index.ts +0 -254
  173. package/src/utils/index.ts +0 -7
  174. package/src/utils/key.test.ts +0 -76
  175. package/src/utils/key.ts +0 -62
  176. package/src/utils/mentions.test.ts +0 -132
  177. package/src/utils/mentions.ts +0 -131
  178. package/src/utils/status.ts +0 -419
  179. package/src/utils/workflow.ts +0 -306
  180. package/src/webhooks/dispatcher.ts +0 -190
  181. package/src/webhooks/index.ts +0 -2
  182. package/src/webhooks/signature.ts +0 -57
  183. package/src/webhooks/webhooks.test.ts +0 -201
  184. package/tsconfig.json +0 -8
  185. 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
- });