@critical-path/core 0.18.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 +17 -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 +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 +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 +3 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +71 -4
- 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 +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 +11 -1
- 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 +79 -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 +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();
|
|
@@ -531,6 +534,58 @@ export class CriticalPathEngine {
|
|
|
531
534
|
await this.events.publish(updateEvent);
|
|
532
535
|
}
|
|
533
536
|
|
|
537
|
+
const wasBlocked = Boolean(existing.isBlocked || existing.customFields?.isBlocked || existing.status === 'blocked');
|
|
538
|
+
const isNowBlocked = Boolean(updated.isBlocked || updated.customFields?.isBlocked || updated.status === 'blocked');
|
|
539
|
+
|
|
540
|
+
if (!wasBlocked && isNowBlocked) {
|
|
541
|
+
const blockedReason = updated.blockedReason ?? (updated.customFields?.blockedReason as string | undefined) ?? null;
|
|
542
|
+
const blockedEvent: TaskBlockedEvent = {
|
|
543
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
544
|
+
name: 'task.blocked',
|
|
545
|
+
aggregateId: updated.id,
|
|
546
|
+
aggregateType: 'Task',
|
|
547
|
+
occurredAt: now,
|
|
548
|
+
payload: {
|
|
549
|
+
task: updated,
|
|
550
|
+
reason: blockedReason
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
await this.events.publish(blockedEvent);
|
|
554
|
+
await this.store.logActivity({
|
|
555
|
+
projectId: updated.projectId,
|
|
556
|
+
taskId: updated.id,
|
|
557
|
+
actorId: updated.assigneeId || 'system',
|
|
558
|
+
action: 'task.blocked',
|
|
559
|
+
details: { reason: blockedReason }
|
|
560
|
+
});
|
|
561
|
+
this.dispatchWebhook('task.blocked', {
|
|
562
|
+
task: updated,
|
|
563
|
+
reason: blockedReason
|
|
564
|
+
});
|
|
565
|
+
} else if (wasBlocked && !isNowBlocked) {
|
|
566
|
+
const unblockedEvent: TaskUnblockedEvent = {
|
|
567
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
568
|
+
name: 'task.unblocked',
|
|
569
|
+
aggregateId: updated.id,
|
|
570
|
+
aggregateType: 'Task',
|
|
571
|
+
occurredAt: now,
|
|
572
|
+
payload: {
|
|
573
|
+
task: updated
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
await this.events.publish(unblockedEvent);
|
|
577
|
+
await this.store.logActivity({
|
|
578
|
+
projectId: updated.projectId,
|
|
579
|
+
taskId: updated.id,
|
|
580
|
+
actorId: updated.assigneeId || 'system',
|
|
581
|
+
action: 'task.unblocked',
|
|
582
|
+
details: {}
|
|
583
|
+
});
|
|
584
|
+
this.dispatchWebhook('task.unblocked', {
|
|
585
|
+
task: updated
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
534
589
|
await this.store.logActivity({
|
|
535
590
|
projectId: updated.projectId,
|
|
536
591
|
taskId: updated.id,
|
|
@@ -577,6 +632,11 @@ export class CriticalPathEngine {
|
|
|
577
632
|
updates.status = defaultStatus;
|
|
578
633
|
}
|
|
579
634
|
|
|
635
|
+
if (downstream.isBlocked || downstream.blockedReason) {
|
|
636
|
+
updates.isBlocked = false;
|
|
637
|
+
updates.blockedReason = null;
|
|
638
|
+
}
|
|
639
|
+
|
|
580
640
|
if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
|
|
581
641
|
updates.customFields = {
|
|
582
642
|
...downstream.customFields,
|
|
@@ -1379,7 +1439,11 @@ export class CriticalPathEngine {
|
|
|
1379
1439
|
}
|
|
1380
1440
|
|
|
1381
1441
|
// --- Ladder of Abstraction & Critical Path Method ---
|
|
1382
|
-
async calculateCriticalPath(
|
|
1442
|
+
async calculateCriticalPath(
|
|
1443
|
+
projectId: string,
|
|
1444
|
+
options: CPMOptions = {}
|
|
1445
|
+
): Promise<CriticalPathAnalysis> {
|
|
1446
|
+
const project = await this.store.getProject(projectId);
|
|
1383
1447
|
const tasks = await this.store.getTasks(projectId);
|
|
1384
1448
|
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
1385
1449
|
const seenDepIds = new Set<string>();
|
|
@@ -1392,7 +1456,9 @@ export class CriticalPathEngine {
|
|
|
1392
1456
|
}
|
|
1393
1457
|
}
|
|
1394
1458
|
}
|
|
1395
|
-
|
|
1459
|
+
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1460
|
+
const projectStartDate = options.projectStartDate || project?.startDate;
|
|
1461
|
+
return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
|
|
1396
1462
|
}
|
|
1397
1463
|
|
|
1398
1464
|
async getTimelineLadder(
|
|
@@ -1435,6 +1501,8 @@ export class CriticalPathEngine {
|
|
|
1435
1501
|
const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
|
|
1436
1502
|
const timeEntries: TimeEntry[] = timeEntriesNested.flat();
|
|
1437
1503
|
|
|
1504
|
+
const schedule = project.schedule || this.config.defaultSchedule;
|
|
1505
|
+
|
|
1438
1506
|
return buildTimelineLadder(
|
|
1439
1507
|
{
|
|
1440
1508
|
project,
|
|
@@ -1445,7 +1513,8 @@ export class CriticalPathEngine {
|
|
|
1445
1513
|
deliverables,
|
|
1446
1514
|
attachments,
|
|
1447
1515
|
timeEntries,
|
|
1448
|
-
activities
|
|
1516
|
+
activities,
|
|
1517
|
+
schedule
|
|
1449
1518
|
},
|
|
1450
1519
|
options
|
|
1451
1520
|
);
|
|
@@ -1518,6 +1587,8 @@ export class CriticalPathEngine {
|
|
|
1518
1587
|
): Promise<WorkloadDistribution> {
|
|
1519
1588
|
const tasks = await this.store.getTasks(projectId);
|
|
1520
1589
|
const teams = await this.store.getTeams();
|
|
1590
|
+
const project = projectId ? await this.store.getProject(projectId) : undefined;
|
|
1591
|
+
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1521
1592
|
|
|
1522
1593
|
const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
|
|
1523
1594
|
const timeEntries: TimeEntry[] = timeEntriesNested.flat();
|
|
@@ -1529,7 +1600,10 @@ export class CriticalPathEngine {
|
|
|
1529
1600
|
teams,
|
|
1530
1601
|
projectId
|
|
1531
1602
|
},
|
|
1532
|
-
|
|
1603
|
+
{
|
|
1604
|
+
...options,
|
|
1605
|
+
schedule
|
|
1606
|
+
}
|
|
1533
1607
|
);
|
|
1534
1608
|
}
|
|
1535
1609
|
|
package/src/store/sqlite.test.ts
CHANGED
|
@@ -40,8 +40,24 @@ describe('SQLiteStore', () => {
|
|
|
40
40
|
expect(task.priority).toBe('high');
|
|
41
41
|
expect(task.tags).toContain('sqlite');
|
|
42
42
|
|
|
43
|
-
const updated = await store.updateTask(task.id, {
|
|
43
|
+
const updated = await store.updateTask(task.id, {
|
|
44
|
+
status: 'in_progress',
|
|
45
|
+
isBlocked: true,
|
|
46
|
+
blockedReason: 'Waiting for SQLite lock'
|
|
47
|
+
});
|
|
44
48
|
expect(updated?.status).toBe('in_progress');
|
|
49
|
+
expect(updated?.isBlocked).toBe(true);
|
|
50
|
+
expect(updated?.blockedReason).toBe('Waiting for SQLite lock');
|
|
51
|
+
|
|
52
|
+
const fetchedTask = await store.getTask(task.id);
|
|
53
|
+
expect(fetchedTask?.isBlocked).toBe(true);
|
|
54
|
+
expect(fetchedTask?.blockedReason).toBe('Waiting for SQLite lock');
|
|
55
|
+
|
|
56
|
+
const unblocked = await store.updateTask(task.id, {
|
|
57
|
+
isBlocked: false,
|
|
58
|
+
blockedReason: null
|
|
59
|
+
});
|
|
60
|
+
expect(unblocked?.isBlocked).toBe(false);
|
|
45
61
|
|
|
46
62
|
const projectTasks = await store.getTasks(project.id);
|
|
47
63
|
expect(projectTasks).toHaveLength(1);
|
package/src/store/sqlite.ts
CHANGED
|
@@ -132,6 +132,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
132
132
|
actualDurationMinutes REAL,
|
|
133
133
|
billableDurationMinutes REAL,
|
|
134
134
|
progress REAL,
|
|
135
|
+
isBlocked INTEGER,
|
|
136
|
+
blockedReason TEXT,
|
|
135
137
|
tags TEXT,
|
|
136
138
|
customFields TEXT,
|
|
137
139
|
parentId TEXT,
|
|
@@ -659,8 +661,9 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
659
661
|
reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
|
|
660
662
|
dueDate, estimatedHours, loggedHours, actualHours, billableHours,
|
|
661
663
|
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes, progress,
|
|
664
|
+
isBlocked, blockedReason,
|
|
662
665
|
tags, customFields, parentId, createdAt, updatedAt
|
|
663
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
666
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
664
667
|
`);
|
|
665
668
|
stmt.run(
|
|
666
669
|
newTask.id,
|
|
@@ -690,6 +693,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
690
693
|
newTask.actualDurationMinutes ?? null,
|
|
691
694
|
newTask.billableDurationMinutes ?? null,
|
|
692
695
|
newTask.progress ?? null,
|
|
696
|
+
newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
|
|
697
|
+
newTask.blockedReason ?? null,
|
|
693
698
|
JSON.stringify(newTask.tags || []),
|
|
694
699
|
JSON.stringify(newTask.customFields || {}),
|
|
695
700
|
newTask.parentId || null,
|
|
@@ -716,6 +721,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
716
721
|
plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
|
|
717
722
|
estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
|
|
718
723
|
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?, progress = ?,
|
|
724
|
+
isBlocked = ?, blockedReason = ?,
|
|
719
725
|
tags = ?, customFields = ?, parentId = ?, updatedAt = ?
|
|
720
726
|
WHERE id = ?
|
|
721
727
|
`);
|
|
@@ -746,6 +752,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
746
752
|
updated.actualDurationMinutes ?? null,
|
|
747
753
|
updated.billableDurationMinutes ?? null,
|
|
748
754
|
updated.progress ?? null,
|
|
755
|
+
updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
|
|
756
|
+
updated.blockedReason ?? null,
|
|
749
757
|
JSON.stringify(updated.tags || []),
|
|
750
758
|
JSON.stringify(updated.customFields || {}),
|
|
751
759
|
updated.parentId || null,
|
|
@@ -1188,6 +1196,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1188
1196
|
private mapTask(row: any): Task {
|
|
1189
1197
|
return {
|
|
1190
1198
|
...row,
|
|
1199
|
+
isBlocked: row.isBlocked !== null && row.isBlocked !== undefined ? Boolean(row.isBlocked) : undefined,
|
|
1200
|
+
blockedReason: row.blockedReason || undefined,
|
|
1191
1201
|
taskType: row.taskType || undefined,
|
|
1192
1202
|
deliverableId: row.deliverableId || undefined,
|
|
1193
1203
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
package/src/task-state.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { CriticalPathEngine } from './engine/index.js';
|
|
3
3
|
import { deriveTaskLifecycleState } from './utils/status.js';
|
|
4
|
-
import type { Task, StatusDefinition, TaskUnblockedEvent } from './index.js';
|
|
4
|
+
import type { Task, StatusDefinition, TaskBlockedEvent, TaskUnblockedEvent } from './index.js';
|
|
5
5
|
|
|
6
6
|
describe('Task State Behaviour & Dependency Validation', () => {
|
|
7
7
|
const baseTask: Task = {
|
|
@@ -443,5 +443,64 @@ describe('Task State Behaviour & Dependency Validation', () => {
|
|
|
443
443
|
expect(lifecycle?.isBlocked).toBe(false);
|
|
444
444
|
expect(lifecycle?.isReady).toBe(true);
|
|
445
445
|
});
|
|
446
|
+
|
|
447
|
+
it('manages first-class isBlocked and blockedReason independently of workflow status', async () => {
|
|
448
|
+
const engine = new CriticalPathEngine();
|
|
449
|
+
const project = await engine.createProject({ key: 'BLOCK', name: 'First Class Blocking' });
|
|
450
|
+
|
|
451
|
+
const blockedEvents: TaskBlockedEvent[] = [];
|
|
452
|
+
const unblockedEvents: TaskUnblockedEvent[] = [];
|
|
453
|
+
engine.events.subscribe<TaskBlockedEvent>('task.blocked', (evt) => {
|
|
454
|
+
blockedEvents.push(evt);
|
|
455
|
+
});
|
|
456
|
+
engine.events.subscribe<TaskUnblockedEvent>('task.unblocked', (evt) => {
|
|
457
|
+
unblockedEvents.push(evt);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// 1. Create a task in 'in_progress' status
|
|
461
|
+
const task = await engine.createTask({
|
|
462
|
+
projectId: project.id,
|
|
463
|
+
title: 'Work on feature',
|
|
464
|
+
status: 'in_progress'
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
let lifecycle = await engine.getTaskLifecycleState(task.id);
|
|
468
|
+
expect(lifecycle?.isBlocked).toBe(false);
|
|
469
|
+
|
|
470
|
+
// 2. Explicitly block the task while keeping status in_progress
|
|
471
|
+
const blockedTask = await engine.updateTask(task.id, {
|
|
472
|
+
isBlocked: true,
|
|
473
|
+
blockedReason: 'Awaiting third-party API key'
|
|
474
|
+
});
|
|
475
|
+
expect(blockedTask?.isBlocked).toBe(true);
|
|
476
|
+
expect(blockedTask?.blockedReason).toBe('Awaiting third-party API key');
|
|
477
|
+
expect(blockedTask?.status).toBe('in_progress');
|
|
478
|
+
|
|
479
|
+
// Verify domain event
|
|
480
|
+
expect(blockedEvents).toHaveLength(1);
|
|
481
|
+
expect(blockedEvents[0].aggregateId).toBe(task.id);
|
|
482
|
+
expect(blockedEvents[0].payload.reason).toBe('Awaiting third-party API key');
|
|
483
|
+
|
|
484
|
+
// Verify lifecycle state
|
|
485
|
+
lifecycle = await engine.getTaskLifecycleState(task.id);
|
|
486
|
+
expect(lifecycle?.isBlocked).toBe(true);
|
|
487
|
+
expect(lifecycle?.isReady).toBe(false);
|
|
488
|
+
|
|
489
|
+
// 3. Unblock the task
|
|
490
|
+
const unblockedTask = await engine.updateTask(task.id, {
|
|
491
|
+
isBlocked: false,
|
|
492
|
+
blockedReason: null
|
|
493
|
+
});
|
|
494
|
+
expect(unblockedTask?.isBlocked).toBe(false);
|
|
495
|
+
expect(unblockedTask?.blockedReason).toBeNull();
|
|
496
|
+
expect(unblockedTask?.status).toBe('in_progress');
|
|
497
|
+
|
|
498
|
+
// Verify domain event
|
|
499
|
+
expect(unblockedEvents).toHaveLength(1);
|
|
500
|
+
expect(unblockedEvents[0].aggregateId).toBe(task.id);
|
|
501
|
+
|
|
502
|
+
lifecycle = await engine.getTaskLifecycleState(task.id);
|
|
503
|
+
expect(lifecycle?.isBlocked).toBe(false);
|
|
504
|
+
});
|
|
446
505
|
});
|
|
447
506
|
});
|