@critical-path/core 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +1 -1
  2. package/dist/domain/authorization.d.ts.map +1 -1
  3. package/dist/domain/authorization.js +10 -7
  4. package/dist/domain/authorization.js.map +1 -1
  5. package/dist/engine/index.d.ts +6 -0
  6. package/dist/engine/index.d.ts.map +1 -1
  7. package/dist/engine/index.js +28 -2
  8. package/dist/engine/index.js.map +1 -1
  9. package/dist/schemas/index.d.ts +42 -12
  10. package/dist/schemas/index.d.ts.map +1 -1
  11. package/dist/schemas/index.js +4 -1
  12. package/dist/schemas/index.js.map +1 -1
  13. package/dist/store/firebase.d.ts +2 -2
  14. package/dist/store/firebase.d.ts.map +1 -1
  15. package/dist/store/firebase.js +4 -2
  16. package/dist/store/firebase.js.map +1 -1
  17. package/dist/store/index.d.ts +7 -2
  18. package/dist/store/index.d.ts.map +1 -1
  19. package/dist/store/index.js +3 -2
  20. package/dist/store/index.js.map +1 -1
  21. package/dist/store/sqlite.d.ts +2 -2
  22. package/dist/store/sqlite.d.ts.map +1 -1
  23. package/dist/store/sqlite.js +5 -3
  24. package/dist/store/sqlite.js.map +1 -1
  25. package/dist/testing/conformance.d.ts.map +1 -1
  26. package/dist/testing/conformance.js +9 -0
  27. package/dist/testing/conformance.js.map +1 -1
  28. package/dist/types/index.d.ts +13 -2
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/package.json +5 -1
  31. package/CHANGELOG.md +0 -593
  32. package/dist/deliverables.test.d.ts +0 -2
  33. package/dist/deliverables.test.d.ts.map +0 -1
  34. package/dist/deliverables.test.js +0 -232
  35. package/dist/deliverables.test.js.map +0 -1
  36. package/dist/domain/calendar.test.d.ts +0 -2
  37. package/dist/domain/calendar.test.d.ts.map +0 -1
  38. package/dist/domain/calendar.test.js +0 -181
  39. package/dist/domain/calendar.test.js.map +0 -1
  40. package/dist/domain/cpm.test.d.ts +0 -2
  41. package/dist/domain/cpm.test.d.ts.map +0 -1
  42. package/dist/domain/cpm.test.js +0 -166
  43. package/dist/domain/cpm.test.js.map +0 -1
  44. package/dist/domain/ddd.test.d.ts +0 -2
  45. package/dist/domain/ddd.test.d.ts.map +0 -1
  46. package/dist/domain/ddd.test.js +0 -349
  47. package/dist/domain/ddd.test.js.map +0 -1
  48. package/dist/domain/ladder.test.d.ts +0 -2
  49. package/dist/domain/ladder.test.d.ts.map +0 -1
  50. package/dist/domain/ladder.test.js +0 -213
  51. package/dist/domain/ladder.test.js.map +0 -1
  52. package/dist/domain/metrics.test.d.ts +0 -2
  53. package/dist/domain/metrics.test.d.ts.map +0 -1
  54. package/dist/domain/metrics.test.js +0 -252
  55. package/dist/domain/metrics.test.js.map +0 -1
  56. package/dist/domain/task-duration.test.d.ts +0 -2
  57. package/dist/domain/task-duration.test.d.ts.map +0 -1
  58. package/dist/domain/task-duration.test.js +0 -307
  59. package/dist/domain/task-duration.test.js.map +0 -1
  60. package/dist/domain/workload.test.d.ts +0 -2
  61. package/dist/domain/workload.test.d.ts.map +0 -1
  62. package/dist/domain/workload.test.js +0 -261
  63. package/dist/domain/workload.test.js.map +0 -1
  64. package/dist/engine/actor.test.d.ts +0 -2
  65. package/dist/engine/actor.test.d.ts.map +0 -1
  66. package/dist/engine/actor.test.js +0 -54
  67. package/dist/engine/actor.test.js.map +0 -1
  68. package/dist/engine/authorization.test.d.ts +0 -2
  69. package/dist/engine/authorization.test.d.ts.map +0 -1
  70. package/dist/engine/authorization.test.js +0 -154
  71. package/dist/engine/authorization.test.js.map +0 -1
  72. package/dist/engine/cascade.test.d.ts +0 -2
  73. package/dist/engine/cascade.test.d.ts.map +0 -1
  74. package/dist/engine/cascade.test.js +0 -99
  75. package/dist/engine/cascade.test.js.map +0 -1
  76. package/dist/engine/config.test.d.ts +0 -2
  77. package/dist/engine/config.test.d.ts.map +0 -1
  78. package/dist/engine/config.test.js +0 -46
  79. package/dist/engine/config.test.js.map +0 -1
  80. package/dist/engine/entity-events.test.d.ts +0 -2
  81. package/dist/engine/entity-events.test.d.ts.map +0 -1
  82. package/dist/engine/entity-events.test.js +0 -61
  83. package/dist/engine/entity-events.test.js.map +0 -1
  84. package/dist/index.test.d.ts +0 -2
  85. package/dist/index.test.d.ts.map +0 -1
  86. package/dist/index.test.js +0 -429
  87. package/dist/index.test.js.map +0 -1
  88. package/dist/plugins/plugins.test.d.ts +0 -2
  89. package/dist/plugins/plugins.test.d.ts.map +0 -1
  90. package/dist/plugins/plugins.test.js +0 -137
  91. package/dist/plugins/plugins.test.js.map +0 -1
  92. package/dist/schemas/schemas.test.d.ts +0 -2
  93. package/dist/schemas/schemas.test.d.ts.map +0 -1
  94. package/dist/schemas/schemas.test.js +0 -58
  95. package/dist/schemas/schemas.test.js.map +0 -1
  96. package/dist/status.test.d.ts +0 -2
  97. package/dist/status.test.d.ts.map +0 -1
  98. package/dist/status.test.js +0 -295
  99. package/dist/status.test.js.map +0 -1
  100. package/dist/storage/storage.test.d.ts +0 -2
  101. package/dist/storage/storage.test.d.ts.map +0 -1
  102. package/dist/storage/storage.test.js +0 -236
  103. package/dist/storage/storage.test.js.map +0 -1
  104. package/dist/storage/upload-limits.test.d.ts +0 -2
  105. package/dist/storage/upload-limits.test.d.ts.map +0 -1
  106. package/dist/storage/upload-limits.test.js +0 -31
  107. package/dist/storage/upload-limits.test.js.map +0 -1
  108. package/dist/store/conformance.test.d.ts +0 -2
  109. package/dist/store/conformance.test.d.ts.map +0 -1
  110. package/dist/store/conformance.test.js +0 -15
  111. package/dist/store/conformance.test.js.map +0 -1
  112. package/dist/store/firebase.test.d.ts +0 -2
  113. package/dist/store/firebase.test.d.ts.map +0 -1
  114. package/dist/store/firebase.test.js +0 -103
  115. package/dist/store/firebase.test.js.map +0 -1
  116. package/dist/store/query.test.d.ts +0 -2
  117. package/dist/store/query.test.d.ts.map +0 -1
  118. package/dist/store/query.test.js +0 -89
  119. package/dist/store/query.test.js.map +0 -1
  120. package/dist/store/sqlite.test.d.ts +0 -2
  121. package/dist/store/sqlite.test.d.ts.map +0 -1
  122. package/dist/store/sqlite.test.js +0 -177
  123. package/dist/store/sqlite.test.js.map +0 -1
  124. package/dist/task-state.test.d.ts +0 -2
  125. package/dist/task-state.test.d.ts.map +0 -1
  126. package/dist/task-state.test.js +0 -423
  127. package/dist/task-state.test.js.map +0 -1
  128. package/dist/use-case-validation.test.d.ts +0 -2
  129. package/dist/use-case-validation.test.d.ts.map +0 -1
  130. package/dist/use-case-validation.test.js +0 -281
  131. package/dist/use-case-validation.test.js.map +0 -1
  132. package/dist/utils/fractional-index.test.d.ts +0 -2
  133. package/dist/utils/fractional-index.test.d.ts.map +0 -1
  134. package/dist/utils/fractional-index.test.js +0 -182
  135. package/dist/utils/fractional-index.test.js.map +0 -1
  136. package/dist/utils/key.test.d.ts +0 -2
  137. package/dist/utils/key.test.d.ts.map +0 -1
  138. package/dist/utils/key.test.js +0 -65
  139. package/dist/utils/key.test.js.map +0 -1
  140. package/dist/utils/mentions.test.d.ts +0 -2
  141. package/dist/utils/mentions.test.d.ts.map +0 -1
  142. package/dist/utils/mentions.test.js +0 -114
  143. package/dist/utils/mentions.test.js.map +0 -1
  144. package/dist/webhooks/webhooks.test.d.ts +0 -2
  145. package/dist/webhooks/webhooks.test.d.ts.map +0 -1
  146. package/dist/webhooks/webhooks.test.js +0 -154
  147. package/dist/webhooks/webhooks.test.js.map +0 -1
  148. package/src/deliverables.test.ts +0 -284
  149. package/src/domain/authorization.ts +0 -82
  150. package/src/domain/calendar.test.ts +0 -216
  151. package/src/domain/calendar.ts +0 -502
  152. package/src/domain/cpm.test.ts +0 -189
  153. package/src/domain/cpm.ts +0 -258
  154. package/src/domain/custom-fields.ts +0 -172
  155. package/src/domain/ddd.test.ts +0 -442
  156. package/src/domain/entities.ts +0 -798
  157. package/src/domain/errors.ts +0 -38
  158. package/src/domain/events.ts +0 -369
  159. package/src/domain/graph.ts +0 -70
  160. package/src/domain/index.ts +0 -12
  161. package/src/domain/ladder.test.ts +0 -251
  162. package/src/domain/ladder.ts +0 -474
  163. package/src/domain/metrics.test.ts +0 -288
  164. package/src/domain/metrics.ts +0 -497
  165. package/src/domain/task-duration.test.ts +0 -354
  166. package/src/domain/workload.test.ts +0 -311
  167. package/src/domain/workload.ts +0 -457
  168. package/src/engine/actor.test.ts +0 -63
  169. package/src/engine/authorization.test.ts +0 -199
  170. package/src/engine/cascade.test.ts +0 -121
  171. package/src/engine/config.test.ts +0 -51
  172. package/src/engine/entity-events.test.ts +0 -67
  173. package/src/engine/index.ts +0 -2495
  174. package/src/index.test.ts +0 -514
  175. package/src/index.ts +0 -9
  176. package/src/plugins/index.ts +0 -103
  177. package/src/plugins/plugins.test.ts +0 -161
  178. package/src/schemas/index.ts +0 -402
  179. package/src/schemas/schemas.test.ts +0 -116
  180. package/src/status.test.ts +0 -354
  181. package/src/storage/file-storage.ts +0 -200
  182. package/src/storage/firebase-storage.ts +0 -209
  183. package/src/storage/index.ts +0 -3
  184. package/src/storage/s3.ts +0 -151
  185. package/src/storage/storage.test.ts +0 -274
  186. package/src/storage/upload-limits.test.ts +0 -41
  187. package/src/store/conformance.test.ts +0 -15
  188. package/src/store/firebase.test.ts +0 -131
  189. package/src/store/firebase.ts +0 -658
  190. package/src/store/index.ts +0 -623
  191. package/src/store/query.test.ts +0 -110
  192. package/src/store/query.ts +0 -113
  193. package/src/store/sqlite.test.ts +0 -218
  194. package/src/store/sqlite.ts +0 -1480
  195. package/src/task-state.test.ts +0 -506
  196. package/src/testing/conformance.ts +0 -319
  197. package/src/testing/index.ts +0 -1
  198. package/src/types/index.ts +0 -871
  199. package/src/use-case-validation.test.ts +0 -324
  200. package/src/utils/fractional-index.test.ts +0 -228
  201. package/src/utils/fractional-index.ts +0 -254
  202. package/src/utils/index.ts +0 -7
  203. package/src/utils/key.test.ts +0 -76
  204. package/src/utils/key.ts +0 -62
  205. package/src/utils/mentions.test.ts +0 -132
  206. package/src/utils/mentions.ts +0 -131
  207. package/src/utils/status.ts +0 -419
  208. package/src/utils/workflow.ts +0 -306
  209. package/src/webhooks/dispatcher.ts +0 -190
  210. package/src/webhooks/index.ts +0 -2
  211. package/src/webhooks/signature.ts +0 -57
  212. package/src/webhooks/webhooks.test.ts +0 -201
  213. package/tsconfig.json +0 -8
  214. package/tsconfig.tsbuildinfo +0 -1
@@ -1,457 +0,0 @@
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
- import {
14
- DEFAULT_WORK_SCHEDULE,
15
- getWorkingDaysList,
16
- getNetAvailableCapacity,
17
- getWorkingHoursInDay
18
- } from './calendar.js';
19
-
20
- export interface WorkloadContext {
21
- tasks: Task[];
22
- timeEntries?: TimeEntry[];
23
- users?: User[];
24
- teams?: Team[];
25
- projectId?: string;
26
- }
27
-
28
- function toDateString(d: Date): string {
29
- return d.toISOString().split('T')[0];
30
- }
31
-
32
- function parseDateOnly(isoString: string): Date {
33
- const parts = isoString.split('T')[0].split('-');
34
- return new Date(Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)));
35
- }
36
-
37
- function addDays(d: Date, days: number): Date {
38
- const res = new Date(d.getTime());
39
- res.setUTCDate(res.getUTCDate() + days);
40
- return res;
41
- }
42
-
43
- function startOfWeek(d: Date): Date {
44
- const date = new Date(d.getTime());
45
- const day = date.getUTCDay();
46
- // Monday is day 1, Sunday is day 0 -> shift Sunday to 7
47
- const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
48
- return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), diff));
49
- }
50
-
51
- function startOfMonth(d: Date): Date {
52
- return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
53
- }
54
-
55
- function nextBucketDate(d: Date, interval: WorkloadInterval): Date {
56
- if (interval === 'day') {
57
- return addDays(d, 1);
58
- }
59
- if (interval === 'week') {
60
- return addDays(d, 7);
61
- }
62
- // month: next month 1st
63
- return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
64
- }
65
-
66
- function getBucketKey(d: Date, interval: WorkloadInterval): string {
67
- if (interval === 'day') {
68
- return toDateString(d);
69
- }
70
- if (interval === 'week') {
71
- return toDateString(startOfWeek(d));
72
- }
73
- return toDateString(startOfMonth(d));
74
- }
75
-
76
- function getTaskDurationHours(task: Task): number {
77
- if (typeof task.estimatedHours === 'number' && task.estimatedHours >= 0) {
78
- return task.estimatedHours;
79
- }
80
- if (typeof task.estimatedDurationMinutes === 'number' && task.estimatedDurationMinutes >= 0) {
81
- return task.estimatedDurationMinutes / 60;
82
- }
83
- if (typeof task.actualHours === 'number' && task.actualHours > 0) {
84
- return task.actualHours;
85
- }
86
- return 1;
87
- }
88
-
89
- export function calculateWorkloadDistribution(
90
- context: WorkloadContext,
91
- options: WorkloadDistributionOptions = {}
92
- ): WorkloadDistribution {
93
- const { tasks, timeEntries = [], users = [], teams = [], projectId } = context;
94
- const interval: WorkloadInterval = options.interval || 'week';
95
- const groupBy: WorkloadGroupBy = options.groupBy || 'assignee';
96
- const metric: WorkloadMetric = options.metric || 'blended';
97
- const defaultWeeklyCapacity = options.defaultWeeklyCapacityHours ?? 40;
98
-
99
- // Build lookups for human-readable labels
100
- const userMap = new Map<string, User>(users.map((u) => [u.id, u]));
101
- const teamMap = new Map<string, Team>(teams.map((t) => [t.id, t]));
102
-
103
- // 1. Determine date range
104
- let minDate: Date | null = options.startDate ? parseDateOnly(options.startDate) : null;
105
- let maxDate: Date | null = options.endDate ? parseDateOnly(options.endDate) : null;
106
-
107
- if (!minDate || !maxDate) {
108
- const dates: number[] = [];
109
- for (const t of tasks) {
110
- if (t.plannedStartDate) dates.push(parseDateOnly(t.plannedStartDate).getTime());
111
- if (t.actualStartDate) dates.push(parseDateOnly(t.actualStartDate).getTime());
112
- if (t.dueDate) dates.push(parseDateOnly(t.dueDate).getTime());
113
- if (t.actualEndDate) dates.push(parseDateOnly(t.actualEndDate).getTime());
114
- if (t.createdAt) dates.push(parseDateOnly(t.createdAt).getTime());
115
- }
116
- for (const te of timeEntries) {
117
- if (te.loggedAt) dates.push(parseDateOnly(te.loggedAt).getTime());
118
- }
119
-
120
- if (dates.length > 0) {
121
- if (!minDate) minDate = new Date(Math.min(...dates));
122
- if (!maxDate) maxDate = new Date(Math.max(...dates));
123
- } else {
124
- const now = new Date();
125
- if (!minDate) minDate = addDays(now, -7);
126
- if (!maxDate) maxDate = addDays(now, 28);
127
- }
128
- }
129
-
130
- // Ensure minDate <= maxDate
131
- if (minDate > maxDate) {
132
- maxDate = new Date(minDate.getTime());
133
- }
134
-
135
- // Align start to bucket boundary
136
- let cursor = interval === 'week' ? startOfWeek(minDate) : interval === 'month' ? startOfMonth(minDate) : minDate;
137
- const endLimit = interval === 'week' ? startOfWeek(maxDate) : interval === 'month' ? startOfMonth(maxDate) : maxDate;
138
-
139
- // Generate contiguous buckets
140
- interface InternalBucket {
141
- date: string;
142
- startDate: Date;
143
- endDate: Date;
144
- timestamp: number;
145
- values: Record<string, number>;
146
- }
147
-
148
- const bucketList: InternalBucket[] = [];
149
- const bucketMap = new Map<string, InternalBucket>();
150
-
151
- while (cursor <= endLimit || bucketList.length === 0) {
152
- const dateStr = toDateString(cursor);
153
- const nextDate = nextBucketDate(cursor, interval);
154
- const b: InternalBucket = {
155
- date: dateStr,
156
- startDate: new Date(cursor.getTime()),
157
- endDate: nextDate,
158
- timestamp: cursor.getTime(),
159
- values: {}
160
- };
161
- bucketList.push(b);
162
- bucketMap.set(dateStr, b);
163
- cursor = nextDate;
164
- }
165
-
166
- const seriesKeySet = new Set<string>();
167
-
168
- const getDimensionKey = (task: Task, timeEntry?: TimeEntry): string => {
169
- switch (groupBy) {
170
- case 'assignee':
171
- if (timeEntry?.userId) return timeEntry.userId;
172
- return task.assigneeId || 'unassigned';
173
- case 'team':
174
- return task.teamId || (task.customFields?.teamId as string) || 'unassigned';
175
- case 'taskType':
176
- return task.taskType || (task.customFields?.type as string) || 'standard';
177
- case 'priority':
178
- return task.priority || 'none';
179
- case 'status':
180
- return task.status || 'todo';
181
- default:
182
- return 'unassigned';
183
- }
184
- };
185
-
186
- const addValue = (bucket: InternalBucket, key: string, hours: number) => {
187
- if (hours <= 0) return;
188
- seriesKeySet.add(key);
189
- bucket.values[key] = (bucket.values[key] || 0) + hours;
190
- };
191
-
192
- const todayStr = toDateString(new Date());
193
- const taskMap = new Map<string, Task>(tasks.map((t) => [t.id, t]));
194
-
195
- // 2. Process Logged Hours from TimeEntries
196
- if (metric === 'logged' || metric === 'blended') {
197
- for (const entry of timeEntries) {
198
- if (!entry.hours || entry.hours <= 0) continue;
199
- const entryDate = parseDateOnly(entry.loggedAt);
200
- const bKey = getBucketKey(entryDate, interval);
201
- const bucket = bucketMap.get(bKey);
202
- if (bucket) {
203
- const associatedTask = taskMap.get(entry.taskId) || ({
204
- id: entry.taskId,
205
- projectId: projectId || '',
206
- title: '',
207
- status: 'in_progress',
208
- priority: 'medium',
209
- createdAt: entry.loggedAt,
210
- updatedAt: entry.loggedAt
211
- } as Task);
212
- const dimKey = getDimensionKey(associatedTask, entry);
213
- addValue(bucket, dimKey, entry.hours);
214
- }
215
- }
216
- }
217
-
218
- // 3. Process Scheduled / Remaining / Blended Hours from Tasks
219
- if (metric === 'scheduled' || metric === 'remaining' || metric === 'blended') {
220
- for (const task of tasks) {
221
- const estimated = getTaskDurationHours(task);
222
- const logged = task.loggedHours || 0;
223
- let hoursToDistribute = 0;
224
-
225
- if (metric === 'scheduled') {
226
- hoursToDistribute = estimated;
227
- } else if (metric === 'remaining' || metric === 'blended') {
228
- if (task.semanticStatus === 'completed' || task.semanticStatus === 'canceled') {
229
- hoursToDistribute = 0;
230
- } else {
231
- hoursToDistribute = Math.max(0, estimated - logged);
232
- }
233
- }
234
-
235
- if (hoursToDistribute <= 0) continue;
236
-
237
- const dimKey = getDimensionKey(task);
238
-
239
- // Determine task date range
240
- let taskStart = task.plannedStartDate
241
- ? parseDateOnly(task.plannedStartDate)
242
- : task.actualStartDate
243
- ? parseDateOnly(task.actualStartDate)
244
- : task.createdAt
245
- ? parseDateOnly(task.createdAt)
246
- : minDate;
247
-
248
- let taskEnd = task.dueDate
249
- ? parseDateOnly(task.dueDate)
250
- : taskStart
251
- ? addDays(taskStart, Math.max(1, Math.ceil(hoursToDistribute / 8)))
252
- : maxDate;
253
-
254
- // In blended mode, uncompleted tasks' remaining hours start from today or planned start (whichever is later)
255
- if (metric === 'blended') {
256
- const today = parseDateOnly(todayStr);
257
- if (taskStart < today) {
258
- taskStart = today;
259
- }
260
- if (taskEnd < taskStart) {
261
- taskEnd = addDays(taskStart, 1);
262
- }
263
- }
264
-
265
- if (taskEnd < taskStart) {
266
- taskEnd = taskStart;
267
- }
268
-
269
- // Resolve schedule for task assignee / team / project
270
- const taskSchedule =
271
- (task.assigneeId && options.userSchedules?.[task.assigneeId]) ||
272
- (task.assigneeId && userMap.get(task.assigneeId)?.schedule) ||
273
- (task.teamId && options.teamSchedules?.[task.teamId]) ||
274
- (task.teamId && teamMap.get(task.teamId)?.schedule) ||
275
- options.schedule ||
276
- DEFAULT_WORK_SCHEDULE;
277
-
278
- const workingDays = getWorkingDaysList(taskStart, taskEnd, taskSchedule);
279
- const msPerDay = 1000 * 60 * 60 * 24;
280
-
281
- if (workingDays.length > 0) {
282
- const hoursPerWorkingDay = hoursToDistribute / workingDays.length;
283
-
284
- for (const bucket of bucketList) {
285
- const bucketStart = bucket.startDate.getTime();
286
- const bucketEnd = bucket.endDate.getTime();
287
-
288
- let workingDaysInBucket = 0;
289
- for (const wdStr of workingDays) {
290
- const wdTime = parseDateOnly(wdStr).getTime();
291
- if (wdTime >= bucketStart && wdTime < bucketEnd) {
292
- workingDaysInBucket++;
293
- }
294
- }
295
-
296
- if (workingDaysInBucket > 0) {
297
- const bucketEffort = workingDaysInBucket * hoursPerWorkingDay;
298
- addValue(bucket, dimKey, bucketEffort);
299
- }
300
- }
301
- } else {
302
- // Fallback if no working days in range (e.g. task scheduled on an all-holiday window)
303
- const totalDays = Math.max(1, Math.round((taskEnd.getTime() - taskStart.getTime()) / msPerDay) + 1);
304
- const hoursPerDay = hoursToDistribute / totalDays;
305
-
306
- for (const bucket of bucketList) {
307
- const bucketLastDay = addDays(bucket.endDate, -1);
308
- const overlapStart = new Date(Math.max(taskStart.getTime(), bucket.startDate.getTime()));
309
- const overlapEnd = new Date(Math.min(taskEnd.getTime(), bucketLastDay.getTime()));
310
-
311
- if (overlapStart <= overlapEnd) {
312
- const overlapDays = Math.round((overlapEnd.getTime() - overlapStart.getTime()) / msPerDay) + 1;
313
- const bucketEffort = overlapDays * hoursPerDay;
314
- addValue(bucket, dimKey, bucketEffort);
315
- }
316
- }
317
- }
318
- }
319
- }
320
-
321
- // Ensure all registered users are in series keys if grouping by assignee
322
- if (groupBy === 'assignee' && users.length > 0) {
323
- for (const u of users) {
324
- seriesKeySet.add(u.id);
325
- }
326
- }
327
- if (groupBy === 'team' && teams.length > 0) {
328
- for (const t of teams) {
329
- seriesKeySet.add(t.id);
330
- }
331
- }
332
-
333
- const seriesKeys = Array.from(seriesKeySet).sort();
334
-
335
- // Create human-readable series labels
336
- const seriesLabels: Record<string, string> = {};
337
- for (const key of seriesKeys) {
338
- if (groupBy === 'assignee') {
339
- const u = userMap.get(key);
340
- seriesLabels[key] = u ? u.name : key === 'unassigned' ? 'Unassigned' : key;
341
- } else if (groupBy === 'team') {
342
- const t = teamMap.get(key);
343
- seriesLabels[key] = t ? t.name : key === 'unassigned' ? 'Unassigned' : key;
344
- } else {
345
- seriesLabels[key] = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
346
- }
347
- }
348
-
349
- // 4. Calculate Capacity & Normalize Buckets
350
- let totalDistributedHours = 0;
351
- let sumCapacityAcrossBuckets = 0;
352
-
353
- const buckets: WorkloadBucket[] = bucketList.map((ib) => {
354
- const values: Record<string, number> = {};
355
- const capacity: Record<string, number> = {};
356
- let bucketTotalHours = 0;
357
- let bucketTotalCapacity = 0;
358
-
359
- for (const key of seriesKeys) {
360
- const rawHours = ib.values[key] || 0;
361
- const roundedHours = Math.round(rawHours * 100) / 100;
362
- values[key] = roundedHours;
363
- bucketTotalHours += roundedHours;
364
-
365
- // Capacity calculation
366
- const entitySchedule =
367
- (groupBy === 'assignee' && (options.userSchedules?.[key] || userMap.get(key)?.schedule)) ||
368
- (groupBy === 'team' && (options.teamSchedules?.[key] || teamMap.get(key)?.schedule)) ||
369
- options.schedule ||
370
- DEFAULT_WORK_SCHEDULE;
371
-
372
- let weeklyCap = defaultWeeklyCapacity;
373
- if (options.capacityOverrides && typeof options.capacityOverrides[key] === 'number') {
374
- weeklyCap = options.capacityOverrides[key];
375
- } else if (groupBy === 'assignee') {
376
- const u = userMap.get(key);
377
- if (u?.weeklyCapacityHours) weeklyCap = u.weeklyCapacityHours;
378
- else if (key === 'unassigned') weeklyCap = 0;
379
- } else if (groupBy === 'team') {
380
- const t = teamMap.get(key);
381
- if (t?.weeklyCapacityHours) {
382
- weeklyCap = t.weeklyCapacityHours;
383
- } else if (t?.memberIds?.length) {
384
- weeklyCap = t.memberIds.length * defaultWeeklyCapacity;
385
- } else if (key === 'unassigned') {
386
- weeklyCap = 0;
387
- }
388
- }
389
-
390
- let bucketCap = 0;
391
- if (key === 'unassigned') {
392
- bucketCap = 0;
393
- } else if (interval === 'week') {
394
- const netWeekCap = getNetAvailableCapacity(ib.startDate, addDays(ib.endDate, -1), entitySchedule);
395
- const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
396
- const scale = baseline > 0 ? weeklyCap / baseline : 1;
397
- bucketCap = Math.round(netWeekCap * scale * 100) / 100;
398
- } else if (interval === 'day') {
399
- const dayHours = getWorkingHoursInDay(ib.startDate, entitySchedule);
400
- const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
401
- const scale = baseline > 0 ? weeklyCap / baseline : 1;
402
- bucketCap = Math.round(dayHours * scale * 100) / 100;
403
- } else if (interval === 'month') {
404
- const netMonthCap = getNetAvailableCapacity(ib.startDate, addDays(ib.endDate, -1), entitySchedule);
405
- const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
406
- const scale = baseline > 0 ? weeklyCap / baseline : 1;
407
- bucketCap = Math.round(netMonthCap * scale * 100) / 100;
408
- }
409
-
410
- capacity[key] = bucketCap;
411
- bucketTotalCapacity += bucketCap;
412
- }
413
-
414
- bucketTotalHours = Math.round(bucketTotalHours * 100) / 100;
415
- bucketTotalCapacity = Math.round(bucketTotalCapacity * 100) / 100;
416
- totalDistributedHours += bucketTotalHours;
417
- sumCapacityAcrossBuckets += bucketTotalCapacity;
418
-
419
- const utilizationRatio =
420
- bucketTotalCapacity > 0
421
- ? Math.round((bucketTotalHours / bucketTotalCapacity) * 1000) / 1000
422
- : undefined;
423
-
424
- return {
425
- date: ib.date,
426
- timestamp: ib.timestamp,
427
- totalHours: bucketTotalHours,
428
- values,
429
- capacity,
430
- totalCapacity: bucketTotalCapacity,
431
- utilizationRatio
432
- };
433
- });
434
-
435
- totalDistributedHours = Math.round(totalDistributedHours * 100) / 100;
436
- sumCapacityAcrossBuckets = Math.round(sumCapacityAcrossBuckets * 100) / 100;
437
-
438
- const averageUtilization =
439
- sumCapacityAcrossBuckets > 0
440
- ? Math.round((totalDistributedHours / sumCapacityAcrossBuckets) * 1000) / 1000
441
- : undefined;
442
-
443
- return {
444
- projectId,
445
- startDate: toDateString(minDate),
446
- endDate: toDateString(maxDate),
447
- interval,
448
- groupBy,
449
- metric,
450
- seriesKeys,
451
- seriesLabels,
452
- buckets,
453
- totalHours: totalDistributedHours,
454
- totalCapacity: sumCapacityAcrossBuckets,
455
- averageUtilization
456
- };
457
- }
@@ -1,63 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { CriticalPathEngine } from '../index.js';
3
-
4
- describe('CriticalPathEngine.withActor', () => {
5
- it('attributes mutations to the scoped actor and ignores identity claimed in payloads', async () => {
6
- const engine = new CriticalPathEngine();
7
- const alice = engine.withActor({ userId: 'alice', username: 'Alice' });
8
-
9
- const project = await alice.createProject({ key: 'ACT', name: 'Actors', ownerId: 'mallory' });
10
- const task = await alice.createTask({ projectId: project.id, title: 'Scoped' });
11
- expect(task.reporterId).toBe('alice');
12
-
13
- // Identity in the payload is not part of the update type at all; cast to simulate an untyped caller
14
- await alice.updateTask(task.id, { title: 'Renamed', actorId: 'ceo', actor: { userId: 'ceo' } } as any);
15
- const comment = await alice.addComment({ taskId: task.id, content: 'hi', authorId: 'ceo', authorType: 'agent' });
16
- const reacted = await alice.addCommentReaction(comment.id, { emoji: '👍', userId: 'ceo' });
17
- const entry = await alice.logTime({ taskId: task.id, hours: 1, userId: 'ceo' } as any);
18
- const attachment = await alice.createAttachment({
19
- taskId: task.id,
20
- uploaderId: 'ceo',
21
- filename: 'spec.md',
22
- mimeType: 'text/markdown',
23
- sizeBytes: 1,
24
- url: 'https://example.com/spec.md'
25
- });
26
-
27
- expect(comment.authorId).toBe('alice');
28
- expect(comment.authorType).toBe('user');
29
- expect(reacted?.reactions?.[0].userId).toBe('alice');
30
- expect(entry.userId).toBe('alice');
31
- expect(attachment.uploaderId).toBe('alice');
32
-
33
- const activities = await engine.store.getActivities({ projectId: project.id });
34
- expect(activities.length).toBeGreaterThan(0);
35
- expect(activities.every((a) => a.actorId === 'alice')).toBe(true);
36
- });
37
-
38
- it('does not change the base engine, and concurrent views stay isolated', async () => {
39
- const engine = new CriticalPathEngine();
40
- const project = await engine.createProject({ key: 'ISO', name: 'Isolation' });
41
-
42
- const [a, b] = await Promise.all([
43
- engine.withActor({ userId: 'a' }).createTask({ projectId: project.id, title: 'A' }),
44
- engine.withActor({ userId: 'b' }).createTask({ projectId: project.id, title: 'B' })
45
- ]);
46
- expect(a.reporterId).toBe('a');
47
- expect(b.reporterId).toBe('b');
48
- expect(engine.actor).toBeUndefined();
49
-
50
- // Views share state with the base engine
51
- expect(await engine.getTask(a.id)).not.toBeNull();
52
- });
53
-
54
- it('still lets explicit updateTask options override the scoped actor', async () => {
55
- const engine = new CriticalPathEngine();
56
- const project = await engine.createProject({ key: 'OPT', name: 'Options' });
57
- const task = await engine.createTask({ projectId: project.id, title: 'T' });
58
-
59
- await engine.withActor({ userId: 'alice' }).updateTask(task.id, { title: 'T2' }, { actorId: 'automation' });
60
- const activities = await engine.store.getActivities({ taskId: task.id });
61
- expect(activities.some((a) => a.actorId === 'automation')).toBe(true);
62
- });
63
- });