@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.
- package/CHANGELOG.md +23 -0
- package/README.md +71 -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 +2 -0
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +2 -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 +10 -0
- package/dist/domain/workload.d.ts.map +1 -0
- package/dist/domain/workload.js +392 -0
- package/dist/domain/workload.js.map +1 -0
- package/dist/domain/workload.test.d.ts +2 -0
- package/dist/domain/workload.test.d.ts.map +1 -0
- package/dist/domain/workload.test.js +261 -0
- package/dist/domain/workload.test.js.map +1 -0
- package/dist/engine/index.d.ts +5 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +83 -3
- 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 +81 -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 +2 -0
- package/src/domain/ladder.ts +25 -4
- package/src/domain/metrics.test.ts +6 -4
- package/src/domain/workload.test.ts +311 -0
- package/src/domain/workload.ts +457 -0
- package/src/engine/index.ts +103 -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 +89 -0
- package/src/utils/status.ts +7 -2
- package/tsconfig.tsbuildinfo +1 -1
package/src/domain/ladder.ts
CHANGED
|
@@ -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
|
+
});
|