@critical-path/core 0.16.0 → 0.17.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 +13 -1
- package/README.md +22 -1
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +5 -0
- package/dist/domain/entities.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.map +1 -1
- package/dist/domain/ladder.js +14 -0
- package/dist/domain/ladder.js.map +1 -1
- package/dist/domain/ladder.test.js +3 -3
- package/dist/domain/ladder.test.js.map +1 -1
- package/dist/domain/metrics.d.ts +29 -0
- package/dist/domain/metrics.d.ts.map +1 -0
- package/dist/domain/metrics.js +403 -0
- package/dist/domain/metrics.js.map +1 -0
- package/dist/domain/metrics.test.d.ts +2 -0
- package/dist/domain/metrics.test.d.ts.map +1 -0
- package/dist/domain/metrics.test.js +251 -0
- package/dist/domain/metrics.test.js.map +1 -0
- package/dist/engine/index.d.ts +4 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +42 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/types/index.d.ts +54 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/entities.ts +6 -0
- package/src/domain/index.ts +1 -0
- package/src/domain/ladder.test.ts +3 -3
- package/src/domain/ladder.ts +16 -0
- package/src/domain/metrics.test.ts +286 -0
- package/src/domain/metrics.ts +484 -0
- package/src/engine/index.ts +62 -3
- package/src/types/index.ts +69 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Task,
|
|
3
|
+
Activity,
|
|
4
|
+
TimeEntry,
|
|
5
|
+
TaskProgressInference,
|
|
6
|
+
ProgressInferenceSource,
|
|
7
|
+
ProgressCurveProfile,
|
|
8
|
+
TaskProgressHistoryPoint,
|
|
9
|
+
TaskProgressHistory,
|
|
10
|
+
TaskEVM,
|
|
11
|
+
TaskInferredActuals,
|
|
12
|
+
TaskMetrics,
|
|
13
|
+
RealityDelta
|
|
14
|
+
} from '../types/index.js';
|
|
15
|
+
import { resolveStatusDefinition } from '../utils/status.js';
|
|
16
|
+
|
|
17
|
+
export interface MetricOptions {
|
|
18
|
+
referenceDate?: Date;
|
|
19
|
+
customStatusDefinitions?: Array<{ key: string; category: any; label: string }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Infer task progress percentage from explicit user input, todos/checklists,
|
|
24
|
+
* logged effort vs estimate, and elapsed schedule duration.
|
|
25
|
+
*/
|
|
26
|
+
export function inferTaskProgress(
|
|
27
|
+
task: Task,
|
|
28
|
+
options?: MetricOptions
|
|
29
|
+
): TaskProgressInference {
|
|
30
|
+
const referenceDate = options?.referenceDate ?? new Date();
|
|
31
|
+
const statusDef = resolveStatusDefinition(task.status, options?.customStatusDefinitions);
|
|
32
|
+
const semanticStatus = task.semanticStatus || statusDef.category;
|
|
33
|
+
|
|
34
|
+
// Completed tasks are 100% complete
|
|
35
|
+
if (semanticStatus === 'completed') {
|
|
36
|
+
return {
|
|
37
|
+
progressPercentage: 100,
|
|
38
|
+
source: task.progress === 100 ? 'explicit' : 'blended',
|
|
39
|
+
isExplicit: task.progress !== undefined && task.progress !== null,
|
|
40
|
+
breakdown: {
|
|
41
|
+
explicitProgress: task.progress,
|
|
42
|
+
todoProgress: task.todos && task.todos.length > 0 ? 100 : undefined,
|
|
43
|
+
effortProgress: 100,
|
|
44
|
+
scheduleProgress: 100
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 1. Explicit Progress
|
|
50
|
+
let explicitProgress: number | undefined;
|
|
51
|
+
if (task.progress !== undefined && task.progress !== null) {
|
|
52
|
+
explicitProgress = Math.min(100, Math.max(0, Math.round(task.progress * 10) / 10));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Checklist / Todo Progress
|
|
56
|
+
let todoProgress: number | undefined;
|
|
57
|
+
if (task.todos && task.todos.length > 0) {
|
|
58
|
+
const completed = task.todos.filter((t) => t.completed).length;
|
|
59
|
+
todoProgress = Math.round((completed / task.todos.length) * 1000) / 10;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Effort / Hours Progress (Logged vs Estimated)
|
|
63
|
+
let effortProgress: number | undefined;
|
|
64
|
+
const estimatedH = task.estimatedHours || (task.estimatedDurationMinutes ? task.estimatedDurationMinutes / 60 : 0);
|
|
65
|
+
const loggedH = task.loggedHours || task.actualHours || 0;
|
|
66
|
+
if (estimatedH > 0) {
|
|
67
|
+
effortProgress = Math.min(100, Math.round((loggedH / estimatedH) * 1000) / 10);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 4. Schedule Elapsed Progress
|
|
71
|
+
let scheduleProgress: number | undefined;
|
|
72
|
+
const startDateStr = task.actualStartDate || task.plannedStartDate;
|
|
73
|
+
if (startDateStr && task.dueDate && semanticStatus === 'in_progress') {
|
|
74
|
+
const startTime = new Date(startDateStr).getTime();
|
|
75
|
+
const dueTime = new Date(task.dueDate).getTime();
|
|
76
|
+
const nowTime = referenceDate.getTime();
|
|
77
|
+
|
|
78
|
+
if (!isNaN(startTime) && !isNaN(dueTime) && dueTime > startTime) {
|
|
79
|
+
const elapsed = nowTime - startTime;
|
|
80
|
+
const total = dueTime - startTime;
|
|
81
|
+
const ratio = Math.min(1, Math.max(0, elapsed / total));
|
|
82
|
+
scheduleProgress = Math.round(ratio * 1000) / 10;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Select canonical progress percentage and primary inference source
|
|
87
|
+
let progressPercentage = 0;
|
|
88
|
+
let source: ProgressInferenceSource = 'explicit';
|
|
89
|
+
|
|
90
|
+
if (explicitProgress !== undefined && explicitProgress > 0) {
|
|
91
|
+
progressPercentage = explicitProgress;
|
|
92
|
+
source = 'explicit';
|
|
93
|
+
} else if (todoProgress !== undefined && todoProgress > 0) {
|
|
94
|
+
progressPercentage = todoProgress;
|
|
95
|
+
source = 'todos';
|
|
96
|
+
} else if (effortProgress !== undefined && effortProgress > 0 && semanticStatus === 'in_progress') {
|
|
97
|
+
progressPercentage = effortProgress;
|
|
98
|
+
source = 'time_effort';
|
|
99
|
+
} else if (scheduleProgress !== undefined && scheduleProgress > 0 && semanticStatus === 'in_progress') {
|
|
100
|
+
progressPercentage = scheduleProgress;
|
|
101
|
+
source = 'schedule_elapsed';
|
|
102
|
+
} else if (explicitProgress !== undefined) {
|
|
103
|
+
progressPercentage = explicitProgress;
|
|
104
|
+
source = 'explicit';
|
|
105
|
+
} else {
|
|
106
|
+
progressPercentage = 0;
|
|
107
|
+
source = 'explicit';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
progressPercentage,
|
|
112
|
+
source,
|
|
113
|
+
isExplicit: explicitProgress !== undefined,
|
|
114
|
+
breakdown: {
|
|
115
|
+
explicitProgress,
|
|
116
|
+
todoProgress,
|
|
117
|
+
effortProgress,
|
|
118
|
+
scheduleProgress
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Calculate Earned Value Management (EVM) metrics for a task.
|
|
125
|
+
*/
|
|
126
|
+
export function calculateTaskEVM(
|
|
127
|
+
task: Task,
|
|
128
|
+
options?: MetricOptions & { progressInference?: TaskProgressInference }
|
|
129
|
+
): TaskEVM {
|
|
130
|
+
const referenceDate = options?.referenceDate ?? new Date();
|
|
131
|
+
const statusDef = resolveStatusDefinition(task.status, options?.customStatusDefinitions);
|
|
132
|
+
const semanticStatus = task.semanticStatus || statusDef.category;
|
|
133
|
+
|
|
134
|
+
const estimatedHours = task.estimatedHours || (task.estimatedDurationMinutes ? task.estimatedDurationMinutes / 60 : 0);
|
|
135
|
+
const actualCost = Math.round(((task.loggedHours ?? task.actualHours) || 0) * 100) / 100;
|
|
136
|
+
|
|
137
|
+
if (estimatedHours <= 0) {
|
|
138
|
+
return {
|
|
139
|
+
plannedValue: actualCost,
|
|
140
|
+
earnedValue: actualCost,
|
|
141
|
+
actualCost,
|
|
142
|
+
costVariance: 0,
|
|
143
|
+
scheduleVariance: 0,
|
|
144
|
+
costPerformanceIndex: 1.0,
|
|
145
|
+
schedulePerformanceIndex: 1.0
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const progress = options?.progressInference ?? inferTaskProgress(task, options);
|
|
150
|
+
const actualProgressRatio = Math.min(1, Math.max(0, progress.progressPercentage / 100));
|
|
151
|
+
const earnedValue = Math.round(actualProgressRatio * estimatedHours * 100) / 100;
|
|
152
|
+
|
|
153
|
+
// Compute Planned Value (PV) based on expected progress at reference date
|
|
154
|
+
let plannedRatio = 0;
|
|
155
|
+
if (semanticStatus === 'completed') {
|
|
156
|
+
plannedRatio = 1.0;
|
|
157
|
+
} else {
|
|
158
|
+
const startStr = task.plannedStartDate || task.actualStartDate;
|
|
159
|
+
if (startStr && task.dueDate) {
|
|
160
|
+
const startTime = new Date(startStr).getTime();
|
|
161
|
+
const dueTime = new Date(task.dueDate).getTime();
|
|
162
|
+
const nowTime = referenceDate.getTime();
|
|
163
|
+
|
|
164
|
+
if (!isNaN(startTime) && !isNaN(dueTime)) {
|
|
165
|
+
if (nowTime <= startTime) {
|
|
166
|
+
plannedRatio = 0;
|
|
167
|
+
} else if (nowTime >= dueTime) {
|
|
168
|
+
plannedRatio = 1.0;
|
|
169
|
+
} else {
|
|
170
|
+
plannedRatio = (nowTime - startTime) / (dueTime - startTime);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else if (task.dueDate) {
|
|
174
|
+
const dueTime = new Date(task.dueDate).getTime();
|
|
175
|
+
if (!isNaN(dueTime) && referenceDate.getTime() >= dueTime) {
|
|
176
|
+
plannedRatio = 1.0;
|
|
177
|
+
} else if (semanticStatus === 'in_progress') {
|
|
178
|
+
plannedRatio = 0.5;
|
|
179
|
+
}
|
|
180
|
+
} else if (semanticStatus === 'in_progress') {
|
|
181
|
+
plannedRatio = 0.5;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const plannedValue = Math.round(plannedRatio * estimatedHours * 100) / 100;
|
|
186
|
+
const costVariance = Math.round((earnedValue - actualCost) * 100) / 100;
|
|
187
|
+
const scheduleVariance = Math.round((earnedValue - plannedValue) * 100) / 100;
|
|
188
|
+
|
|
189
|
+
const costPerformanceIndex = actualCost > 0
|
|
190
|
+
? Math.round((earnedValue / actualCost) * 100) / 100
|
|
191
|
+
: earnedValue > 0 ? 1.0 : 1.0;
|
|
192
|
+
|
|
193
|
+
const schedulePerformanceIndex = plannedValue > 0
|
|
194
|
+
? Math.round((earnedValue / plannedValue) * 100) / 100
|
|
195
|
+
: earnedValue > 0 ? 1.0 : 1.0;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
plannedValue,
|
|
199
|
+
earnedValue,
|
|
200
|
+
actualCost,
|
|
201
|
+
costVariance,
|
|
202
|
+
scheduleVariance,
|
|
203
|
+
costPerformanceIndex,
|
|
204
|
+
schedulePerformanceIndex
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Reconstruct task progress history and curve shape from activity logs.
|
|
210
|
+
*/
|
|
211
|
+
export function reconstructTaskProgressHistory(
|
|
212
|
+
task: Task,
|
|
213
|
+
activities: Activity[] = [],
|
|
214
|
+
options?: MetricOptions
|
|
215
|
+
): TaskProgressHistory {
|
|
216
|
+
const referenceDate = options?.referenceDate ?? new Date();
|
|
217
|
+
const taskActivities = activities
|
|
218
|
+
.filter((a) => a.taskId === task.id)
|
|
219
|
+
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
220
|
+
|
|
221
|
+
const points: TaskProgressHistoryPoint[] = [];
|
|
222
|
+
|
|
223
|
+
// Point 0: Task creation
|
|
224
|
+
points.push({
|
|
225
|
+
timestamp: task.createdAt,
|
|
226
|
+
progress: 0,
|
|
227
|
+
inferredProgress: 0,
|
|
228
|
+
status: 'todo',
|
|
229
|
+
semanticStatus: 'not_started',
|
|
230
|
+
action: 'task.created'
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
let runningProgress = 0;
|
|
234
|
+
let runningStatus = task.status;
|
|
235
|
+
|
|
236
|
+
for (const act of taskActivities) {
|
|
237
|
+
let pointProgress = runningProgress;
|
|
238
|
+
let pointInferred: number | undefined;
|
|
239
|
+
|
|
240
|
+
const details = (act.details || {}) as Record<string, unknown>;
|
|
241
|
+
|
|
242
|
+
if (act.action === 'task.status_changed') {
|
|
243
|
+
const newStatus = (details.toStatus as string) || (details.newStatus as string) || runningStatus;
|
|
244
|
+
runningStatus = newStatus;
|
|
245
|
+
const def = resolveStatusDefinition(newStatus, options?.customStatusDefinitions);
|
|
246
|
+
if (def.category === 'completed') {
|
|
247
|
+
pointProgress = 100;
|
|
248
|
+
pointInferred = 100;
|
|
249
|
+
}
|
|
250
|
+
} else if (act.action === 'task.updated') {
|
|
251
|
+
if (typeof details.progress === 'number') {
|
|
252
|
+
pointProgress = Math.min(100, Math.max(0, details.progress));
|
|
253
|
+
} else if (details.updates && typeof (details.updates as Record<string, unknown>).progress === 'number') {
|
|
254
|
+
pointProgress = Math.min(100, Math.max(0, (details.updates as Record<string, unknown>).progress as number));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
runningProgress = pointProgress;
|
|
259
|
+
const def = resolveStatusDefinition(runningStatus, options?.customStatusDefinitions);
|
|
260
|
+
|
|
261
|
+
points.push({
|
|
262
|
+
timestamp: act.createdAt,
|
|
263
|
+
progress: pointProgress,
|
|
264
|
+
inferredProgress: pointInferred,
|
|
265
|
+
status: runningStatus,
|
|
266
|
+
semanticStatus: def.category,
|
|
267
|
+
action: act.action
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Ensure latest current state is reflected at the end if different from last point
|
|
272
|
+
const currentInferred = inferTaskProgress(task, options);
|
|
273
|
+
const currentProgress = task.progress ?? currentInferred.progressPercentage;
|
|
274
|
+
const lastPoint = points[points.length - 1];
|
|
275
|
+
|
|
276
|
+
if (!lastPoint || lastPoint.progress !== currentProgress || lastPoint.status !== task.status) {
|
|
277
|
+
points.push({
|
|
278
|
+
timestamp: task.updatedAt || referenceDate.toISOString(),
|
|
279
|
+
progress: currentProgress,
|
|
280
|
+
inferredProgress: currentInferred.progressPercentage,
|
|
281
|
+
status: task.status,
|
|
282
|
+
semanticStatus: task.semanticStatus || resolveStatusDefinition(task.status, options?.customStatusDefinitions).category,
|
|
283
|
+
action: 'snapshot.current'
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Deduplicate any points with exact same timestamp
|
|
288
|
+
const uniquePoints: TaskProgressHistoryPoint[] = [];
|
|
289
|
+
const seenTimestamps = new Set<string>();
|
|
290
|
+
for (const pt of points) {
|
|
291
|
+
if (!seenTimestamps.has(pt.timestamp + pt.action)) {
|
|
292
|
+
seenTimestamps.add(pt.timestamp + pt.action);
|
|
293
|
+
uniquePoints.push(pt);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Classify curve profile
|
|
298
|
+
const curveProfile = classifyCurveProfile(uniquePoints, task, referenceDate);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
taskId: task.id,
|
|
302
|
+
points: uniquePoints,
|
|
303
|
+
curveProfile
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function classifyCurveProfile(
|
|
308
|
+
points: TaskProgressHistoryPoint[],
|
|
309
|
+
task: Task,
|
|
310
|
+
referenceDate: Date
|
|
311
|
+
): ProgressCurveProfile {
|
|
312
|
+
if (points.length < 3) {
|
|
313
|
+
return 'insufficient_data';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const startPt = points[0];
|
|
317
|
+
const endPt = points[points.length - 1];
|
|
318
|
+
const startTime = new Date(startPt.timestamp).getTime();
|
|
319
|
+
const endTime = new Date(endPt.timestamp).getTime();
|
|
320
|
+
const totalDuration = endTime - startTime;
|
|
321
|
+
|
|
322
|
+
if (totalDuration <= 0) {
|
|
323
|
+
return 'insufficient_data';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Check if stalled: last 2 or more points have same progress for an extended span while in progress
|
|
327
|
+
const statusDef = resolveStatusDefinition(task.status);
|
|
328
|
+
if (statusDef.category === 'in_progress') {
|
|
329
|
+
const lastActivity = new Date(endPt.timestamp).getTime();
|
|
330
|
+
const stallThresholdMs = 5 * 24 * 60 * 60 * 1000;
|
|
331
|
+
if (referenceDate.getTime() - lastActivity > stallThresholdMs) {
|
|
332
|
+
return 'stalled';
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Sample progress at 25%, 50%, and 75% marks of timeline
|
|
337
|
+
const p25Time = startTime + totalDuration * 0.25;
|
|
338
|
+
const p50Time = startTime + totalDuration * 0.50;
|
|
339
|
+
const p75Time = startTime + totalDuration * 0.75;
|
|
340
|
+
|
|
341
|
+
const getInterpolatedProgress = (t: number): number => {
|
|
342
|
+
if (t <= startTime) return points[0].progress;
|
|
343
|
+
if (t >= endTime) return endPt.progress;
|
|
344
|
+
|
|
345
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
346
|
+
const pA = points[i];
|
|
347
|
+
const pB = points[i + 1];
|
|
348
|
+
const tA = new Date(pA.timestamp).getTime();
|
|
349
|
+
const tB = new Date(pB.timestamp).getTime();
|
|
350
|
+
if (t >= tA && t <= tB) {
|
|
351
|
+
if (tB === tA) return pA.progress;
|
|
352
|
+
const fraction = (t - tA) / (tB - tA);
|
|
353
|
+
return pA.progress + fraction * (pB.progress - pA.progress);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return endPt.progress;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const p25 = getInterpolatedProgress(p25Time);
|
|
360
|
+
const p50 = getInterpolatedProgress(p50Time);
|
|
361
|
+
const p75 = getInterpolatedProgress(p75Time);
|
|
362
|
+
const finalP = endPt.progress;
|
|
363
|
+
|
|
364
|
+
const totalGain = finalP - startPt.progress;
|
|
365
|
+
if (totalGain <= 5) {
|
|
366
|
+
return 'stalled';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Early Surge: Reached > 60% early on (by 25% or 50% time) and flattened
|
|
370
|
+
if (p25 >= 60 || (p50 >= 65 && finalP - p50 <= 15)) {
|
|
371
|
+
return 'early_surge';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Late Rush: Little progress (< 25%) until late in the timeline (75%), then steep jump
|
|
375
|
+
if (p50 <= 20 && p75 <= 35 && finalP >= 70) {
|
|
376
|
+
return 'late_rush';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// S-Curve: Slow start (p25 <= 25), steep middle acceleration (p75 - p25 >= 55), slow finish (finalP - p75 <= 20)
|
|
380
|
+
if (p25 <= 25 && (p75 - p25) >= 55 && (finalP - p75) <= 20) {
|
|
381
|
+
return 's_curve';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Default steady progression
|
|
385
|
+
return 'linear';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Calculate comprehensive multi-dimensional metrics for a task.
|
|
390
|
+
*/
|
|
391
|
+
export function calculateTaskMetrics(
|
|
392
|
+
task: Task,
|
|
393
|
+
activities: Activity[] = [],
|
|
394
|
+
timeEntries: TimeEntry[] = [],
|
|
395
|
+
options?: MetricOptions
|
|
396
|
+
): TaskMetrics {
|
|
397
|
+
const referenceDate = options?.referenceDate ?? new Date();
|
|
398
|
+
const taskTimeEntries = timeEntries.filter((te) => te.taskId === task.id);
|
|
399
|
+
const taskActivities = activities.filter((a) => a.taskId === task.id);
|
|
400
|
+
|
|
401
|
+
const loggedHoursFromEntries = taskTimeEntries.reduce((sum, e) => sum + e.hours, 0);
|
|
402
|
+
const effectiveLoggedHours = Math.max(loggedHoursFromEntries, task.loggedHours || 0, task.actualHours || 0);
|
|
403
|
+
const estimatedHours = task.estimatedHours || (task.estimatedDurationMinutes ? task.estimatedDurationMinutes / 60 : 0);
|
|
404
|
+
const varianceHours = Math.round((effectiveLoggedHours - estimatedHours) * 100) / 100;
|
|
405
|
+
|
|
406
|
+
// Multi-dimensional variance
|
|
407
|
+
let scheduleVarianceDays: number | undefined;
|
|
408
|
+
if (task.dueDate && task.actualEndDate) {
|
|
409
|
+
const dueTime = new Date(task.dueDate).getTime();
|
|
410
|
+
const actualTime = new Date(task.actualEndDate).getTime();
|
|
411
|
+
if (!isNaN(dueTime) && !isNaN(actualTime)) {
|
|
412
|
+
scheduleVarianceDays = Math.round((actualTime - dueTime) / (1000 * 60 * 60 * 24));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let durationVarianceHours: number | undefined;
|
|
417
|
+
if (task.actualStartDate && task.actualEndDate && task.plannedStartDate && task.dueDate) {
|
|
418
|
+
const actualDuration = (new Date(task.actualEndDate).getTime() - new Date(task.actualStartDate).getTime()) / (1000 * 60 * 60);
|
|
419
|
+
const plannedDuration = (new Date(task.dueDate).getTime() - new Date(task.plannedStartDate).getTime()) / (1000 * 60 * 60);
|
|
420
|
+
if (!isNaN(actualDuration) && !isNaN(plannedDuration)) {
|
|
421
|
+
durationVarianceHours = Math.round((actualDuration - plannedDuration) * 100) / 100;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const accuracyRatio = estimatedHours > 0
|
|
426
|
+
? Math.round((effectiveLoggedHours / estimatedHours) * 100) / 100
|
|
427
|
+
: undefined;
|
|
428
|
+
|
|
429
|
+
const nowIso = referenceDate.toISOString();
|
|
430
|
+
const isOverdue = Boolean(
|
|
431
|
+
task.dueDate &&
|
|
432
|
+
task.dueDate < nowIso &&
|
|
433
|
+
task.semanticStatus !== 'completed' &&
|
|
434
|
+
task.semanticStatus !== 'canceled'
|
|
435
|
+
);
|
|
436
|
+
const isOverEstimate = Boolean(estimatedHours > 0 && effectiveLoggedHours > estimatedHours);
|
|
437
|
+
|
|
438
|
+
const realityDelta: RealityDelta = {
|
|
439
|
+
plannedStartDate: task.plannedStartDate,
|
|
440
|
+
actualStartDate: task.actualStartDate,
|
|
441
|
+
dueDate: task.dueDate,
|
|
442
|
+
actualEndDate: task.actualEndDate,
|
|
443
|
+
estimatedHours: Math.round(estimatedHours * 100) / 100,
|
|
444
|
+
loggedHours: Math.round(effectiveLoggedHours * 100) / 100,
|
|
445
|
+
varianceHours,
|
|
446
|
+
effortVarianceHours: varianceHours,
|
|
447
|
+
durationVarianceHours,
|
|
448
|
+
scheduleVarianceDays,
|
|
449
|
+
accuracyRatio,
|
|
450
|
+
isOverdue,
|
|
451
|
+
isOverEstimate
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Compute Inferred Actuals
|
|
455
|
+
let activeWorkingHours: number | undefined;
|
|
456
|
+
if (task.actualStartDate && task.actualEndDate) {
|
|
457
|
+
const startMs = new Date(task.actualStartDate).getTime();
|
|
458
|
+
const endMs = new Date(task.actualEndDate).getTime();
|
|
459
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
460
|
+
activeWorkingHours = Math.round(((endMs - startMs) / (1000 * 60 * 60)) * 100) / 100;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const inferredActuals: TaskInferredActuals = {
|
|
465
|
+
actualStartDate: task.actualStartDate,
|
|
466
|
+
actualEndDate: task.actualEndDate,
|
|
467
|
+
isStartDateInferred: Boolean(task.actualStartDate && !task.plannedStartDate),
|
|
468
|
+
isEndDateInferred: Boolean(task.actualEndDate && task.semanticStatus === 'completed'),
|
|
469
|
+
activeWorkingHours
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const progress = inferTaskProgress(task, options);
|
|
473
|
+
const evm = calculateTaskEVM(task, { ...options, progressInference: progress });
|
|
474
|
+
const progressHistory = reconstructTaskProgressHistory(task, taskActivities, options);
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
taskId: task.id,
|
|
478
|
+
inferredActuals,
|
|
479
|
+
realityDelta,
|
|
480
|
+
progress,
|
|
481
|
+
evm,
|
|
482
|
+
progressHistory
|
|
483
|
+
};
|
|
484
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -28,7 +28,9 @@ import type {
|
|
|
28
28
|
TaskLadderView,
|
|
29
29
|
MacroPhaseRollup,
|
|
30
30
|
StandardTaskTimelineItem,
|
|
31
|
-
ConcreteTaskEvidence
|
|
31
|
+
ConcreteTaskEvidence,
|
|
32
|
+
TaskMetrics,
|
|
33
|
+
TaskProgressHistory
|
|
32
34
|
} from '../types/index.js';
|
|
33
35
|
import { StorageAdapter, InMemoryStore } from '../store/index.js';
|
|
34
36
|
import { PluginRegistry } from '../plugins/index.js';
|
|
@@ -77,6 +79,11 @@ import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
|
77
79
|
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
78
80
|
import { calculateCPM } from '../domain/cpm.js';
|
|
79
81
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
82
|
+
import {
|
|
83
|
+
calculateTaskMetrics,
|
|
84
|
+
reconstructTaskProgressHistory,
|
|
85
|
+
type MetricOptions
|
|
86
|
+
} from '../domain/metrics.js';
|
|
80
87
|
|
|
81
88
|
export class CriticalPathEngine {
|
|
82
89
|
public readonly store: StorageAdapter;
|
|
@@ -451,6 +458,19 @@ export class CriticalPathEngine {
|
|
|
451
458
|
);
|
|
452
459
|
processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? statusDef.category;
|
|
453
460
|
const now = new Date().toISOString();
|
|
461
|
+
|
|
462
|
+
const existingStatusDef = resolveStatusDefinition(
|
|
463
|
+
existing.status,
|
|
464
|
+
project?.statusDefinitions || workflow?.statuses
|
|
465
|
+
);
|
|
466
|
+
if (
|
|
467
|
+
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
468
|
+
(statusDef.category === 'in_progress' || statusDef.category === 'not_started') &&
|
|
469
|
+
processedUpdates.actualEndDate === undefined
|
|
470
|
+
) {
|
|
471
|
+
processedUpdates.actualEndDate = undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
454
474
|
if (statusDef.category === 'in_progress' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
455
475
|
processedUpdates.actualStartDate = now;
|
|
456
476
|
}
|
|
@@ -1355,7 +1375,7 @@ export class CriticalPathEngine {
|
|
|
1355
1375
|
return this.store.deleteIteration(id);
|
|
1356
1376
|
}
|
|
1357
1377
|
|
|
1358
|
-
// ---
|
|
1378
|
+
// --- Ladder of Abstraction & Critical Path Method ---
|
|
1359
1379
|
async calculateCriticalPath(projectId: string): Promise<CriticalPathAnalysis> {
|
|
1360
1380
|
const tasks = await this.store.getTasks(projectId);
|
|
1361
1381
|
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
@@ -1441,15 +1461,54 @@ export class CriticalPathEngine {
|
|
|
1441
1461
|
|
|
1442
1462
|
const concrete = ladder.concrete?.[taskId] || aggregateConcreteEvidenceForTask(task);
|
|
1443
1463
|
const macroPhase = ladder.macro?.phases.find((p) => p.taskIds.includes(taskId));
|
|
1464
|
+
const metrics = await this.getTaskMetrics(taskId);
|
|
1444
1465
|
|
|
1445
1466
|
return {
|
|
1446
1467
|
taskId,
|
|
1447
1468
|
macroPhase,
|
|
1448
1469
|
standard,
|
|
1449
|
-
concrete
|
|
1470
|
+
concrete,
|
|
1471
|
+
metrics: metrics || undefined
|
|
1450
1472
|
};
|
|
1451
1473
|
}
|
|
1452
1474
|
|
|
1475
|
+
async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
|
|
1476
|
+
const task = await this.store.getTask(taskId);
|
|
1477
|
+
if (!task) return null;
|
|
1478
|
+
|
|
1479
|
+
const [activities, timeEntries, project, workflow] = await Promise.all([
|
|
1480
|
+
this.store.getActivities({ taskId }),
|
|
1481
|
+
this.store.getTimeEntries(taskId),
|
|
1482
|
+
this.store.getProject(task.projectId),
|
|
1483
|
+
this.resolveProjectWorkflow(task.projectId)
|
|
1484
|
+
]);
|
|
1485
|
+
|
|
1486
|
+
const customStatusDefinitions = project?.statusDefinitions || workflow?.statuses;
|
|
1487
|
+
|
|
1488
|
+
return calculateTaskMetrics(task, activities, timeEntries, {
|
|
1489
|
+
customStatusDefinitions,
|
|
1490
|
+
...options
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
|
|
1495
|
+
const task = await this.store.getTask(taskId);
|
|
1496
|
+
if (!task) return null;
|
|
1497
|
+
|
|
1498
|
+
const [activities, project, workflow] = await Promise.all([
|
|
1499
|
+
this.store.getActivities({ taskId }),
|
|
1500
|
+
this.store.getProject(task.projectId),
|
|
1501
|
+
this.resolveProjectWorkflow(task.projectId)
|
|
1502
|
+
]);
|
|
1503
|
+
|
|
1504
|
+
const customStatusDefinitions = project?.statusDefinitions || workflow?.statuses;
|
|
1505
|
+
|
|
1506
|
+
return reconstructTaskProgressHistory(task, activities, {
|
|
1507
|
+
customStatusDefinitions,
|
|
1508
|
+
...options
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1453
1512
|
private async dispatchWebhook(event: WebhookEvent, payload: Record<string, unknown>): Promise<void> {
|
|
1454
1513
|
const webhooks = await this.store.getWebhooks();
|
|
1455
1514
|
const active = webhooks.filter((w) => w.active && w.events.includes(event));
|
package/src/types/index.ts
CHANGED
|
@@ -426,7 +426,7 @@ export interface CriticalPathConfig {
|
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
// ==========================================
|
|
429
|
-
//
|
|
429
|
+
// Ladder of Abstraction Types
|
|
430
430
|
// ==========================================
|
|
431
431
|
|
|
432
432
|
export type AbstractionLevel = 'macro' | 'standard' | 'concrete' | 'all';
|
|
@@ -518,11 +518,78 @@ export interface RealityDelta {
|
|
|
518
518
|
estimatedHours: number;
|
|
519
519
|
loggedHours: number;
|
|
520
520
|
varianceHours: number; // loggedHours - estimatedHours
|
|
521
|
+
effortVarianceHours?: number; // loggedHours - estimatedHours (effort variance)
|
|
522
|
+
durationVarianceHours?: number; // actual duration hours - planned duration hours
|
|
521
523
|
scheduleVarianceDays?: number; // days difference between planned/due vs actual
|
|
524
|
+
accuracyRatio?: number; // loggedHours / estimatedHours (estimation accuracy ratio)
|
|
522
525
|
isOverdue: boolean;
|
|
523
526
|
isOverEstimate: boolean;
|
|
524
527
|
}
|
|
525
528
|
|
|
529
|
+
export type ProgressInferenceSource = 'explicit' | 'todos' | 'time_effort' | 'schedule_elapsed' | 'blended';
|
|
530
|
+
|
|
531
|
+
export interface TaskProgressInference {
|
|
532
|
+
progressPercentage: number;
|
|
533
|
+
source: ProgressInferenceSource;
|
|
534
|
+
isExplicit: boolean;
|
|
535
|
+
breakdown: {
|
|
536
|
+
explicitProgress?: number;
|
|
537
|
+
todoProgress?: number;
|
|
538
|
+
effortProgress?: number;
|
|
539
|
+
scheduleProgress?: number;
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export type ProgressCurveProfile =
|
|
544
|
+
| 'linear'
|
|
545
|
+
| 's_curve'
|
|
546
|
+
| 'early_surge'
|
|
547
|
+
| 'late_rush'
|
|
548
|
+
| 'stalled'
|
|
549
|
+
| 'insufficient_data';
|
|
550
|
+
|
|
551
|
+
export interface TaskProgressHistoryPoint {
|
|
552
|
+
timestamp: string;
|
|
553
|
+
progress: number;
|
|
554
|
+
inferredProgress?: number;
|
|
555
|
+
status: TaskStatus;
|
|
556
|
+
semanticStatus?: SemanticStatus;
|
|
557
|
+
action: string;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export interface TaskProgressHistory {
|
|
561
|
+
taskId: string;
|
|
562
|
+
points: TaskProgressHistoryPoint[];
|
|
563
|
+
curveProfile: ProgressCurveProfile;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export interface TaskEVM {
|
|
567
|
+
plannedValue: number; // PV (expected hours)
|
|
568
|
+
earnedValue: number; // EV (earned hours)
|
|
569
|
+
actualCost: number; // AC (logged hours)
|
|
570
|
+
costVariance: number; // CV = EV - AC
|
|
571
|
+
scheduleVariance: number; // SV = EV - PV
|
|
572
|
+
costPerformanceIndex: number; // CPI = EV / AC
|
|
573
|
+
schedulePerformanceIndex: number; // SPI = EV / PV
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export interface TaskInferredActuals {
|
|
577
|
+
actualStartDate?: string;
|
|
578
|
+
actualEndDate?: string;
|
|
579
|
+
isStartDateInferred: boolean;
|
|
580
|
+
isEndDateInferred: boolean;
|
|
581
|
+
activeWorkingHours?: number;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export interface TaskMetrics {
|
|
585
|
+
taskId: string;
|
|
586
|
+
inferredActuals: TaskInferredActuals;
|
|
587
|
+
realityDelta: RealityDelta;
|
|
588
|
+
progress: TaskProgressInference;
|
|
589
|
+
evm: TaskEVM;
|
|
590
|
+
progressHistory: TaskProgressHistory;
|
|
591
|
+
}
|
|
592
|
+
|
|
526
593
|
export interface ConcreteTaskEvidence {
|
|
527
594
|
taskId: string;
|
|
528
595
|
attachments: Attachment[];
|
|
@@ -560,5 +627,6 @@ export interface TaskLadderView {
|
|
|
560
627
|
macroPhase?: MacroPhaseRollup;
|
|
561
628
|
standard: StandardTaskTimelineItem;
|
|
562
629
|
concrete: ConcreteTaskEvidence;
|
|
630
|
+
metrics?: TaskMetrics;
|
|
563
631
|
}
|
|
564
632
|
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
|