@critical-path/core 0.16.0 → 0.18.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 (48) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +50 -1
  3. package/dist/domain/entities.d.ts.map +1 -1
  4. package/dist/domain/entities.js +5 -0
  5. package/dist/domain/entities.js.map +1 -1
  6. package/dist/domain/index.d.ts +2 -0
  7. package/dist/domain/index.d.ts.map +1 -1
  8. package/dist/domain/index.js +2 -0
  9. package/dist/domain/index.js.map +1 -1
  10. package/dist/domain/ladder.d.ts.map +1 -1
  11. package/dist/domain/ladder.js +14 -0
  12. package/dist/domain/ladder.js.map +1 -1
  13. package/dist/domain/ladder.test.js +3 -3
  14. package/dist/domain/ladder.test.js.map +1 -1
  15. package/dist/domain/metrics.d.ts +29 -0
  16. package/dist/domain/metrics.d.ts.map +1 -0
  17. package/dist/domain/metrics.js +403 -0
  18. package/dist/domain/metrics.js.map +1 -0
  19. package/dist/domain/metrics.test.d.ts +2 -0
  20. package/dist/domain/metrics.test.d.ts.map +1 -0
  21. package/dist/domain/metrics.test.js +251 -0
  22. package/dist/domain/metrics.test.js.map +1 -0
  23. package/dist/domain/workload.d.ts +10 -0
  24. package/dist/domain/workload.d.ts.map +1 -0
  25. package/dist/domain/workload.js +349 -0
  26. package/dist/domain/workload.js.map +1 -0
  27. package/dist/domain/workload.test.d.ts +2 -0
  28. package/dist/domain/workload.test.d.ts.map +1 -0
  29. package/dist/domain/workload.test.js +201 -0
  30. package/dist/domain/workload.test.js.map +1 -0
  31. package/dist/engine/index.d.ts +5 -1
  32. package/dist/engine/index.d.ts.map +1 -1
  33. package/dist/engine/index.js +55 -2
  34. package/dist/engine/index.js.map +1 -1
  35. package/dist/types/index.d.ts +91 -0
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/domain/entities.ts +6 -0
  39. package/src/domain/index.ts +2 -0
  40. package/src/domain/ladder.test.ts +3 -3
  41. package/src/domain/ladder.ts +16 -0
  42. package/src/domain/metrics.test.ts +286 -0
  43. package/src/domain/metrics.ts +484 -0
  44. package/src/domain/workload.test.ts +238 -0
  45. package/src/domain/workload.ts +403 -0
  46. package/src/engine/index.ts +86 -3
  47. package/src/types/index.ts +110 -1
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,238 @@
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
+ });
@@ -0,0 +1,403 @@
1
+ import type {
2
+ Task,
3
+ TimeEntry,
4
+ User,
5
+ Team,
6
+ WorkloadInterval,
7
+ WorkloadGroupBy,
8
+ WorkloadMetric,
9
+ WorkloadBucket,
10
+ WorkloadDistribution,
11
+ WorkloadDistributionOptions
12
+ } from '../types/index.js';
13
+
14
+ export interface WorkloadContext {
15
+ tasks: Task[];
16
+ timeEntries?: TimeEntry[];
17
+ users?: User[];
18
+ teams?: Team[];
19
+ projectId?: string;
20
+ }
21
+
22
+ function toDateString(d: Date): string {
23
+ return d.toISOString().split('T')[0];
24
+ }
25
+
26
+ function parseDateOnly(isoString: string): Date {
27
+ const parts = isoString.split('T')[0].split('-');
28
+ return new Date(Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)));
29
+ }
30
+
31
+ function addDays(d: Date, days: number): Date {
32
+ const res = new Date(d.getTime());
33
+ res.setUTCDate(res.getUTCDate() + days);
34
+ return res;
35
+ }
36
+
37
+ function startOfWeek(d: Date): Date {
38
+ const date = new Date(d.getTime());
39
+ const day = date.getUTCDay();
40
+ // Monday is day 1, Sunday is day 0 -> shift Sunday to 7
41
+ const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
42
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), diff));
43
+ }
44
+
45
+ function startOfMonth(d: Date): Date {
46
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
47
+ }
48
+
49
+ function nextBucketDate(d: Date, interval: WorkloadInterval): Date {
50
+ if (interval === 'day') {
51
+ return addDays(d, 1);
52
+ }
53
+ if (interval === 'week') {
54
+ return addDays(d, 7);
55
+ }
56
+ // month: next month 1st
57
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
58
+ }
59
+
60
+ function getBucketKey(d: Date, interval: WorkloadInterval): string {
61
+ if (interval === 'day') {
62
+ return toDateString(d);
63
+ }
64
+ if (interval === 'week') {
65
+ return toDateString(startOfWeek(d));
66
+ }
67
+ return toDateString(startOfMonth(d));
68
+ }
69
+
70
+ function getTaskDurationHours(task: Task): number {
71
+ if (typeof task.estimatedHours === 'number' && task.estimatedHours >= 0) {
72
+ return task.estimatedHours;
73
+ }
74
+ if (typeof task.estimatedDurationMinutes === 'number' && task.estimatedDurationMinutes >= 0) {
75
+ return task.estimatedDurationMinutes / 60;
76
+ }
77
+ if (typeof task.actualHours === 'number' && task.actualHours > 0) {
78
+ return task.actualHours;
79
+ }
80
+ return 1;
81
+ }
82
+
83
+ export function calculateWorkloadDistribution(
84
+ context: WorkloadContext,
85
+ options: WorkloadDistributionOptions = {}
86
+ ): WorkloadDistribution {
87
+ const { tasks, timeEntries = [], users = [], teams = [], projectId } = context;
88
+ const interval: WorkloadInterval = options.interval || 'week';
89
+ const groupBy: WorkloadGroupBy = options.groupBy || 'assignee';
90
+ const metric: WorkloadMetric = options.metric || 'blended';
91
+ const defaultWeeklyCapacity = options.defaultWeeklyCapacityHours ?? 40;
92
+
93
+ // Build lookups for human-readable labels
94
+ const userMap = new Map<string, User>(users.map((u) => [u.id, u]));
95
+ const teamMap = new Map<string, Team>(teams.map((t) => [t.id, t]));
96
+
97
+ // 1. Determine date range
98
+ let minDate: Date | null = options.startDate ? parseDateOnly(options.startDate) : null;
99
+ let maxDate: Date | null = options.endDate ? parseDateOnly(options.endDate) : null;
100
+
101
+ if (!minDate || !maxDate) {
102
+ const dates: number[] = [];
103
+ for (const t of tasks) {
104
+ if (t.plannedStartDate) dates.push(parseDateOnly(t.plannedStartDate).getTime());
105
+ if (t.actualStartDate) dates.push(parseDateOnly(t.actualStartDate).getTime());
106
+ if (t.dueDate) dates.push(parseDateOnly(t.dueDate).getTime());
107
+ if (t.actualEndDate) dates.push(parseDateOnly(t.actualEndDate).getTime());
108
+ if (t.createdAt) dates.push(parseDateOnly(t.createdAt).getTime());
109
+ }
110
+ for (const te of timeEntries) {
111
+ if (te.loggedAt) dates.push(parseDateOnly(te.loggedAt).getTime());
112
+ }
113
+
114
+ if (dates.length > 0) {
115
+ if (!minDate) minDate = new Date(Math.min(...dates));
116
+ if (!maxDate) maxDate = new Date(Math.max(...dates));
117
+ } else {
118
+ const now = new Date();
119
+ if (!minDate) minDate = addDays(now, -7);
120
+ if (!maxDate) maxDate = addDays(now, 28);
121
+ }
122
+ }
123
+
124
+ // Ensure minDate <= maxDate
125
+ if (minDate > maxDate) {
126
+ maxDate = new Date(minDate.getTime());
127
+ }
128
+
129
+ // Align start to bucket boundary
130
+ let cursor = interval === 'week' ? startOfWeek(minDate) : interval === 'month' ? startOfMonth(minDate) : minDate;
131
+ const endLimit = interval === 'week' ? startOfWeek(maxDate) : interval === 'month' ? startOfMonth(maxDate) : maxDate;
132
+
133
+ // Generate contiguous buckets
134
+ interface InternalBucket {
135
+ date: string;
136
+ startDate: Date;
137
+ endDate: Date;
138
+ timestamp: number;
139
+ values: Record<string, number>;
140
+ }
141
+
142
+ const bucketList: InternalBucket[] = [];
143
+ const bucketMap = new Map<string, InternalBucket>();
144
+
145
+ while (cursor <= endLimit || bucketList.length === 0) {
146
+ const dateStr = toDateString(cursor);
147
+ const nextDate = nextBucketDate(cursor, interval);
148
+ const b: InternalBucket = {
149
+ date: dateStr,
150
+ startDate: new Date(cursor.getTime()),
151
+ endDate: nextDate,
152
+ timestamp: cursor.getTime(),
153
+ values: {}
154
+ };
155
+ bucketList.push(b);
156
+ bucketMap.set(dateStr, b);
157
+ cursor = nextDate;
158
+ }
159
+
160
+ const seriesKeySet = new Set<string>();
161
+
162
+ const getDimensionKey = (task: Task, timeEntry?: TimeEntry): string => {
163
+ switch (groupBy) {
164
+ case 'assignee':
165
+ if (timeEntry?.userId) return timeEntry.userId;
166
+ return task.assigneeId || 'unassigned';
167
+ case 'team':
168
+ return task.teamId || (task.customFields?.teamId as string) || 'unassigned';
169
+ case 'taskType':
170
+ return task.taskType || (task.customFields?.type as string) || 'standard';
171
+ case 'priority':
172
+ return task.priority || 'none';
173
+ case 'status':
174
+ return task.status || 'todo';
175
+ default:
176
+ return 'unassigned';
177
+ }
178
+ };
179
+
180
+ const addValue = (bucket: InternalBucket, key: string, hours: number) => {
181
+ if (hours <= 0) return;
182
+ seriesKeySet.add(key);
183
+ bucket.values[key] = (bucket.values[key] || 0) + hours;
184
+ };
185
+
186
+ const todayStr = toDateString(new Date());
187
+ const taskMap = new Map<string, Task>(tasks.map((t) => [t.id, t]));
188
+
189
+ // 2. Process Logged Hours from TimeEntries
190
+ if (metric === 'logged' || metric === 'blended') {
191
+ for (const entry of timeEntries) {
192
+ if (!entry.hours || entry.hours <= 0) continue;
193
+ const entryDate = parseDateOnly(entry.loggedAt);
194
+ const bKey = getBucketKey(entryDate, interval);
195
+ const bucket = bucketMap.get(bKey);
196
+ if (bucket) {
197
+ const associatedTask = taskMap.get(entry.taskId) || ({
198
+ id: entry.taskId,
199
+ projectId: projectId || '',
200
+ title: '',
201
+ status: 'in_progress',
202
+ priority: 'medium',
203
+ createdAt: entry.loggedAt,
204
+ updatedAt: entry.loggedAt
205
+ } as Task);
206
+ const dimKey = getDimensionKey(associatedTask, entry);
207
+ addValue(bucket, dimKey, entry.hours);
208
+ }
209
+ }
210
+ }
211
+
212
+ // 3. Process Scheduled / Remaining / Blended Hours from Tasks
213
+ if (metric === 'scheduled' || metric === 'remaining' || metric === 'blended') {
214
+ for (const task of tasks) {
215
+ const estimated = getTaskDurationHours(task);
216
+ const logged = task.loggedHours || 0;
217
+ let hoursToDistribute = 0;
218
+
219
+ if (metric === 'scheduled') {
220
+ hoursToDistribute = estimated;
221
+ } else if (metric === 'remaining' || metric === 'blended') {
222
+ if (task.semanticStatus === 'completed' || task.semanticStatus === 'canceled') {
223
+ hoursToDistribute = 0;
224
+ } else {
225
+ hoursToDistribute = Math.max(0, estimated - logged);
226
+ }
227
+ }
228
+
229
+ if (hoursToDistribute <= 0) continue;
230
+
231
+ const dimKey = getDimensionKey(task);
232
+
233
+ // Determine task date range
234
+ let taskStart = task.plannedStartDate
235
+ ? parseDateOnly(task.plannedStartDate)
236
+ : task.actualStartDate
237
+ ? parseDateOnly(task.actualStartDate)
238
+ : task.createdAt
239
+ ? parseDateOnly(task.createdAt)
240
+ : minDate;
241
+
242
+ let taskEnd = task.dueDate
243
+ ? parseDateOnly(task.dueDate)
244
+ : taskStart
245
+ ? addDays(taskStart, Math.max(1, Math.ceil(hoursToDistribute / 8)))
246
+ : maxDate;
247
+
248
+ // In blended mode, uncompleted tasks' remaining hours start from today or planned start (whichever is later)
249
+ if (metric === 'blended') {
250
+ const today = parseDateOnly(todayStr);
251
+ if (taskStart < today) {
252
+ taskStart = today;
253
+ }
254
+ if (taskEnd < taskStart) {
255
+ taskEnd = addDays(taskStart, 1);
256
+ }
257
+ }
258
+
259
+ if (taskEnd < taskStart) {
260
+ taskEnd = taskStart;
261
+ }
262
+
263
+ // Calculate days in task window
264
+ const msPerDay = 1000 * 60 * 60 * 24;
265
+ const totalDays = Math.max(1, Math.round((taskEnd.getTime() - taskStart.getTime()) / msPerDay) + 1);
266
+ const hoursPerDay = hoursToDistribute / totalDays;
267
+
268
+ // Distribute across overlapping buckets
269
+ for (const bucket of bucketList) {
270
+ const bucketLastDay = addDays(bucket.endDate, -1);
271
+ const overlapStart = new Date(Math.max(taskStart.getTime(), bucket.startDate.getTime()));
272
+ const overlapEnd = new Date(Math.min(taskEnd.getTime(), bucketLastDay.getTime()));
273
+
274
+ if (overlapStart <= overlapEnd) {
275
+ const overlapDays = Math.round((overlapEnd.getTime() - overlapStart.getTime()) / msPerDay) + 1;
276
+ const bucketEffort = overlapDays * hoursPerDay;
277
+ addValue(bucket, dimKey, bucketEffort);
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ // Ensure all registered users are in series keys if grouping by assignee
284
+ if (groupBy === 'assignee' && users.length > 0) {
285
+ for (const u of users) {
286
+ seriesKeySet.add(u.id);
287
+ }
288
+ }
289
+ if (groupBy === 'team' && teams.length > 0) {
290
+ for (const t of teams) {
291
+ seriesKeySet.add(t.id);
292
+ }
293
+ }
294
+
295
+ const seriesKeys = Array.from(seriesKeySet).sort();
296
+
297
+ // Create human-readable series labels
298
+ const seriesLabels: Record<string, string> = {};
299
+ for (const key of seriesKeys) {
300
+ if (groupBy === 'assignee') {
301
+ const u = userMap.get(key);
302
+ seriesLabels[key] = u ? u.name : key === 'unassigned' ? 'Unassigned' : key;
303
+ } else if (groupBy === 'team') {
304
+ const t = teamMap.get(key);
305
+ seriesLabels[key] = t ? t.name : key === 'unassigned' ? 'Unassigned' : key;
306
+ } else {
307
+ seriesLabels[key] = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
308
+ }
309
+ }
310
+
311
+ // 4. Calculate Capacity & Normalize Buckets
312
+ let totalDistributedHours = 0;
313
+ let sumCapacityAcrossBuckets = 0;
314
+
315
+ const buckets: WorkloadBucket[] = bucketList.map((ib) => {
316
+ const values: Record<string, number> = {};
317
+ const capacity: Record<string, number> = {};
318
+ let bucketTotalHours = 0;
319
+ let bucketTotalCapacity = 0;
320
+
321
+ for (const key of seriesKeys) {
322
+ const rawHours = ib.values[key] || 0;
323
+ const roundedHours = Math.round(rawHours * 100) / 100;
324
+ values[key] = roundedHours;
325
+ bucketTotalHours += roundedHours;
326
+
327
+ // Capacity calculation
328
+ let weeklyCap = defaultWeeklyCapacity;
329
+ if (options.capacityOverrides && typeof options.capacityOverrides[key] === 'number') {
330
+ weeklyCap = options.capacityOverrides[key];
331
+ } else if (groupBy === 'assignee') {
332
+ const u = userMap.get(key);
333
+ if (u?.weeklyCapacityHours) weeklyCap = u.weeklyCapacityHours;
334
+ else if (key === 'unassigned') weeklyCap = 0;
335
+ } else if (groupBy === 'team') {
336
+ const t = teamMap.get(key);
337
+ if (t?.weeklyCapacityHours) {
338
+ weeklyCap = t.weeklyCapacityHours;
339
+ } else if (t?.memberIds?.length) {
340
+ weeklyCap = t.memberIds.length * defaultWeeklyCapacity;
341
+ } else if (key === 'unassigned') {
342
+ weeklyCap = 0;
343
+ }
344
+ }
345
+
346
+ let bucketCap = 0;
347
+ if (interval === 'week') {
348
+ bucketCap = weeklyCap;
349
+ } else if (interval === 'day') {
350
+ bucketCap = Math.round((weeklyCap / 5) * 100) / 100;
351
+ } else if (interval === 'month') {
352
+ const daysInMonth = new Date(Date.UTC(ib.startDate.getUTCFullYear(), ib.startDate.getUTCMonth() + 1, 0)).getUTCDate();
353
+ bucketCap = Math.round((weeklyCap * (daysInMonth / 7)) * 100) / 100;
354
+ }
355
+
356
+ capacity[key] = bucketCap;
357
+ bucketTotalCapacity += bucketCap;
358
+ }
359
+
360
+ bucketTotalHours = Math.round(bucketTotalHours * 100) / 100;
361
+ bucketTotalCapacity = Math.round(bucketTotalCapacity * 100) / 100;
362
+ totalDistributedHours += bucketTotalHours;
363
+ sumCapacityAcrossBuckets += bucketTotalCapacity;
364
+
365
+ const utilizationRatio =
366
+ bucketTotalCapacity > 0
367
+ ? Math.round((bucketTotalHours / bucketTotalCapacity) * 1000) / 1000
368
+ : undefined;
369
+
370
+ return {
371
+ date: ib.date,
372
+ timestamp: ib.timestamp,
373
+ totalHours: bucketTotalHours,
374
+ values,
375
+ capacity,
376
+ totalCapacity: bucketTotalCapacity,
377
+ utilizationRatio
378
+ };
379
+ });
380
+
381
+ totalDistributedHours = Math.round(totalDistributedHours * 100) / 100;
382
+ sumCapacityAcrossBuckets = Math.round(sumCapacityAcrossBuckets * 100) / 100;
383
+
384
+ const averageUtilization =
385
+ sumCapacityAcrossBuckets > 0
386
+ ? Math.round((totalDistributedHours / sumCapacityAcrossBuckets) * 1000) / 1000
387
+ : undefined;
388
+
389
+ return {
390
+ projectId,
391
+ startDate: toDateString(minDate),
392
+ endDate: toDateString(maxDate),
393
+ interval,
394
+ groupBy,
395
+ metric,
396
+ seriesKeys,
397
+ seriesLabels,
398
+ buckets,
399
+ totalHours: totalDistributedHours,
400
+ totalCapacity: sumCapacityAcrossBuckets,
401
+ averageUtilization
402
+ };
403
+ }