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