@critical-path/core 0.20.0 → 0.20.2
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 +20 -0
- package/dist/domain/entities.d.ts +4 -0
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +56 -0
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/metrics.d.ts.map +1 -1
- package/dist/domain/metrics.js +2 -1
- package/dist/domain/metrics.js.map +1 -1
- package/dist/domain/task-duration.test.js +129 -0
- package/dist/domain/task-duration.test.js.map +1 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +80 -28
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +4 -3
- package/dist/index.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +11 -5
- package/dist/store/sqlite.js.map +1 -1
- package/dist/task-state.test.js +11 -11
- package/dist/task-state.test.js.map +1 -1
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +1 -3
- package/dist/utils/status.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/entities.ts +56 -0
- package/src/domain/metrics.ts +2 -1
- package/src/domain/task-duration.test.ts +148 -0
- package/src/engine/index.ts +89 -37
- package/src/index.test.ts +4 -3
- package/src/store/sqlite.ts +15 -5
- package/src/task-state.test.ts +11 -11
- package/src/types/index.ts +6 -0
- package/src/utils/status.ts +1 -5
package/src/domain/entities.ts
CHANGED
|
@@ -89,7 +89,11 @@ export class TaskEntity extends BaseEntity {
|
|
|
89
89
|
public billableDurationMinutes?: number;
|
|
90
90
|
public actualDurationSeconds?: number;
|
|
91
91
|
public inProgressSince?: string | null;
|
|
92
|
+
public blockedDurationSeconds?: number;
|
|
93
|
+
public blockedSince?: string | null;
|
|
92
94
|
public progress?: number;
|
|
95
|
+
public isBlocked?: boolean;
|
|
96
|
+
public blockedReason?: string | null;
|
|
93
97
|
public tags?: string[];
|
|
94
98
|
public todos?: TaskTodoItem[];
|
|
95
99
|
public customFields?: Record<string, unknown>;
|
|
@@ -125,7 +129,11 @@ export class TaskEntity extends BaseEntity {
|
|
|
125
129
|
this.billableDurationMinutes = data.billableDurationMinutes;
|
|
126
130
|
this.actualDurationSeconds = data.actualDurationSeconds;
|
|
127
131
|
this.inProgressSince = data.inProgressSince;
|
|
132
|
+
this.blockedDurationSeconds = data.blockedDurationSeconds;
|
|
133
|
+
this.blockedSince = data.blockedSince;
|
|
128
134
|
this.progress = data.progress ?? 0;
|
|
135
|
+
this.isBlocked = data.isBlocked;
|
|
136
|
+
this.blockedReason = data.blockedReason;
|
|
129
137
|
this.tags = data.tags ? [...data.tags] : [];
|
|
130
138
|
this.todos = data.todos ? data.todos.map((t) => ({ ...t })) : [];
|
|
131
139
|
this.customFields = data.customFields ? { ...data.customFields } : {};
|
|
@@ -149,6 +157,8 @@ export class TaskEntity extends BaseEntity {
|
|
|
149
157
|
const actualStartDate = input.actualStartDate ?? (semanticStatus === 'in_progress' ? now : undefined);
|
|
150
158
|
const inProgressSince = input.inProgressSince ?? (semanticStatus === 'in_progress' ? now : undefined);
|
|
151
159
|
const actualEndDate = input.actualEndDate ?? ((semanticStatus === 'completed' || semanticStatus === 'canceled') ? now : undefined);
|
|
160
|
+
const isBlocked = input.isBlocked ?? false;
|
|
161
|
+
const blockedSince = input.blockedSince ?? (isBlocked && semanticStatus === 'in_progress' ? now : undefined);
|
|
152
162
|
|
|
153
163
|
const task = new TaskEntity({
|
|
154
164
|
...input,
|
|
@@ -161,6 +171,10 @@ export class TaskEntity extends BaseEntity {
|
|
|
161
171
|
inProgressSince,
|
|
162
172
|
actualEndDate,
|
|
163
173
|
actualDurationSeconds: input.actualDurationSeconds,
|
|
174
|
+
blockedDurationSeconds: input.blockedDurationSeconds,
|
|
175
|
+
blockedSince,
|
|
176
|
+
isBlocked,
|
|
177
|
+
blockedReason: input.blockedReason ?? null,
|
|
164
178
|
priority: input.priority || 'medium',
|
|
165
179
|
loggedHours: input.loggedHours ?? 0,
|
|
166
180
|
progress: input.progress ?? 0,
|
|
@@ -216,6 +230,15 @@ export class TaskEntity extends BaseEntity {
|
|
|
216
230
|
}
|
|
217
231
|
this.inProgressSince = null;
|
|
218
232
|
}
|
|
233
|
+
if (this.blockedSince) {
|
|
234
|
+
const startMs = new Date(this.blockedSince).getTime();
|
|
235
|
+
const endMs = new Date(now).getTime();
|
|
236
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
237
|
+
const blockedSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
238
|
+
this.blockedDurationSeconds = (this.blockedDurationSeconds || 0) + blockedSeconds;
|
|
239
|
+
}
|
|
240
|
+
this.blockedSince = null;
|
|
241
|
+
}
|
|
219
242
|
}
|
|
220
243
|
|
|
221
244
|
// 2. Entering in_progress: retain earliest start date & start session timer
|
|
@@ -226,6 +249,9 @@ export class TaskEntity extends BaseEntity {
|
|
|
226
249
|
if (!this.inProgressSince) {
|
|
227
250
|
this.inProgressSince = now;
|
|
228
251
|
}
|
|
252
|
+
if (this.isBlocked && !this.blockedSince) {
|
|
253
|
+
this.blockedSince = now;
|
|
254
|
+
}
|
|
229
255
|
if (prevStatusDef.category === 'completed' || prevStatusDef.category === 'canceled') {
|
|
230
256
|
this.actualEndDate = undefined;
|
|
231
257
|
}
|
|
@@ -394,6 +420,32 @@ export class TaskEntity extends BaseEntity {
|
|
|
394
420
|
});
|
|
395
421
|
}
|
|
396
422
|
|
|
423
|
+
const wasBlocked = Boolean(this.isBlocked);
|
|
424
|
+
const isNowBlocked = updates.isBlocked !== undefined ? Boolean(updates.isBlocked) : wasBlocked;
|
|
425
|
+
const now = new Date().toISOString();
|
|
426
|
+
const effectiveSemantic = updates.semanticStatus ?? this.semanticStatus;
|
|
427
|
+
|
|
428
|
+
if (effectiveSemantic === 'in_progress') {
|
|
429
|
+
if (!wasBlocked && isNowBlocked) {
|
|
430
|
+
if (!updates.blockedSince) {
|
|
431
|
+
updates.blockedSince = now;
|
|
432
|
+
}
|
|
433
|
+
} else if (wasBlocked && !isNowBlocked) {
|
|
434
|
+
if (this.blockedSince) {
|
|
435
|
+
const startMs = new Date(this.blockedSince).getTime();
|
|
436
|
+
const endMs = new Date(now).getTime();
|
|
437
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
438
|
+
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
439
|
+
updates.blockedDurationSeconds =
|
|
440
|
+
(updates.blockedDurationSeconds ?? this.blockedDurationSeconds ?? 0) + sessionSeconds;
|
|
441
|
+
}
|
|
442
|
+
updates.blockedSince = null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} else if (!isNowBlocked && this.blockedSince) {
|
|
446
|
+
updates.blockedSince = null;
|
|
447
|
+
}
|
|
448
|
+
|
|
397
449
|
Object.assign(this, updates);
|
|
398
450
|
if (updates.actualDurationSeconds !== undefined && updates.actualHours === undefined) {
|
|
399
451
|
this.actualHours = Math.round((updates.actualDurationSeconds / 3600) * 100) / 100;
|
|
@@ -451,7 +503,11 @@ export class TaskEntity extends BaseEntity {
|
|
|
451
503
|
billableDurationMinutes: this.billableDurationMinutes,
|
|
452
504
|
actualDurationSeconds: this.actualDurationSeconds,
|
|
453
505
|
inProgressSince: this.inProgressSince,
|
|
506
|
+
blockedDurationSeconds: this.blockedDurationSeconds,
|
|
507
|
+
blockedSince: this.blockedSince,
|
|
454
508
|
progress: this.progress,
|
|
509
|
+
isBlocked: this.isBlocked,
|
|
510
|
+
blockedReason: this.blockedReason,
|
|
455
511
|
tags: this.tags ? [...this.tags] : [],
|
|
456
512
|
todos: this.todos ? this.todos.map((t) => ({ ...t })) : [],
|
|
457
513
|
customFields: this.customFields ? { ...this.customFields } : {},
|
package/src/domain/metrics.ts
CHANGED
|
@@ -478,7 +478,8 @@ export function calculateTaskMetrics(
|
|
|
478
478
|
isEndDateInferred: Boolean(task.actualEndDate && task.semanticStatus === 'completed'),
|
|
479
479
|
activeWorkingHours,
|
|
480
480
|
calendarDurationHours,
|
|
481
|
-
actualDurationSeconds: task.actualDurationSeconds
|
|
481
|
+
actualDurationSeconds: task.actualDurationSeconds,
|
|
482
|
+
blockedDurationSeconds: task.blockedDurationSeconds
|
|
482
483
|
};
|
|
483
484
|
|
|
484
485
|
const progress = inferTaskProgress(task, options);
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
2
2
|
import { TaskEntity } from './entities.js';
|
|
3
3
|
import { CriticalPathEngine } from '../engine/index.js';
|
|
4
4
|
import { InMemoryStore } from '../store/index.js';
|
|
5
|
+
import { SQLiteStore } from '../store/sqlite.js';
|
|
5
6
|
import { DEFAULT_SOFTWARE_WORKFLOW } from '../utils/workflow.js';
|
|
6
7
|
|
|
7
8
|
describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
|
|
@@ -167,4 +168,151 @@ describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
|
|
|
167
168
|
vi.useRealTimers();
|
|
168
169
|
}
|
|
169
170
|
});
|
|
171
|
+
|
|
172
|
+
it('tracks blocked duration while task is in_progress', async () => {
|
|
173
|
+
vi.useFakeTimers();
|
|
174
|
+
try {
|
|
175
|
+
const store = new InMemoryStore();
|
|
176
|
+
const engine = new CriticalPathEngine({ store });
|
|
177
|
+
|
|
178
|
+
const project = await engine.createProject({
|
|
179
|
+
name: 'Blocked Project'
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
|
|
183
|
+
const task = await engine.createTask({
|
|
184
|
+
projectId: project.id,
|
|
185
|
+
title: 'Blocked Tracking Feature'
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// 1. Being blocked in 'todo' does NOT accumulate blocked duration or stamp blockedSince
|
|
189
|
+
await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'Waiting for spec' });
|
|
190
|
+
const todoBlocked = await engine.getTask(task.id);
|
|
191
|
+
expect(todoBlocked?.isBlocked).toBe(true);
|
|
192
|
+
expect(todoBlocked?.blockedSince).toBeUndefined();
|
|
193
|
+
expect(todoBlocked?.blockedDurationSeconds).toBeUndefined();
|
|
194
|
+
|
|
195
|
+
// 2. Unblock while still in todo
|
|
196
|
+
await engine.updateTask(task.id, { isBlocked: false, blockedReason: null });
|
|
197
|
+
|
|
198
|
+
// 3. Start task at 10:00:00 (in_progress)
|
|
199
|
+
const started = await engine.updateTask(task.id, { status: 'in_progress' });
|
|
200
|
+
expect(started?.inProgressSince).toBe('2026-09-24T10:00:00.000Z');
|
|
201
|
+
expect(started?.blockedSince).toBeUndefined();
|
|
202
|
+
|
|
203
|
+
// 4. Block task at 10:00:30 (30 seconds in)
|
|
204
|
+
vi.setSystemTime(new Date('2026-09-24T10:00:30Z'));
|
|
205
|
+
const blocked = await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'API down' });
|
|
206
|
+
expect(blocked?.isBlocked).toBe(true);
|
|
207
|
+
expect(blocked?.blockedSince).toBe('2026-09-24T10:00:30.000Z');
|
|
208
|
+
|
|
209
|
+
// 5. Unblock task at 10:01:00 (30 seconds blocked)
|
|
210
|
+
vi.setSystemTime(new Date('2026-09-24T10:01:00Z'));
|
|
211
|
+
const unblocked = await engine.updateTask(task.id, { isBlocked: false, blockedReason: null });
|
|
212
|
+
expect(unblocked?.isBlocked).toBe(false);
|
|
213
|
+
expect(unblocked?.blockedSince).toBeNull();
|
|
214
|
+
expect(unblocked?.blockedDurationSeconds).toBe(30);
|
|
215
|
+
|
|
216
|
+
// 6. Block again at 10:01:10
|
|
217
|
+
vi.setSystemTime(new Date('2026-09-24T10:01:10Z'));
|
|
218
|
+
const blockedAgain = await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'Review pending' });
|
|
219
|
+
expect(blockedAgain?.blockedSince).toBe('2026-09-24T10:01:10.000Z');
|
|
220
|
+
|
|
221
|
+
// 7. Complete task at 10:01:24 while still blocked (14 seconds blocked)
|
|
222
|
+
// Total gross in-progress: 84s (1m 24s)
|
|
223
|
+
// Total blocked duration: 30s + 14s = 44s
|
|
224
|
+
vi.setSystemTime(new Date('2026-09-24T10:01:24Z'));
|
|
225
|
+
const completed = await engine.updateTask(task.id, { status: 'done' });
|
|
226
|
+
expect(completed?.status).toBe('done');
|
|
227
|
+
expect(completed?.actualDurationSeconds).toBe(84);
|
|
228
|
+
expect(completed?.blockedDurationSeconds).toBe(44);
|
|
229
|
+
expect(completed?.blockedSince).toBeNull();
|
|
230
|
+
|
|
231
|
+
// 8. Verify metrics
|
|
232
|
+
const metrics = await engine.getTaskMetrics(task.id);
|
|
233
|
+
expect(metrics?.inferredActuals.actualDurationSeconds).toBe(84);
|
|
234
|
+
expect(metrics?.inferredActuals.blockedDurationSeconds).toBe(44);
|
|
235
|
+
} finally {
|
|
236
|
+
vi.useRealTimers();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('persists and retrieves blocked duration fields in SQLiteStore', async () => {
|
|
241
|
+
const store = new SQLiteStore({ filename: ':memory:' });
|
|
242
|
+
const project = await store.createProject({ key: 'SQL', name: 'SQLite Project' });
|
|
243
|
+
|
|
244
|
+
const task = await store.createTask({
|
|
245
|
+
projectId: project.id,
|
|
246
|
+
title: 'SQLite Blocked Task',
|
|
247
|
+
status: 'in_progress',
|
|
248
|
+
priority: 'medium',
|
|
249
|
+
isBlocked: true,
|
|
250
|
+
blockedReason: 'Third party dependency',
|
|
251
|
+
blockedSince: '2026-09-24T12:00:00.000Z',
|
|
252
|
+
blockedDurationSeconds: 120
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const retrieved = await store.getTask(task.id);
|
|
256
|
+
expect(retrieved?.isBlocked).toBe(true);
|
|
257
|
+
expect(retrieved?.blockedReason).toBe('Third party dependency');
|
|
258
|
+
expect(retrieved?.blockedSince).toBe('2026-09-24T12:00:00.000Z');
|
|
259
|
+
expect(retrieved?.blockedDurationSeconds).toBe(120);
|
|
260
|
+
|
|
261
|
+
const updated = await store.updateTask(task.id, {
|
|
262
|
+
isBlocked: false,
|
|
263
|
+
blockedReason: null,
|
|
264
|
+
blockedSince: null,
|
|
265
|
+
blockedDurationSeconds: 180
|
|
266
|
+
});
|
|
267
|
+
expect(updated?.isBlocked).toBe(false);
|
|
268
|
+
expect(updated?.blockedReason).toBeFalsy();
|
|
269
|
+
expect(updated?.blockedSince).toBeFalsy();
|
|
270
|
+
expect(updated?.blockedDurationSeconds).toBe(180);
|
|
271
|
+
|
|
272
|
+
const reloaded = await store.getTask(task.id);
|
|
273
|
+
expect(reloaded?.isBlocked).toBe(false);
|
|
274
|
+
expect(reloaded?.blockedReason).toBeUndefined();
|
|
275
|
+
expect(reloaded?.blockedSince).toBeUndefined();
|
|
276
|
+
expect(reloaded?.blockedDurationSeconds).toBe(180);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('manages blocked state directly on TaskEntity transitions and updates', () => {
|
|
280
|
+
vi.useFakeTimers();
|
|
281
|
+
try {
|
|
282
|
+
vi.setSystemTime(new Date('2026-09-24T12:00:00Z'));
|
|
283
|
+
const task = TaskEntity.create({
|
|
284
|
+
projectId: 'proj_blk',
|
|
285
|
+
title: 'Entity Blocked Test',
|
|
286
|
+
status: 'in_progress'
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Block while in progress
|
|
290
|
+
vi.setSystemTime(new Date('2026-09-24T12:00:30Z'));
|
|
291
|
+
task.update({ isBlocked: true, blockedReason: 'Database migration' });
|
|
292
|
+
expect(task.isBlocked).toBe(true);
|
|
293
|
+
expect(task.blockedSince).toBe('2026-09-24T12:00:30.000Z');
|
|
294
|
+
|
|
295
|
+
// Unblock while in progress
|
|
296
|
+
vi.setSystemTime(new Date('2026-09-24T12:01:00Z'));
|
|
297
|
+
task.update({ isBlocked: false, blockedReason: null });
|
|
298
|
+
expect(task.isBlocked).toBe(false);
|
|
299
|
+
expect(task.blockedSince).toBeNull();
|
|
300
|
+
expect(task.blockedDurationSeconds).toBe(30);
|
|
301
|
+
|
|
302
|
+
// Block again
|
|
303
|
+
vi.setSystemTime(new Date('2026-09-24T12:01:10Z'));
|
|
304
|
+
task.update({ isBlocked: true, blockedReason: 'Code review' });
|
|
305
|
+
expect(task.blockedSince).toBe('2026-09-24T12:01:10.000Z');
|
|
306
|
+
|
|
307
|
+
// Transition to completed (done) while blocked
|
|
308
|
+
vi.setSystemTime(new Date('2026-09-24T12:01:30Z'));
|
|
309
|
+
task.transitionTo('done', DEFAULT_SOFTWARE_WORKFLOW);
|
|
310
|
+
expect(task.status).toBe('done');
|
|
311
|
+
expect(task.actualDurationSeconds).toBe(90);
|
|
312
|
+
expect(task.blockedDurationSeconds).toBe(50); // 30s + 20s
|
|
313
|
+
expect(task.blockedSince).toBeNull();
|
|
314
|
+
} finally {
|
|
315
|
+
vi.useRealTimers();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
170
318
|
});
|
package/src/engine/index.ts
CHANGED
|
@@ -374,6 +374,10 @@ export class CriticalPathEngine {
|
|
|
374
374
|
const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
375
375
|
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
|
|
376
376
|
|
|
377
|
+
const isInitialBlocked = processedInput.isBlocked ?? taskInput.isBlocked ?? false;
|
|
378
|
+
const blockedSince = processedInput.blockedSince ?? taskInput.blockedSince ?? (isInitialBlocked && statusDef.category === 'in_progress' ? now : undefined);
|
|
379
|
+
const blockedDurationSeconds = processedInput.blockedDurationSeconds ?? taskInput.blockedDurationSeconds;
|
|
380
|
+
|
|
377
381
|
const created = await this.store.createTask({
|
|
378
382
|
projectId,
|
|
379
383
|
title: processedInput.title || taskInput.title,
|
|
@@ -403,7 +407,11 @@ export class CriticalPathEngine {
|
|
|
403
407
|
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
404
408
|
actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
|
|
405
409
|
inProgressSince,
|
|
410
|
+
blockedDurationSeconds,
|
|
411
|
+
blockedSince,
|
|
406
412
|
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
|
|
413
|
+
isBlocked: isInitialBlocked,
|
|
414
|
+
blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
|
|
407
415
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
408
416
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
409
417
|
parentId: processedInput.parentId ?? taskInput.parentId
|
|
@@ -505,22 +513,25 @@ export class CriticalPathEngine {
|
|
|
505
513
|
|
|
506
514
|
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
|
|
507
515
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
+
const now = new Date().toISOString();
|
|
517
|
+
const existingStatusDef = resolveStatusDefinition(
|
|
518
|
+
existing.status,
|
|
519
|
+
project?.statusDefinitions || workflow?.statuses
|
|
520
|
+
);
|
|
521
|
+
const newStatusDef = processedUpdates.status
|
|
522
|
+
? resolveStatusDefinition(
|
|
523
|
+
processedUpdates.status,
|
|
524
|
+
project?.statusDefinitions || workflow?.statuses
|
|
525
|
+
)
|
|
526
|
+
: undefined;
|
|
527
|
+
const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
|
|
516
528
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
);
|
|
529
|
+
// Auto-update execution/completion timestamps if status changes
|
|
530
|
+
if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
|
|
531
|
+
processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? newStatusDef.category;
|
|
521
532
|
|
|
522
|
-
// 1. Leaving in_progress: finalize active session duration
|
|
523
|
-
if (existingStatusDef.category === 'in_progress' &&
|
|
533
|
+
// 1. Leaving in_progress: finalize active session duration and blocked duration
|
|
534
|
+
if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
|
|
524
535
|
const inProgressSince = existing.inProgressSince;
|
|
525
536
|
if (inProgressSince) {
|
|
526
537
|
const startMs = new Date(inProgressSince).getTime();
|
|
@@ -538,16 +549,34 @@ export class CriticalPathEngine {
|
|
|
538
549
|
}
|
|
539
550
|
processedUpdates.inProgressSince = null;
|
|
540
551
|
}
|
|
552
|
+
|
|
553
|
+
const blockedSince = processedUpdates.blockedSince ?? existing.blockedSince;
|
|
554
|
+
if (blockedSince) {
|
|
555
|
+
const startMs = new Date(blockedSince).getTime();
|
|
556
|
+
const endMs = new Date(now).getTime();
|
|
557
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
558
|
+
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
559
|
+
processedUpdates.blockedDurationSeconds =
|
|
560
|
+
(processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
|
|
561
|
+
}
|
|
562
|
+
processedUpdates.blockedSince = null;
|
|
563
|
+
}
|
|
541
564
|
}
|
|
542
565
|
|
|
543
|
-
// 2. Entering in_progress: retain earliest start date & start session timer
|
|
544
|
-
if (
|
|
566
|
+
// 2. Entering in_progress: retain earliest start date & start session timer (and blocked timer if blocked)
|
|
567
|
+
if (newStatusDef.category === 'in_progress') {
|
|
545
568
|
if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
546
569
|
processedUpdates.actualStartDate = now;
|
|
547
570
|
}
|
|
548
571
|
if (!processedUpdates.inProgressSince) {
|
|
549
572
|
processedUpdates.inProgressSince = now;
|
|
550
573
|
}
|
|
574
|
+
const isTaskBlocked = processedUpdates.isBlocked !== undefined
|
|
575
|
+
? Boolean(processedUpdates.isBlocked)
|
|
576
|
+
: Boolean(existing.isBlocked);
|
|
577
|
+
if (isTaskBlocked && !processedUpdates.blockedSince) {
|
|
578
|
+
processedUpdates.blockedSince = now;
|
|
579
|
+
}
|
|
551
580
|
if (
|
|
552
581
|
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
553
582
|
processedUpdates.actualEndDate === undefined
|
|
@@ -556,21 +585,49 @@ export class CriticalPathEngine {
|
|
|
556
585
|
}
|
|
557
586
|
} else if (
|
|
558
587
|
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
559
|
-
|
|
588
|
+
newStatusDef.category === 'not_started' &&
|
|
560
589
|
processedUpdates.actualEndDate === undefined
|
|
561
590
|
) {
|
|
562
591
|
processedUpdates.actualEndDate = undefined;
|
|
563
592
|
}
|
|
564
593
|
|
|
565
594
|
// 3. Entering completed or canceled
|
|
566
|
-
if ((
|
|
595
|
+
if ((newStatusDef.category === 'completed' || newStatusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
|
|
567
596
|
processedUpdates.actualEndDate = now;
|
|
568
|
-
if (
|
|
597
|
+
if (newStatusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
569
598
|
processedUpdates.progress = 100;
|
|
570
599
|
}
|
|
571
600
|
}
|
|
572
601
|
}
|
|
573
602
|
|
|
603
|
+
// Handle isBlocked transitions while in_progress (when in_progress, whether status changed or not)
|
|
604
|
+
if (effectiveCategory === 'in_progress') {
|
|
605
|
+
const wasBlocked = Boolean(existing.isBlocked);
|
|
606
|
+
const isNowBlocked = processedUpdates.isBlocked !== undefined
|
|
607
|
+
? Boolean(processedUpdates.isBlocked)
|
|
608
|
+
: wasBlocked;
|
|
609
|
+
|
|
610
|
+
if (!wasBlocked && isNowBlocked) {
|
|
611
|
+
if (!processedUpdates.blockedSince) {
|
|
612
|
+
processedUpdates.blockedSince = now;
|
|
613
|
+
}
|
|
614
|
+
} else if (wasBlocked && !isNowBlocked) {
|
|
615
|
+
const blockedSince = existing.blockedSince ?? processedUpdates.blockedSince;
|
|
616
|
+
if (blockedSince) {
|
|
617
|
+
const startMs = new Date(blockedSince).getTime();
|
|
618
|
+
const endMs = new Date(now).getTime();
|
|
619
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
620
|
+
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
621
|
+
processedUpdates.blockedDurationSeconds =
|
|
622
|
+
(processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
|
|
623
|
+
}
|
|
624
|
+
processedUpdates.blockedSince = null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
|
|
628
|
+
processedUpdates.blockedSince = null;
|
|
629
|
+
}
|
|
630
|
+
|
|
574
631
|
// Mutual sync for manual duration/effort edits
|
|
575
632
|
if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
|
|
576
633
|
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
@@ -589,7 +646,6 @@ export class CriticalPathEngine {
|
|
|
589
646
|
|
|
590
647
|
await this.plugins.runAfterTaskUpdate(updated, existing);
|
|
591
648
|
|
|
592
|
-
const now = new Date().toISOString();
|
|
593
649
|
const isStatusChange = existing.status !== updated.status;
|
|
594
650
|
|
|
595
651
|
// Publish typed Domain Events
|
|
@@ -636,11 +692,11 @@ export class CriticalPathEngine {
|
|
|
636
692
|
await this.events.publish(updateEvent);
|
|
637
693
|
}
|
|
638
694
|
|
|
639
|
-
const wasBlocked = Boolean(existing.isBlocked
|
|
640
|
-
const isNowBlocked = Boolean(updated.isBlocked
|
|
695
|
+
const wasBlocked = Boolean(existing.isBlocked);
|
|
696
|
+
const isNowBlocked = Boolean(updated.isBlocked);
|
|
641
697
|
|
|
642
698
|
if (!wasBlocked && isNowBlocked) {
|
|
643
|
-
const blockedReason = updated.blockedReason ??
|
|
699
|
+
const blockedReason = updated.blockedReason ?? null;
|
|
644
700
|
const blockedEvent: TaskBlockedEvent = {
|
|
645
701
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
646
702
|
name: 'task.blocked',
|
|
@@ -739,22 +795,18 @@ export class CriticalPathEngine {
|
|
|
739
795
|
let updatedDownstream = downstream;
|
|
740
796
|
const updates: Partial<Task> = {};
|
|
741
797
|
|
|
742
|
-
if (downstream.
|
|
743
|
-
const defaultStatus = downstreamWorkflow?.defaultStatusKey || 'todo';
|
|
744
|
-
updates.status = defaultStatus;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
if (downstream.isBlocked || downstream.blockedReason) {
|
|
798
|
+
if (downstream.isBlocked || downstream.blockedReason || downstream.blockedSince) {
|
|
748
799
|
updates.isBlocked = false;
|
|
749
800
|
updates.blockedReason = null;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
801
|
+
if (downstream.blockedSince) {
|
|
802
|
+
const startMs = new Date(downstream.blockedSince).getTime();
|
|
803
|
+
const endMs = new Date().getTime();
|
|
804
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
805
|
+
const blockedSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
806
|
+
updates.blockedDurationSeconds = (downstream.blockedDurationSeconds ?? 0) + blockedSeconds;
|
|
807
|
+
}
|
|
808
|
+
updates.blockedSince = null;
|
|
809
|
+
}
|
|
758
810
|
}
|
|
759
811
|
|
|
760
812
|
if (Object.keys(updates).length > 0) {
|
package/src/index.test.ts
CHANGED
|
@@ -386,8 +386,9 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
386
386
|
const downstream = await engine.createTask({
|
|
387
387
|
projectId: proj.id,
|
|
388
388
|
title: 'Downstream Task',
|
|
389
|
-
status: '
|
|
390
|
-
|
|
389
|
+
status: 'todo',
|
|
390
|
+
isBlocked: true,
|
|
391
|
+
blockedReason: 'Waiting on upstream'
|
|
391
392
|
});
|
|
392
393
|
|
|
393
394
|
await engine.addDependency({
|
|
@@ -412,7 +413,7 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
412
413
|
|
|
413
414
|
const refreshedDownstream = await engine.getTask(downstream.id);
|
|
414
415
|
expect(refreshedDownstream?.status).toBe('todo');
|
|
415
|
-
expect(refreshedDownstream?.
|
|
416
|
+
expect(refreshedDownstream?.isBlocked).toBe(false);
|
|
416
417
|
});
|
|
417
418
|
|
|
418
419
|
it('allows VFX workflow transitions from revision_requested to in_production', async () => {
|
package/src/store/sqlite.ts
CHANGED
|
@@ -133,6 +133,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
133
133
|
billableDurationMinutes REAL,
|
|
134
134
|
actualDurationSeconds REAL,
|
|
135
135
|
inProgressSince TEXT,
|
|
136
|
+
blockedDurationSeconds REAL,
|
|
137
|
+
blockedSince TEXT,
|
|
136
138
|
progress REAL,
|
|
137
139
|
isBlocked INTEGER,
|
|
138
140
|
blockedReason TEXT,
|
|
@@ -663,18 +665,18 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
663
665
|
reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
|
|
664
666
|
dueDate, estimatedHours, loggedHours, actualHours, billableHours,
|
|
665
667
|
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
|
|
666
|
-
actualDurationSeconds, inProgressSince, progress,
|
|
668
|
+
actualDurationSeconds, inProgressSince, blockedDurationSeconds, blockedSince, progress,
|
|
667
669
|
isBlocked, blockedReason,
|
|
668
670
|
tags, customFields, parentId, createdAt, updatedAt
|
|
669
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
671
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
670
672
|
`);
|
|
671
673
|
stmt.run(
|
|
672
674
|
newTask.id,
|
|
673
675
|
newTask.projectId,
|
|
674
676
|
newTask.title,
|
|
675
677
|
newTask.description || null,
|
|
676
|
-
newTask.status,
|
|
677
|
-
newTask.priority,
|
|
678
|
+
newTask.status || 'todo',
|
|
679
|
+
newTask.priority || 'medium',
|
|
678
680
|
newTask.taskType || null,
|
|
679
681
|
newTask.assigneeId || null,
|
|
680
682
|
newTask.assignees ? JSON.stringify(newTask.assignees) : null,
|
|
@@ -697,6 +699,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
697
699
|
newTask.billableDurationMinutes ?? null,
|
|
698
700
|
newTask.actualDurationSeconds ?? null,
|
|
699
701
|
newTask.inProgressSince ?? null,
|
|
702
|
+
newTask.blockedDurationSeconds ?? null,
|
|
703
|
+
newTask.blockedSince ?? null,
|
|
700
704
|
newTask.progress ?? null,
|
|
701
705
|
newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
|
|
702
706
|
newTask.blockedReason ?? null,
|
|
@@ -726,7 +730,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
726
730
|
plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
|
|
727
731
|
estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
|
|
728
732
|
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
|
|
729
|
-
actualDurationSeconds = ?, inProgressSince = ?, progress = ?,
|
|
733
|
+
actualDurationSeconds = ?, inProgressSince = ?, blockedDurationSeconds = ?, blockedSince = ?, progress = ?,
|
|
730
734
|
isBlocked = ?, blockedReason = ?,
|
|
731
735
|
tags = ?, customFields = ?, parentId = ?, updatedAt = ?
|
|
732
736
|
WHERE id = ?
|
|
@@ -759,6 +763,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
759
763
|
updated.billableDurationMinutes ?? null,
|
|
760
764
|
updated.actualDurationSeconds ?? null,
|
|
761
765
|
updated.inProgressSince ?? null,
|
|
766
|
+
updated.blockedDurationSeconds ?? null,
|
|
767
|
+
updated.blockedSince ?? null,
|
|
762
768
|
updated.progress ?? null,
|
|
763
769
|
updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
|
|
764
770
|
updated.blockedReason ?? null,
|
|
@@ -1204,6 +1210,10 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1204
1210
|
private mapTask(row: any): Task {
|
|
1205
1211
|
return {
|
|
1206
1212
|
...row,
|
|
1213
|
+
actualDurationSeconds: row.actualDurationSeconds ?? undefined,
|
|
1214
|
+
inProgressSince: row.inProgressSince || undefined,
|
|
1215
|
+
blockedDurationSeconds: row.blockedDurationSeconds ?? undefined,
|
|
1216
|
+
blockedSince: row.blockedSince || undefined,
|
|
1207
1217
|
isBlocked: row.isBlocked !== null && row.isBlocked !== undefined ? Boolean(row.isBlocked) : undefined,
|
|
1208
1218
|
blockedReason: row.blockedReason || undefined,
|
|
1209
1219
|
taskType: row.taskType || undefined,
|
package/src/task-state.test.ts
CHANGED
|
@@ -353,12 +353,13 @@ describe('Task State Behaviour & Dependency Validation', () => {
|
|
|
353
353
|
status: 'todo'
|
|
354
354
|
});
|
|
355
355
|
|
|
356
|
-
// Create downstream task with
|
|
356
|
+
// Create downstream task with isBlocked = true
|
|
357
357
|
const taskDownstream = await engine.createTask({
|
|
358
358
|
projectId: project.id,
|
|
359
359
|
title: 'Run Integration Tests',
|
|
360
|
-
status: '
|
|
361
|
-
|
|
360
|
+
status: 'todo',
|
|
361
|
+
isBlocked: true,
|
|
362
|
+
blockedReason: 'Waiting on compilation'
|
|
362
363
|
});
|
|
363
364
|
|
|
364
365
|
await engine.addDependency({
|
|
@@ -382,11 +383,11 @@ describe('Task State Behaviour & Dependency Validation', () => {
|
|
|
382
383
|
expect(unblockedEvents[0].aggregateId).toBe(taskDownstream.id);
|
|
383
384
|
expect(unblockedEvents[0].payload.upstreamTaskId).toBe(taskUpstream.id);
|
|
384
385
|
|
|
385
|
-
// Verify downstream task
|
|
386
|
+
// Verify downstream task is unblocked
|
|
386
387
|
const updatedDownstream = await engine.getTask(taskDownstream.id);
|
|
387
388
|
expect(updatedDownstream?.status).toBe('todo');
|
|
388
|
-
expect(updatedDownstream?.
|
|
389
|
-
expect(updatedDownstream?.
|
|
389
|
+
expect(updatedDownstream?.isBlocked).toBe(false);
|
|
390
|
+
expect(updatedDownstream?.blockedReason).toBeNull();
|
|
390
391
|
|
|
391
392
|
// Verify lifecycle state is ready and unblocked
|
|
392
393
|
const lifecycle = await engine.getTaskLifecycleState(taskDownstream.id);
|
|
@@ -404,8 +405,8 @@ describe('Task State Behaviour & Dependency Validation', () => {
|
|
|
404
405
|
const downstream = await engine.createTask({
|
|
405
406
|
projectId: project.id,
|
|
406
407
|
title: 'Final Assembly',
|
|
407
|
-
status: '
|
|
408
|
-
|
|
408
|
+
status: 'todo',
|
|
409
|
+
isBlocked: true
|
|
409
410
|
});
|
|
410
411
|
|
|
411
412
|
await engine.addDependency({ taskId: downstream.id, dependsOnTaskId: up1.id, type: 'blocking' });
|
|
@@ -423,8 +424,7 @@ describe('Task State Behaviour & Dependency Validation', () => {
|
|
|
423
424
|
expect(unblockedEvents).toHaveLength(0);
|
|
424
425
|
|
|
425
426
|
let taskState = await engine.getTask(downstream.id);
|
|
426
|
-
expect(taskState?.
|
|
427
|
-
expect(taskState?.customFields?.isBlocked).toBe(true);
|
|
427
|
+
expect(taskState?.isBlocked).toBe(true);
|
|
428
428
|
|
|
429
429
|
let lifecycle = await engine.getTaskLifecycleState(downstream.id);
|
|
430
430
|
expect(lifecycle?.isBlocked).toBe(true);
|
|
@@ -437,7 +437,7 @@ describe('Task State Behaviour & Dependency Validation', () => {
|
|
|
437
437
|
expect(unblockedEvents).toHaveLength(1);
|
|
438
438
|
taskState = await engine.getTask(downstream.id);
|
|
439
439
|
expect(taskState?.status).toBe('todo');
|
|
440
|
-
expect(taskState?.
|
|
440
|
+
expect(taskState?.isBlocked).toBe(false);
|
|
441
441
|
|
|
442
442
|
lifecycle = await engine.getTaskLifecycleState(downstream.id);
|
|
443
443
|
expect(lifecycle?.isBlocked).toBe(false);
|
package/src/types/index.ts
CHANGED
|
@@ -218,6 +218,10 @@ export interface Task {
|
|
|
218
218
|
actualDurationSeconds?: number;
|
|
219
219
|
/** Transient ISO timestamp marking the start of current 'in_progress' session (null when not in_progress) */
|
|
220
220
|
inProgressSince?: string | null;
|
|
221
|
+
/** Cumulative duration spent in blocked state while in progress (in seconds) */
|
|
222
|
+
blockedDurationSeconds?: number;
|
|
223
|
+
/** Transient ISO timestamp marking when the task became blocked while in progress (null when not blocked or not in_progress) */
|
|
224
|
+
blockedSince?: string | null;
|
|
221
225
|
// Progress (0 to 100 percentage)
|
|
222
226
|
progress?: number;
|
|
223
227
|
isBlocked?: boolean;
|
|
@@ -634,6 +638,8 @@ export interface TaskInferredActuals {
|
|
|
634
638
|
calendarDurationHours?: number;
|
|
635
639
|
/** Cumulative active execution duration in seconds */
|
|
636
640
|
actualDurationSeconds?: number;
|
|
641
|
+
/** Cumulative duration spent in blocked state while in progress (in seconds) */
|
|
642
|
+
blockedDurationSeconds?: number;
|
|
637
643
|
}
|
|
638
644
|
|
|
639
645
|
export interface TaskMetrics {
|