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