@critical-path/core 0.18.0 → 0.19.1
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 +43 -0
- package/dist/domain/calendar.d.ts +53 -0
- package/dist/domain/calendar.d.ts.map +1 -0
- package/dist/domain/calendar.js +333 -0
- package/dist/domain/calendar.js.map +1 -0
- package/dist/domain/calendar.test.d.ts +2 -0
- package/dist/domain/calendar.test.d.ts.map +1 -0
- package/dist/domain/calendar.test.js +136 -0
- package/dist/domain/calendar.test.js.map +1 -0
- package/dist/domain/cpm.d.ts +8 -3
- package/dist/domain/cpm.d.ts.map +1 -1
- package/dist/domain/cpm.js +36 -3
- package/dist/domain/cpm.js.map +1 -1
- package/dist/domain/cpm.test.js +53 -0
- package/dist/domain/cpm.test.js.map +1 -1
- package/dist/domain/events.d.ts +38 -2
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/domain/ladder.d.ts +3 -2
- package/dist/domain/ladder.d.ts.map +1 -1
- package/dist/domain/ladder.js +21 -3
- package/dist/domain/ladder.js.map +1 -1
- package/dist/domain/metrics.test.js +5 -4
- package/dist/domain/metrics.test.js.map +1 -1
- package/dist/domain/workload.d.ts.map +1 -1
- package/dist/domain/workload.js +60 -17
- package/dist/domain/workload.js.map +1 -1
- package/dist/domain/workload.test.js +60 -0
- package/dist/domain/workload.test.js.map +1 -1
- package/dist/engine/index.d.ts +22 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +119 -15
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +41 -0
- package/dist/index.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +9 -3
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sqlite.test.js +15 -1
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/task-state.test.js +49 -0
- package/dist/task-state.test.js.map +1 -1
- package/dist/types/index.d.ts +44 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +5 -2
- package/dist/utils/status.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/calendar.test.ts +164 -0
- package/src/domain/calendar.ts +457 -0
- package/src/domain/cpm.test.ts +61 -0
- package/src/domain/cpm.ts +47 -4
- package/src/domain/events.ts +27 -2
- package/src/domain/index.ts +1 -0
- package/src/domain/ladder.ts +25 -4
- package/src/domain/metrics.test.ts +6 -4
- package/src/domain/workload.test.ts +73 -0
- package/src/domain/workload.ts +73 -19
- package/src/engine/index.ts +152 -16
- package/src/index.test.ts +48 -0
- package/src/store/sqlite.test.ts +17 -1
- package/src/store/sqlite.ts +11 -1
- package/src/task-state.test.ts +60 -1
- package/src/types/index.ts +48 -0
- package/src/utils/status.ts +7 -2
- package/tsconfig.tsbuildinfo +1 -1
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
|
});
|
|
@@ -235,4 +235,77 @@ describe('calculateWorkloadDistribution', () => {
|
|
|
235
235
|
expect(distribution.buckets.length).toBeGreaterThan(0);
|
|
236
236
|
expect(distribution.seriesKeys).toContain('u1');
|
|
237
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
|
+
});
|
|
238
311
|
});
|
package/src/domain/workload.ts
CHANGED
|
@@ -10,6 +10,12 @@ import type {
|
|
|
10
10
|
WorkloadDistribution,
|
|
11
11
|
WorkloadDistributionOptions
|
|
12
12
|
} from '../types/index.js';
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_WORK_SCHEDULE,
|
|
15
|
+
getWorkingDaysList,
|
|
16
|
+
getNetAvailableCapacity,
|
|
17
|
+
getWorkingHoursInDay
|
|
18
|
+
} from './calendar.js';
|
|
13
19
|
|
|
14
20
|
export interface WorkloadContext {
|
|
15
21
|
tasks: Task[];
|
|
@@ -260,21 +266,53 @@ export function calculateWorkloadDistribution(
|
|
|
260
266
|
taskEnd = taskStart;
|
|
261
267
|
}
|
|
262
268
|
|
|
263
|
-
//
|
|
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);
|
|
264
279
|
const msPerDay = 1000 * 60 * 60 * 24;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
+
}
|
|
278
316
|
}
|
|
279
317
|
}
|
|
280
318
|
}
|
|
@@ -325,6 +363,12 @@ export function calculateWorkloadDistribution(
|
|
|
325
363
|
bucketTotalHours += roundedHours;
|
|
326
364
|
|
|
327
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
|
+
|
|
328
372
|
let weeklyCap = defaultWeeklyCapacity;
|
|
329
373
|
if (options.capacityOverrides && typeof options.capacityOverrides[key] === 'number') {
|
|
330
374
|
weeklyCap = options.capacityOverrides[key];
|
|
@@ -344,13 +388,23 @@ export function calculateWorkloadDistribution(
|
|
|
344
388
|
}
|
|
345
389
|
|
|
346
390
|
let bucketCap = 0;
|
|
347
|
-
if (
|
|
348
|
-
bucketCap =
|
|
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;
|
|
349
398
|
} else if (interval === 'day') {
|
|
350
|
-
|
|
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;
|
|
351
403
|
} else if (interval === 'month') {
|
|
352
|
-
const
|
|
353
|
-
|
|
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;
|
|
354
408
|
}
|
|
355
409
|
|
|
356
410
|
capacity[key] = bucketCap;
|
package/src/engine/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
TaskCreatedEvent,
|
|
52
52
|
TaskUpdatedEvent,
|
|
53
53
|
TaskStatusChangedEvent,
|
|
54
|
+
TaskBlockedEvent,
|
|
54
55
|
TaskUnblockedEvent,
|
|
55
56
|
TaskDeletedEvent,
|
|
56
57
|
ProjectCreatedEvent,
|
|
@@ -79,7 +80,7 @@ import {
|
|
|
79
80
|
import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
|
|
80
81
|
import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
81
82
|
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
82
|
-
import { calculateCPM } from '../domain/cpm.js';
|
|
83
|
+
import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
|
|
83
84
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
84
85
|
import {
|
|
85
86
|
calculateTaskMetrics,
|
|
@@ -89,6 +90,7 @@ import {
|
|
|
89
90
|
import { calculateWorkloadDistribution } from '../domain/workload.js';
|
|
90
91
|
|
|
91
92
|
export class CriticalPathEngine {
|
|
93
|
+
public readonly config: CriticalPathConfig;
|
|
92
94
|
public readonly store: StorageAdapter;
|
|
93
95
|
public readonly fileStorage?: FileStorageAdapter;
|
|
94
96
|
public readonly plugins: PluginRegistry;
|
|
@@ -96,6 +98,7 @@ export class CriticalPathEngine {
|
|
|
96
98
|
public readonly ready: Promise<void> = Promise.resolve();
|
|
97
99
|
|
|
98
100
|
constructor(config: CriticalPathConfig = {}) {
|
|
101
|
+
this.config = config;
|
|
99
102
|
this.store = typeof config.store === 'object' && config.store !== null
|
|
100
103
|
? (config.store as StorageAdapter)
|
|
101
104
|
: new InMemoryStore();
|
|
@@ -428,30 +431,76 @@ export class CriticalPathEngine {
|
|
|
428
431
|
return created;
|
|
429
432
|
}
|
|
430
433
|
|
|
431
|
-
async updateTask(
|
|
434
|
+
async updateTask(
|
|
435
|
+
id: string,
|
|
436
|
+
updates: Partial<Task> & {
|
|
437
|
+
actorId?: string;
|
|
438
|
+
actorName?: string;
|
|
439
|
+
actorType?: string;
|
|
440
|
+
actor?: { userId: string; username?: string; actorType?: string };
|
|
441
|
+
},
|
|
442
|
+
options?: {
|
|
443
|
+
actorId?: string;
|
|
444
|
+
actorName?: string;
|
|
445
|
+
actorType?: string;
|
|
446
|
+
actor?: { userId: string; username?: string; actorType?: string };
|
|
447
|
+
}
|
|
448
|
+
): Promise<Task | null> {
|
|
432
449
|
const existing = await this.store.getTask(id);
|
|
433
450
|
if (!existing) return null;
|
|
434
451
|
|
|
452
|
+
const actorId =
|
|
453
|
+
options?.actorId ||
|
|
454
|
+
updates.actorId ||
|
|
455
|
+
options?.actor?.userId ||
|
|
456
|
+
updates.actor?.userId ||
|
|
457
|
+
'system';
|
|
458
|
+
const actorName =
|
|
459
|
+
options?.actorName ||
|
|
460
|
+
updates.actorName ||
|
|
461
|
+
options?.actor?.username ||
|
|
462
|
+
updates.actor?.username ||
|
|
463
|
+
(actorId === 'system' ? 'System' : undefined);
|
|
464
|
+
const actorType =
|
|
465
|
+
options?.actorType ||
|
|
466
|
+
updates.actorType ||
|
|
467
|
+
options?.actor?.actorType ||
|
|
468
|
+
updates.actor?.actorType ||
|
|
469
|
+
(actorId === 'system' ? 'system' : 'user');
|
|
470
|
+
const actorObj = options?.actor || updates.actor || {
|
|
471
|
+
userId: actorId,
|
|
472
|
+
username: actorName,
|
|
473
|
+
actorType
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const {
|
|
477
|
+
actorId: _aId,
|
|
478
|
+
actorName: _aName,
|
|
479
|
+
actorType: _aType,
|
|
480
|
+
actor: _aObj,
|
|
481
|
+
...taskUpdates
|
|
482
|
+
} = updates;
|
|
483
|
+
|
|
435
484
|
const project = await this.store.getProject(existing.projectId);
|
|
436
485
|
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
437
486
|
|
|
438
487
|
// Validate workflow transition invariant
|
|
439
|
-
if (
|
|
440
|
-
const isValid = validateTransition(workflow || undefined, existing.status,
|
|
488
|
+
if (taskUpdates.status && taskUpdates.status !== existing.status) {
|
|
489
|
+
const isValid = validateTransition(workflow || undefined, existing.status, taskUpdates.status);
|
|
441
490
|
if (!isValid) {
|
|
442
|
-
throw new WorkflowValidationError(existing.status,
|
|
491
|
+
throw new WorkflowValidationError(existing.status, taskUpdates.status, workflow?.id);
|
|
443
492
|
}
|
|
444
493
|
}
|
|
445
494
|
|
|
446
495
|
// Validate custom field invariants
|
|
447
|
-
if (project?.customFieldDefinitions &&
|
|
496
|
+
if (project?.customFieldDefinitions && taskUpdates.customFields) {
|
|
448
497
|
validateCustomFieldValues(project.customFieldDefinitions, {
|
|
449
498
|
...existing.customFields,
|
|
450
|
-
...
|
|
499
|
+
...taskUpdates.customFields
|
|
451
500
|
});
|
|
452
501
|
}
|
|
453
502
|
|
|
454
|
-
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id,
|
|
503
|
+
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
|
|
455
504
|
|
|
456
505
|
// Auto-update execution/completion timestamps if status changes
|
|
457
506
|
if (processedUpdates.status && processedUpdates.status !== existing.status) {
|
|
@@ -504,7 +553,10 @@ export class CriticalPathEngine {
|
|
|
504
553
|
payload: {
|
|
505
554
|
task: updated,
|
|
506
555
|
previousStatus: existing.status,
|
|
507
|
-
newStatus: updated.status
|
|
556
|
+
newStatus: updated.status,
|
|
557
|
+
actorId,
|
|
558
|
+
...(actorName ? { actorName } : {}),
|
|
559
|
+
actor: actorObj
|
|
508
560
|
}
|
|
509
561
|
};
|
|
510
562
|
await this.events.publish(statusEvent);
|
|
@@ -525,18 +577,83 @@ export class CriticalPathEngine {
|
|
|
525
577
|
occurredAt: now,
|
|
526
578
|
payload: {
|
|
527
579
|
task: updated,
|
|
528
|
-
previous: existing
|
|
580
|
+
previous: existing,
|
|
581
|
+
actorId,
|
|
582
|
+
...(actorName ? { actorName } : {}),
|
|
583
|
+
actor: actorObj
|
|
529
584
|
}
|
|
530
585
|
};
|
|
531
586
|
await this.events.publish(updateEvent);
|
|
532
587
|
}
|
|
533
588
|
|
|
589
|
+
const wasBlocked = Boolean(existing.isBlocked || existing.customFields?.isBlocked || existing.status === 'blocked');
|
|
590
|
+
const isNowBlocked = Boolean(updated.isBlocked || updated.customFields?.isBlocked || updated.status === 'blocked');
|
|
591
|
+
|
|
592
|
+
if (!wasBlocked && isNowBlocked) {
|
|
593
|
+
const blockedReason = updated.blockedReason ?? (updated.customFields?.blockedReason as string | undefined) ?? null;
|
|
594
|
+
const blockedEvent: TaskBlockedEvent = {
|
|
595
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
596
|
+
name: 'task.blocked',
|
|
597
|
+
aggregateId: updated.id,
|
|
598
|
+
aggregateType: 'Task',
|
|
599
|
+
occurredAt: now,
|
|
600
|
+
payload: {
|
|
601
|
+
task: updated,
|
|
602
|
+
reason: blockedReason,
|
|
603
|
+
actorId,
|
|
604
|
+
...(actorName ? { actorName } : {}),
|
|
605
|
+
actor: actorObj
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
await this.events.publish(blockedEvent);
|
|
609
|
+
await this.store.logActivity({
|
|
610
|
+
projectId: updated.projectId,
|
|
611
|
+
taskId: updated.id,
|
|
612
|
+
actorId,
|
|
613
|
+
action: 'task.blocked',
|
|
614
|
+
details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
|
|
615
|
+
});
|
|
616
|
+
this.dispatchWebhook('task.blocked', {
|
|
617
|
+
task: updated,
|
|
618
|
+
reason: blockedReason
|
|
619
|
+
});
|
|
620
|
+
} else if (wasBlocked && !isNowBlocked) {
|
|
621
|
+
const unblockedEvent: TaskUnblockedEvent = {
|
|
622
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
623
|
+
name: 'task.unblocked',
|
|
624
|
+
aggregateId: updated.id,
|
|
625
|
+
aggregateType: 'Task',
|
|
626
|
+
occurredAt: now,
|
|
627
|
+
payload: {
|
|
628
|
+
task: updated,
|
|
629
|
+
actorId,
|
|
630
|
+
...(actorName ? { actorName } : {}),
|
|
631
|
+
actor: actorObj
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
await this.events.publish(unblockedEvent);
|
|
635
|
+
await this.store.logActivity({
|
|
636
|
+
projectId: updated.projectId,
|
|
637
|
+
taskId: updated.id,
|
|
638
|
+
actorId,
|
|
639
|
+
action: 'task.unblocked',
|
|
640
|
+
details: { ...(actorName ? { actorName } : {}) }
|
|
641
|
+
});
|
|
642
|
+
this.dispatchWebhook('task.unblocked', {
|
|
643
|
+
task: updated
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
534
647
|
await this.store.logActivity({
|
|
535
648
|
projectId: updated.projectId,
|
|
536
649
|
taskId: updated.id,
|
|
537
|
-
actorId
|
|
650
|
+
actorId,
|
|
538
651
|
action: isStatusChange ? 'task.status_changed' : 'task.updated',
|
|
539
|
-
details: {
|
|
652
|
+
details: {
|
|
653
|
+
fromStatus: existing.status,
|
|
654
|
+
toStatus: updated.status,
|
|
655
|
+
...(actorName ? { actorName } : {})
|
|
656
|
+
}
|
|
540
657
|
});
|
|
541
658
|
|
|
542
659
|
this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
|
|
@@ -577,6 +694,11 @@ export class CriticalPathEngine {
|
|
|
577
694
|
updates.status = defaultStatus;
|
|
578
695
|
}
|
|
579
696
|
|
|
697
|
+
if (downstream.isBlocked || downstream.blockedReason) {
|
|
698
|
+
updates.isBlocked = false;
|
|
699
|
+
updates.blockedReason = null;
|
|
700
|
+
}
|
|
701
|
+
|
|
580
702
|
if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
|
|
581
703
|
updates.customFields = {
|
|
582
704
|
...downstream.customFields,
|
|
@@ -1379,7 +1501,11 @@ export class CriticalPathEngine {
|
|
|
1379
1501
|
}
|
|
1380
1502
|
|
|
1381
1503
|
// --- Ladder of Abstraction & Critical Path Method ---
|
|
1382
|
-
async calculateCriticalPath(
|
|
1504
|
+
async calculateCriticalPath(
|
|
1505
|
+
projectId: string,
|
|
1506
|
+
options: CPMOptions = {}
|
|
1507
|
+
): Promise<CriticalPathAnalysis> {
|
|
1508
|
+
const project = await this.store.getProject(projectId);
|
|
1383
1509
|
const tasks = await this.store.getTasks(projectId);
|
|
1384
1510
|
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
1385
1511
|
const seenDepIds = new Set<string>();
|
|
@@ -1392,7 +1518,9 @@ export class CriticalPathEngine {
|
|
|
1392
1518
|
}
|
|
1393
1519
|
}
|
|
1394
1520
|
}
|
|
1395
|
-
|
|
1521
|
+
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1522
|
+
const projectStartDate = options.projectStartDate || project?.startDate;
|
|
1523
|
+
return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
|
|
1396
1524
|
}
|
|
1397
1525
|
|
|
1398
1526
|
async getTimelineLadder(
|
|
@@ -1435,6 +1563,8 @@ export class CriticalPathEngine {
|
|
|
1435
1563
|
const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
|
|
1436
1564
|
const timeEntries: TimeEntry[] = timeEntriesNested.flat();
|
|
1437
1565
|
|
|
1566
|
+
const schedule = project.schedule || this.config.defaultSchedule;
|
|
1567
|
+
|
|
1438
1568
|
return buildTimelineLadder(
|
|
1439
1569
|
{
|
|
1440
1570
|
project,
|
|
@@ -1445,7 +1575,8 @@ export class CriticalPathEngine {
|
|
|
1445
1575
|
deliverables,
|
|
1446
1576
|
attachments,
|
|
1447
1577
|
timeEntries,
|
|
1448
|
-
activities
|
|
1578
|
+
activities,
|
|
1579
|
+
schedule
|
|
1449
1580
|
},
|
|
1450
1581
|
options
|
|
1451
1582
|
);
|
|
@@ -1518,6 +1649,8 @@ export class CriticalPathEngine {
|
|
|
1518
1649
|
): Promise<WorkloadDistribution> {
|
|
1519
1650
|
const tasks = await this.store.getTasks(projectId);
|
|
1520
1651
|
const teams = await this.store.getTeams();
|
|
1652
|
+
const project = projectId ? await this.store.getProject(projectId) : undefined;
|
|
1653
|
+
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1521
1654
|
|
|
1522
1655
|
const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
|
|
1523
1656
|
const timeEntries: TimeEntry[] = timeEntriesNested.flat();
|
|
@@ -1529,7 +1662,10 @@ export class CriticalPathEngine {
|
|
|
1529
1662
|
teams,
|
|
1530
1663
|
projectId
|
|
1531
1664
|
},
|
|
1532
|
-
|
|
1665
|
+
{
|
|
1666
|
+
...options,
|
|
1667
|
+
schedule
|
|
1668
|
+
}
|
|
1533
1669
|
);
|
|
1534
1670
|
}
|
|
1535
1671
|
|
package/src/index.test.ts
CHANGED
|
@@ -440,6 +440,54 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
440
440
|
const updated = await engine.updateTask(task.id, { status: 'in_production' });
|
|
441
441
|
expect(updated?.status).toBe('in_production');
|
|
442
442
|
});
|
|
443
|
+
|
|
444
|
+
it('attributes activities and domain events to the real actor rather than the assignee', async () => {
|
|
445
|
+
const engine = new CriticalPathEngine();
|
|
446
|
+
const proj = await engine.createProject({ key: 'ACT', name: 'Actor Attribution Project' });
|
|
447
|
+
const task = await engine.createTask({
|
|
448
|
+
projectId: proj.id,
|
|
449
|
+
title: 'Assigned Task',
|
|
450
|
+
status: 'todo',
|
|
451
|
+
assigneeId: 'agent_worker_1'
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const receivedEvents: any[] = [];
|
|
455
|
+
engine.events.subscribe('task.status_changed', async (evt) => {
|
|
456
|
+
receivedEvents.push(evt);
|
|
457
|
+
});
|
|
458
|
+
engine.events.subscribe('task.updated', async (evt) => {
|
|
459
|
+
receivedEvents.push(evt);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Update with explicit actor (e.g. human user updating an assigned agent task)
|
|
463
|
+
await engine.updateTask(task.id, {
|
|
464
|
+
status: 'in_progress',
|
|
465
|
+
actorId: 'user_human_42',
|
|
466
|
+
actorName: 'Jack James'
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const activities = await engine.store.getActivities({ taskId: task.id });
|
|
470
|
+
const statusActivity = activities.find((a) => a.action === 'task.status_changed');
|
|
471
|
+
expect(statusActivity).toBeDefined();
|
|
472
|
+
expect(statusActivity?.actorId).toBe('user_human_42');
|
|
473
|
+
expect((statusActivity?.details as any)?.actorName).toBe('Jack James');
|
|
474
|
+
expect(statusActivity?.actorId).not.toBe('agent_worker_1');
|
|
475
|
+
|
|
476
|
+
expect(receivedEvents).toHaveLength(1);
|
|
477
|
+
expect(receivedEvents[0].payload.actorId).toBe('user_human_42');
|
|
478
|
+
expect(receivedEvents[0].payload.actorName).toBe('Jack James');
|
|
479
|
+
|
|
480
|
+
// Update without explicit actor - should fall back to 'system', NEVER task.assigneeId
|
|
481
|
+
await engine.updateTask(task.id, {
|
|
482
|
+
title: 'Updated title'
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const updatedActivities = await engine.store.getActivities({ taskId: task.id });
|
|
486
|
+
const updateActivity = updatedActivities.find((a) => a.action === 'task.updated');
|
|
487
|
+
expect(updateActivity).toBeDefined();
|
|
488
|
+
expect(updateActivity?.actorId).toBe('system');
|
|
489
|
+
expect(updateActivity?.actorId).not.toBe('agent_worker_1');
|
|
490
|
+
});
|
|
443
491
|
});
|
|
444
492
|
|
|
445
493
|
|