@critical-path/core 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +71 -0
  3. package/dist/domain/calendar.d.ts +53 -0
  4. package/dist/domain/calendar.d.ts.map +1 -0
  5. package/dist/domain/calendar.js +333 -0
  6. package/dist/domain/calendar.js.map +1 -0
  7. package/dist/domain/calendar.test.d.ts +2 -0
  8. package/dist/domain/calendar.test.d.ts.map +1 -0
  9. package/dist/domain/calendar.test.js +136 -0
  10. package/dist/domain/calendar.test.js.map +1 -0
  11. package/dist/domain/cpm.d.ts +8 -3
  12. package/dist/domain/cpm.d.ts.map +1 -1
  13. package/dist/domain/cpm.js +36 -3
  14. package/dist/domain/cpm.js.map +1 -1
  15. package/dist/domain/cpm.test.js +53 -0
  16. package/dist/domain/cpm.test.js.map +1 -1
  17. package/dist/domain/events.d.ts +10 -2
  18. package/dist/domain/events.d.ts.map +1 -1
  19. package/dist/domain/events.js.map +1 -1
  20. package/dist/domain/index.d.ts +2 -0
  21. package/dist/domain/index.d.ts.map +1 -1
  22. package/dist/domain/index.js +2 -0
  23. package/dist/domain/index.js.map +1 -1
  24. package/dist/domain/ladder.d.ts +3 -2
  25. package/dist/domain/ladder.d.ts.map +1 -1
  26. package/dist/domain/ladder.js +21 -3
  27. package/dist/domain/ladder.js.map +1 -1
  28. package/dist/domain/metrics.test.js +5 -4
  29. package/dist/domain/metrics.test.js.map +1 -1
  30. package/dist/domain/workload.d.ts +10 -0
  31. package/dist/domain/workload.d.ts.map +1 -0
  32. package/dist/domain/workload.js +392 -0
  33. package/dist/domain/workload.js.map +1 -0
  34. package/dist/domain/workload.test.d.ts +2 -0
  35. package/dist/domain/workload.test.d.ts.map +1 -0
  36. package/dist/domain/workload.test.js +261 -0
  37. package/dist/domain/workload.test.js.map +1 -0
  38. package/dist/engine/index.d.ts +5 -2
  39. package/dist/engine/index.d.ts.map +1 -1
  40. package/dist/engine/index.js +83 -3
  41. package/dist/engine/index.js.map +1 -1
  42. package/dist/store/sqlite.d.ts.map +1 -1
  43. package/dist/store/sqlite.js +9 -3
  44. package/dist/store/sqlite.js.map +1 -1
  45. package/dist/store/sqlite.test.js +15 -1
  46. package/dist/store/sqlite.test.js.map +1 -1
  47. package/dist/task-state.test.js +49 -0
  48. package/dist/task-state.test.js.map +1 -1
  49. package/dist/types/index.d.ts +81 -1
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/dist/utils/status.d.ts.map +1 -1
  52. package/dist/utils/status.js +5 -2
  53. package/dist/utils/status.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/domain/calendar.test.ts +164 -0
  56. package/src/domain/calendar.ts +457 -0
  57. package/src/domain/cpm.test.ts +61 -0
  58. package/src/domain/cpm.ts +47 -4
  59. package/src/domain/events.ts +11 -1
  60. package/src/domain/index.ts +2 -0
  61. package/src/domain/ladder.ts +25 -4
  62. package/src/domain/metrics.test.ts +6 -4
  63. package/src/domain/workload.test.ts +311 -0
  64. package/src/domain/workload.ts +457 -0
  65. package/src/engine/index.ts +103 -5
  66. package/src/store/sqlite.test.ts +17 -1
  67. package/src/store/sqlite.ts +11 -1
  68. package/src/task-state.test.ts +60 -1
  69. package/src/types/index.ts +89 -0
  70. package/src/utils/status.ts +7 -2
  71. package/tsconfig.tsbuildinfo +1 -1
@@ -17,9 +17,11 @@ import type {
17
17
  ConcreteTaskEvidence,
18
18
  ConcreteEvidenceSummary,
19
19
  RealityDelta,
20
- TaskLadderView
20
+ TaskLadderView,
21
+ WorkSchedule
21
22
  } from '../types/index.js';
22
23
  import { calculateCPM, getTaskDurationHours } from './cpm.js';
24
+ import { DEFAULT_WORK_SCHEDULE, getWorkingDaysBetween } from './calendar.js';
23
25
 
24
26
  export interface LadderContext {
25
27
  project: Project;
@@ -31,6 +33,7 @@ export interface LadderContext {
31
33
  attachments?: Attachment[];
32
34
  timeEntries?: TimeEntry[];
33
35
  activities?: Activity[];
36
+ schedule?: WorkSchedule;
34
37
  }
35
38
 
36
39
  export function aggregateConcreteEvidenceForTask(
@@ -38,7 +41,8 @@ export function aggregateConcreteEvidenceForTask(
38
41
  attachments: Attachment[] = [],
39
42
  deliverables: Deliverable[] = [],
40
43
  timeEntries: TimeEntry[] = [],
41
- activities: Activity[] = []
44
+ activities: Activity[] = [],
45
+ schedule?: WorkSchedule
42
46
  ): ConcreteTaskEvidence {
43
47
  const taskAttachments = attachments.filter((a) => a.taskId === task.id);
44
48
  const taskDeliverables = deliverables.filter(
@@ -70,10 +74,19 @@ export function aggregateConcreteEvidenceForTask(
70
74
  const isOverEstimate = Boolean(estimatedHours > 0 && effectiveLoggedHours > estimatedHours);
71
75
 
72
76
  let scheduleVarianceDays: number | undefined;
77
+ let scheduleVarianceWorkingDays: number | undefined;
73
78
  if (task.dueDate && task.actualEndDate) {
74
79
  const dueTime = new Date(task.dueDate).getTime();
75
80
  const actualTime = new Date(task.actualEndDate).getTime();
76
81
  scheduleVarianceDays = Math.round((actualTime - dueTime) / (1000 * 60 * 60 * 24));
82
+ const effectiveSched = schedule || DEFAULT_WORK_SCHEDULE;
83
+ if (actualTime > dueTime) {
84
+ scheduleVarianceWorkingDays = Math.max(0, getWorkingDaysBetween(task.dueDate, task.actualEndDate, effectiveSched) - 1);
85
+ } else if (actualTime < dueTime) {
86
+ scheduleVarianceWorkingDays = -Math.max(0, getWorkingDaysBetween(task.actualEndDate, task.dueDate, effectiveSched) - 1);
87
+ } else {
88
+ scheduleVarianceWorkingDays = 0;
89
+ }
77
90
  }
78
91
 
79
92
  let durationVarianceHours: number | undefined;
@@ -100,6 +113,7 @@ export function aggregateConcreteEvidenceForTask(
100
113
  effortVarianceHours: varianceHours,
101
114
  durationVarianceHours,
102
115
  scheduleVarianceDays,
116
+ scheduleVarianceWorkingDays,
103
117
  accuracyRatio,
104
118
  isOverdue,
105
119
  isOverEstimate
@@ -309,8 +323,14 @@ export function buildTimelineLadder(
309
323
  const targetLevel = options.level || 'all';
310
324
  const generatedAt = new Date().toISOString();
311
325
 
326
+ const effectiveSchedule = context.schedule || project.schedule || DEFAULT_WORK_SCHEDULE;
327
+ const projectStartDate = project.startDate || (tasks.length > 0 ? tasks.find((t) => t.plannedStartDate)?.plannedStartDate : undefined);
328
+
312
329
  // Run Critical Path Method (CPM)
313
- const cpm = calculateCPM(project.id, tasks, dependencies);
330
+ const cpm = calculateCPM(project.id, tasks, dependencies, {
331
+ schedule: effectiveSchedule,
332
+ projectStartDate
333
+ });
314
334
  const criticalSet = new Set(cpm.criticalTaskIds);
315
335
  const scheduleMap = new Map(cpm.tasks.map((s) => [s.taskId, s]));
316
336
 
@@ -352,7 +372,8 @@ export function buildTimelineLadder(
352
372
  attachments,
353
373
  deliverables,
354
374
  timeEntries,
355
- activities
375
+ activities,
376
+ effectiveSchedule
356
377
  );
357
378
  }
358
379
 
@@ -148,6 +148,8 @@ describe('Task Metrics Domain', () => {
148
148
  });
149
149
 
150
150
  describe('reconstructTaskProgressHistory & Curve Profile', () => {
151
+ const referenceDate = new Date('2026-09-09T12:00:00Z');
152
+
151
153
  it('detects linear progress', () => {
152
154
  const activities: Activity[] = [
153
155
  { id: 'a1', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 25 }, createdAt: '2026-09-02T10:00:00Z' },
@@ -156,7 +158,7 @@ describe('Task Metrics Domain', () => {
156
158
  { id: 'a4', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 100 }, createdAt: '2026-09-08T10:00:00Z' }
157
159
  ];
158
160
  const task: Task = { ...baseTask, progress: 100, updatedAt: '2026-09-08T10:00:00Z' };
159
- const history = reconstructTaskProgressHistory(task, activities);
161
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
160
162
 
161
163
  expect(history.points.length).toBeGreaterThanOrEqual(4);
162
164
  expect(history.curveProfile).toBe('linear');
@@ -170,7 +172,7 @@ describe('Task Metrics Domain', () => {
170
172
  { id: 'a4', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 95 }, createdAt: '2026-09-09T10:00:00Z' }
171
173
  ];
172
174
  const task: Task = { ...baseTask, progress: 95, updatedAt: '2026-09-09T10:00:00Z' };
173
- const history = reconstructTaskProgressHistory(task, activities);
175
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
174
176
 
175
177
  expect(history.curveProfile).toBe('s_curve');
176
178
  });
@@ -182,7 +184,7 @@ describe('Task Metrics Domain', () => {
182
184
  { id: 'a3', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 78 }, createdAt: '2026-09-08T10:00:00Z' }
183
185
  ];
184
186
  const task: Task = { ...baseTask, progress: 78, updatedAt: '2026-09-08T10:00:00Z' };
185
- const history = reconstructTaskProgressHistory(task, activities);
187
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
186
188
 
187
189
  expect(history.curveProfile).toBe('early_surge');
188
190
  });
@@ -194,7 +196,7 @@ describe('Task Metrics Domain', () => {
194
196
  { id: 'a3', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 90 }, createdAt: '2026-09-08T10:00:00Z' }
195
197
  ];
196
198
  const task: Task = { ...baseTask, progress: 90, updatedAt: '2026-09-08T10:00:00Z' };
197
- const history = reconstructTaskProgressHistory(task, activities);
199
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
198
200
 
199
201
  expect(history.curveProfile).toBe('late_rush');
200
202
  });
@@ -0,0 +1,311 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { calculateWorkloadDistribution } from './workload.js';
3
+ import { CriticalPathEngine } from '../engine/index.js';
4
+ import { InMemoryStore } from '../store/index.js';
5
+ import type { Task, TimeEntry, User, Team } from '../types/index.js';
6
+
7
+ describe('calculateWorkloadDistribution', () => {
8
+ const users: User[] = [
9
+ { id: 'u1', name: 'Alice', email: 'alice@example.com', role: 'contributor', weeklyCapacityHours: 35, createdAt: '2026-09-01' },
10
+ { id: 'u2', name: 'Bob', email: 'bob@example.com', role: 'contributor', weeklyCapacityHours: 40, createdAt: '2026-09-01' }
11
+ ];
12
+
13
+ const teams: Team[] = [
14
+ { id: 'team_frontend', name: 'Frontend', memberIds: ['u1'], createdAt: '2026-09-01', updatedAt: '2026-09-01' },
15
+ { id: 'team_backend', name: 'Backend', memberIds: ['u2'], weeklyCapacityHours: 50, createdAt: '2026-09-01', updatedAt: '2026-09-01' }
16
+ ];
17
+
18
+ it('aggregates logged time entries accurately by assignee across weekly buckets', () => {
19
+ const tasks: Task[] = [
20
+ {
21
+ id: 't1',
22
+ projectId: 'p1',
23
+ title: 'Task 1',
24
+ status: 'in_progress',
25
+ priority: 'high',
26
+ assigneeId: 'u1',
27
+ createdAt: '2026-09-01',
28
+ updatedAt: '2026-09-01'
29
+ }
30
+ ];
31
+
32
+ const timeEntries: TimeEntry[] = [
33
+ { id: 'te1', taskId: 't1', userId: 'u1', hours: 4.5, loggedAt: '2026-09-08' },
34
+ { id: 'te2', taskId: 't1', userId: 'u1', hours: 3.5, loggedAt: '2026-09-09' },
35
+ { id: 'te3', taskId: 't1', userId: 'u2', hours: 6.0, loggedAt: '2026-09-15' }
36
+ ];
37
+
38
+ const result = calculateWorkloadDistribution(
39
+ { tasks, timeEntries, users, teams },
40
+ {
41
+ startDate: '2026-09-07',
42
+ endDate: '2026-09-21',
43
+ interval: 'week',
44
+ groupBy: 'assignee',
45
+ metric: 'logged'
46
+ }
47
+ );
48
+
49
+ expect(result.interval).toBe('week');
50
+ expect(result.groupBy).toBe('assignee');
51
+ expect(result.metric).toBe('logged');
52
+ expect(result.seriesKeys).toContain('u1');
53
+ expect(result.seriesKeys).toContain('u2');
54
+ expect(result.seriesLabels.u1).toBe('Alice');
55
+ expect(result.seriesLabels.u2).toBe('Bob');
56
+
57
+ // Week 1: 2026-09-07 (contains 09-08 and 09-09)
58
+ const week1 = result.buckets.find((b) => b.date === '2026-09-07');
59
+ expect(week1).toBeDefined();
60
+ expect(week1?.values.u1).toBe(8.0);
61
+ expect(week1?.values.u2).toBe(0);
62
+ expect(week1?.totalHours).toBe(8.0);
63
+ expect(week1?.capacity?.u1).toBe(35);
64
+ expect(week1?.capacity?.u2).toBe(40);
65
+
66
+ // Week 2: 2026-09-14 (contains 09-15)
67
+ const week2 = result.buckets.find((b) => b.date === '2026-09-14');
68
+ expect(week2).toBeDefined();
69
+ expect(week2?.values.u2).toBe(6.0);
70
+ expect(week2?.values.u1).toBe(0);
71
+ expect(week2?.totalHours).toBe(6.0);
72
+
73
+ // Total
74
+ expect(result.totalHours).toBe(14.0);
75
+ });
76
+
77
+ it('distributes scheduled estimated hours evenly across task duration', () => {
78
+ const tasks: Task[] = [
79
+ {
80
+ id: 't1',
81
+ projectId: 'p1',
82
+ title: 'Multi-day Task',
83
+ status: 'todo',
84
+ priority: 'high',
85
+ assigneeId: 'u1',
86
+ estimatedHours: 40,
87
+ plannedStartDate: '2026-09-07', // Monday
88
+ dueDate: '2026-09-11', // Friday (5 days total = 8h/day)
89
+ createdAt: '2026-09-01',
90
+ updatedAt: '2026-09-01'
91
+ }
92
+ ];
93
+
94
+ const result = calculateWorkloadDistribution(
95
+ { tasks, users },
96
+ {
97
+ startDate: '2026-09-07',
98
+ endDate: '2026-09-11',
99
+ interval: 'day',
100
+ groupBy: 'assignee',
101
+ metric: 'scheduled'
102
+ }
103
+ );
104
+
105
+ expect(result.buckets.length).toBe(5);
106
+ for (const b of result.buckets) {
107
+ expect(b.values.u1).toBe(8);
108
+ expect(b.totalHours).toBe(8);
109
+ expect(b.capacity?.u1).toBe(7); // 35 / 5 = 7h/day
110
+ }
111
+ expect(result.totalHours).toBe(40);
112
+ });
113
+
114
+ it('handles grouping by team with custom team capacity and unassigned tasks', () => {
115
+ const tasks: Task[] = [
116
+ {
117
+ id: 't1',
118
+ projectId: 'p1',
119
+ title: 'Frontend Task',
120
+ status: 'in_progress',
121
+ priority: 'medium',
122
+ teamId: 'team_frontend',
123
+ estimatedHours: 20,
124
+ plannedStartDate: '2026-09-07',
125
+ dueDate: '2026-09-07',
126
+ createdAt: '2026-09-01',
127
+ updatedAt: '2026-09-01'
128
+ },
129
+ {
130
+ id: 't2',
131
+ projectId: 'p1',
132
+ title: 'Unassigned Task',
133
+ status: 'todo',
134
+ priority: 'low',
135
+ estimatedHours: 10,
136
+ plannedStartDate: '2026-09-07',
137
+ dueDate: '2026-09-07',
138
+ createdAt: '2026-09-01',
139
+ updatedAt: '2026-09-01'
140
+ }
141
+ ];
142
+
143
+ const result = calculateWorkloadDistribution(
144
+ { tasks, teams },
145
+ {
146
+ startDate: '2026-09-07',
147
+ endDate: '2026-09-13',
148
+ interval: 'week',
149
+ groupBy: 'team',
150
+ metric: 'scheduled'
151
+ }
152
+ );
153
+
154
+ expect(result.seriesKeys).toContain('team_frontend');
155
+ expect(result.seriesKeys).toContain('unassigned');
156
+ expect(result.seriesLabels.team_frontend).toBe('Frontend');
157
+ expect(result.seriesLabels.unassigned).toBe('Unassigned');
158
+
159
+ const bucket = result.buckets[0];
160
+ expect(bucket.values.team_frontend).toBe(20);
161
+ expect(bucket.values.unassigned).toBe(10);
162
+ expect(bucket.totalHours).toBe(30);
163
+ });
164
+
165
+ it('calculates streamgraph-ready tabular structure without gaps for D3 stack', () => {
166
+ const tasks: Task[] = [
167
+ {
168
+ id: 't1',
169
+ projectId: 'p1',
170
+ title: 'Task A',
171
+ status: 'done',
172
+ priority: 'high',
173
+ assigneeId: 'u1',
174
+ createdAt: '2026-09-01',
175
+ updatedAt: '2026-09-01'
176
+ }
177
+ ];
178
+
179
+ const timeEntries: TimeEntry[] = [
180
+ { id: 'te1', taskId: 't1', userId: 'u1', hours: 5, loggedAt: '2026-09-01' },
181
+ { id: 'te2', taskId: 't1', userId: 'u2', hours: 8, loggedAt: '2026-09-08' }
182
+ ];
183
+
184
+ const result = calculateWorkloadDistribution(
185
+ { tasks, timeEntries, users },
186
+ {
187
+ startDate: '2026-09-01',
188
+ endDate: '2026-09-14',
189
+ interval: 'week',
190
+ groupBy: 'assignee',
191
+ metric: 'logged'
192
+ }
193
+ );
194
+
195
+ // Verify all buckets contain numerical values (0, not undefined) for every series key
196
+ for (const bucket of result.buckets) {
197
+ for (const key of result.seriesKeys) {
198
+ expect(typeof bucket.values[key]).toBe('number');
199
+ expect(bucket.values[key]).toBeGreaterThanOrEqual(0);
200
+ }
201
+ }
202
+ });
203
+
204
+ it('integrates with CriticalPathEngine.getWorkloadDistribution', async () => {
205
+ const store = new InMemoryStore();
206
+ const engine = new CriticalPathEngine({ store });
207
+
208
+ const project = await engine.createProject({ name: 'Workload Project' });
209
+ const task = await engine.createTask({
210
+ projectId: project.id,
211
+ title: 'Dev Task',
212
+ status: 'in_progress',
213
+ assigneeId: 'u1',
214
+ estimatedHours: 15,
215
+ plannedStartDate: '2026-09-07',
216
+ dueDate: '2026-09-07'
217
+ });
218
+
219
+ await engine.logTime({
220
+ taskId: task.id,
221
+ userId: 'u1',
222
+ hours: 5,
223
+ loggedAt: '2026-09-07T10:00:00.000Z'
224
+ });
225
+
226
+ const distribution = await engine.getWorkloadDistribution(project.id, {
227
+ startDate: '2026-09-07',
228
+ endDate: '2026-09-13',
229
+ interval: 'week',
230
+ metric: 'blended'
231
+ });
232
+
233
+ expect(distribution.projectId).toBe(project.id);
234
+ expect(distribution.totalHours).toBeGreaterThan(0);
235
+ expect(distribution.buckets.length).toBeGreaterThan(0);
236
+ expect(distribution.seriesKeys).toContain('u1');
237
+ });
238
+
239
+ it('distributes task effort only on working days and reduces capacity on holidays', () => {
240
+ // Friday 2026-09-18 to Tuesday 2026-09-22 (5 calendar days: Fri, Sat, Sun, Mon, Tue)
241
+ // Monday 2026-09-21 is a Holiday!
242
+ // Active working days: Friday 2026-09-18 and Tuesday 2026-09-22 (2 days total)
243
+ // 16 estimated hours should be divided into 8h on Fri, 0h on Sat, 0h on Sun, 0h on Mon, 8h on Tue!
244
+ const customSchedule = {
245
+ name: 'Holiday Calendar',
246
+ defaultHoursPerDay: 8,
247
+ days: [
248
+ { dayOfWeek: 0, isWorkingDay: false },
249
+ { dayOfWeek: 1, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
250
+ { dayOfWeek: 2, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
251
+ { dayOfWeek: 3, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
252
+ { dayOfWeek: 4, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
253
+ { dayOfWeek: 5, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
254
+ { dayOfWeek: 6, isWorkingDay: false }
255
+ ],
256
+ holidays: [{ date: '2026-09-21', name: 'Monday Holiday' }]
257
+ };
258
+
259
+ const tasks: Task[] = [
260
+ {
261
+ id: 't_holiday',
262
+ projectId: 'p1',
263
+ title: 'Holiday Spanning Task',
264
+ status: 'todo',
265
+ priority: 'high',
266
+ assigneeId: 'u2',
267
+ estimatedHours: 16,
268
+ plannedStartDate: '2026-09-18', // Friday
269
+ dueDate: '2026-09-22', // Tuesday
270
+ createdAt: '2026-09-01',
271
+ updatedAt: '2026-09-01'
272
+ }
273
+ ];
274
+
275
+ const result = calculateWorkloadDistribution(
276
+ { tasks, users },
277
+ {
278
+ startDate: '2026-09-18',
279
+ endDate: '2026-09-22',
280
+ interval: 'day',
281
+ groupBy: 'assignee',
282
+ metric: 'scheduled',
283
+ schedule: customSchedule
284
+ }
285
+ );
286
+
287
+ const fri = result.buckets.find((b) => b.date === '2026-09-18');
288
+ const sat = result.buckets.find((b) => b.date === '2026-09-19');
289
+ const sun = result.buckets.find((b) => b.date === '2026-09-20');
290
+ const mon = result.buckets.find((b) => b.date === '2026-09-21');
291
+ const tue = result.buckets.find((b) => b.date === '2026-09-22');
292
+
293
+ expect(fri?.values.u2).toBe(8);
294
+ expect(fri?.capacity?.u2).toBe(8);
295
+
296
+ expect(sat?.values.u2).toBe(0);
297
+ expect(sat?.capacity?.u2).toBe(0);
298
+
299
+ expect(sun?.values.u2).toBe(0);
300
+ expect(sun?.capacity?.u2).toBe(0);
301
+
302
+ // Monday is holiday: 0 scheduled effort, 0 capacity
303
+ expect(mon?.values.u2).toBe(0);
304
+ expect(mon?.capacity?.u2).toBe(0);
305
+
306
+ expect(tue?.values.u2).toBe(8);
307
+ expect(tue?.capacity?.u2).toBe(8);
308
+
309
+ expect(result.totalHours).toBe(16);
310
+ });
311
+ });