@critical-path/core 0.20.1 → 0.21.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 +20 -0
- package/README.md +41 -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 +161 -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 +113 -21
- package/dist/engine/index.js.map +1 -1
- package/dist/status.test.js +97 -1
- package/dist/status.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +22 -7
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/fractional-index.d.ts +50 -0
- package/dist/utils/fractional-index.d.ts.map +1 -0
- package/dist/utils/fractional-index.js +230 -0
- package/dist/utils/fractional-index.js.map +1 -0
- package/dist/utils/fractional-index.test.d.ts +2 -0
- package/dist/utils/fractional-index.test.d.ts.map +1 -0
- package/dist/utils/fractional-index.test.js +151 -0
- package/dist/utils/fractional-index.test.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/key.d.ts +5 -0
- package/dist/utils/key.d.ts.map +1 -1
- package/dist/utils/key.js +10 -0
- package/dist/utils/key.js.map +1 -1
- package/dist/utils/key.test.js +18 -1
- package/dist/utils/key.test.js.map +1 -1
- package/dist/utils/mentions.d.ts +13 -5
- package/dist/utils/mentions.d.ts.map +1 -1
- package/dist/utils/mentions.js +52 -16
- package/dist/utils/mentions.js.map +1 -1
- package/dist/utils/mentions.test.js +32 -1
- package/dist/utils/mentions.test.js.map +1 -1
- package/dist/utils/status.d.ts +80 -1
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +194 -0
- 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 +184 -0
- package/src/engine/index.ts +121 -28
- package/src/status.test.ts +130 -2
- package/src/store/sqlite.ts +28 -7
- package/src/types/index.ts +7 -0
- package/src/utils/fractional-index.test.ts +191 -0
- package/src/utils/fractional-index.ts +260 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/key.test.ts +22 -1
- package/src/utils/key.ts +11 -0
- package/src/utils/mentions.test.ts +37 -1
- package/src/utils/mentions.ts +58 -16
- package/src/utils/status.ts +247 -0
- package/tsconfig.tsbuildinfo +1 -1
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,187 @@ 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
|
+
});
|
|
318
|
+
|
|
319
|
+
it('sets completedAt and actualEndDate on completion and resets progress when reopening', async () => {
|
|
320
|
+
vi.useFakeTimers();
|
|
321
|
+
try {
|
|
322
|
+
const store = new InMemoryStore();
|
|
323
|
+
const engine = new CriticalPathEngine({ store });
|
|
324
|
+
|
|
325
|
+
const project = await engine.createProject({ name: 'Progress Transition Proj' });
|
|
326
|
+
vi.setSystemTime(new Date('2026-10-01T10:00:00Z'));
|
|
327
|
+
const task = await engine.createTask({
|
|
328
|
+
projectId: project.id,
|
|
329
|
+
title: 'Task for completion test',
|
|
330
|
+
status: 'in_progress',
|
|
331
|
+
progress: 50
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Complete the task
|
|
335
|
+
vi.setSystemTime(new Date('2026-10-01T12:00:00Z'));
|
|
336
|
+
const completed = await engine.updateTask(task.id, { status: 'done' });
|
|
337
|
+
expect(completed?.status).toBe('done');
|
|
338
|
+
expect(completed?.actualEndDate).toBe('2026-10-01T12:00:00.000Z');
|
|
339
|
+
expect(completed?.completedAt).toBe('2026-10-01T12:00:00.000Z');
|
|
340
|
+
expect(completed?.progress).toBe(100);
|
|
341
|
+
|
|
342
|
+
// Reopen to todo -> actualEndDate & completedAt cleared, progress reset from 100 to 0
|
|
343
|
+
vi.setSystemTime(new Date('2026-10-01T13:00:00Z'));
|
|
344
|
+
const reopened = await engine.updateTask(task.id, { status: 'todo' });
|
|
345
|
+
expect(reopened?.status).toBe('todo');
|
|
346
|
+
expect(reopened?.actualEndDate).toBeUndefined();
|
|
347
|
+
expect(reopened?.completedAt).toBeUndefined();
|
|
348
|
+
expect(reopened?.progress).toBe(0);
|
|
349
|
+
} finally {
|
|
350
|
+
vi.useRealTimers();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
170
353
|
});
|
|
354
|
+
|
package/src/engine/index.ts
CHANGED
|
@@ -373,6 +373,11 @@ export class CriticalPathEngine {
|
|
|
373
373
|
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
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
|
+
const completedAt = processedInput.completedAt ?? taskInput.completedAt ?? (statusDef.category === 'completed' ? now : undefined);
|
|
377
|
+
|
|
378
|
+
const isInitialBlocked = processedInput.isBlocked ?? taskInput.isBlocked ?? false;
|
|
379
|
+
const blockedSince = processedInput.blockedSince ?? taskInput.blockedSince ?? (isInitialBlocked && statusDef.category === 'in_progress' ? now : undefined);
|
|
380
|
+
const blockedDurationSeconds = processedInput.blockedDurationSeconds ?? taskInput.blockedDurationSeconds;
|
|
376
381
|
|
|
377
382
|
const created = await this.store.createTask({
|
|
378
383
|
projectId,
|
|
@@ -393,6 +398,7 @@ export class CriticalPathEngine {
|
|
|
393
398
|
plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
|
|
394
399
|
actualStartDate,
|
|
395
400
|
actualEndDate,
|
|
401
|
+
completedAt,
|
|
396
402
|
dueDate: processedInput.dueDate ?? taskInput.dueDate,
|
|
397
403
|
estimatedHours: processedInput.estimatedHours ?? taskInput.estimatedHours,
|
|
398
404
|
loggedHours: processedInput.loggedHours ?? taskInput.loggedHours ?? 0,
|
|
@@ -403,8 +409,10 @@ export class CriticalPathEngine {
|
|
|
403
409
|
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
404
410
|
actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
|
|
405
411
|
inProgressSince,
|
|
412
|
+
blockedDurationSeconds,
|
|
413
|
+
blockedSince,
|
|
406
414
|
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
|
|
407
|
-
isBlocked:
|
|
415
|
+
isBlocked: isInitialBlocked,
|
|
408
416
|
blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
|
|
409
417
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
410
418
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
@@ -507,22 +515,25 @@ export class CriticalPathEngine {
|
|
|
507
515
|
|
|
508
516
|
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
|
|
509
517
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
+
const now = new Date().toISOString();
|
|
519
|
+
const existingStatusDef = resolveStatusDefinition(
|
|
520
|
+
existing.status,
|
|
521
|
+
project?.statusDefinitions || workflow?.statuses
|
|
522
|
+
);
|
|
523
|
+
const newStatusDef = processedUpdates.status
|
|
524
|
+
? resolveStatusDefinition(
|
|
525
|
+
processedUpdates.status,
|
|
526
|
+
project?.statusDefinitions || workflow?.statuses
|
|
527
|
+
)
|
|
528
|
+
: undefined;
|
|
529
|
+
const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
|
|
518
530
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
);
|
|
531
|
+
// Auto-update execution/completion timestamps if status changes
|
|
532
|
+
if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
|
|
533
|
+
processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? newStatusDef.category;
|
|
523
534
|
|
|
524
|
-
// 1. Leaving in_progress: finalize active session duration
|
|
525
|
-
if (existingStatusDef.category === 'in_progress' &&
|
|
535
|
+
// 1. Leaving in_progress: finalize active session duration and blocked duration
|
|
536
|
+
if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
|
|
526
537
|
const inProgressSince = existing.inProgressSince;
|
|
527
538
|
if (inProgressSince) {
|
|
528
539
|
const startMs = new Date(inProgressSince).getTime();
|
|
@@ -540,39 +551,113 @@ export class CriticalPathEngine {
|
|
|
540
551
|
}
|
|
541
552
|
processedUpdates.inProgressSince = null;
|
|
542
553
|
}
|
|
554
|
+
|
|
555
|
+
const blockedSince = processedUpdates.blockedSince ?? existing.blockedSince;
|
|
556
|
+
if (blockedSince) {
|
|
557
|
+
const startMs = new Date(blockedSince).getTime();
|
|
558
|
+
const endMs = new Date(now).getTime();
|
|
559
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
560
|
+
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
561
|
+
processedUpdates.blockedDurationSeconds =
|
|
562
|
+
(processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
|
|
563
|
+
}
|
|
564
|
+
processedUpdates.blockedSince = null;
|
|
565
|
+
}
|
|
543
566
|
}
|
|
544
567
|
|
|
545
|
-
// 2. Entering in_progress: retain earliest start date & start session timer
|
|
546
|
-
if (
|
|
568
|
+
// 2. Entering in_progress: retain earliest start date & start session timer (and blocked timer if blocked)
|
|
569
|
+
if (newStatusDef.category === 'in_progress') {
|
|
547
570
|
if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
548
571
|
processedUpdates.actualStartDate = now;
|
|
549
572
|
}
|
|
550
573
|
if (!processedUpdates.inProgressSince) {
|
|
551
574
|
processedUpdates.inProgressSince = now;
|
|
552
575
|
}
|
|
576
|
+
const isTaskBlocked = processedUpdates.isBlocked !== undefined
|
|
577
|
+
? Boolean(processedUpdates.isBlocked)
|
|
578
|
+
: Boolean(existing.isBlocked);
|
|
579
|
+
if (isTaskBlocked && !processedUpdates.blockedSince) {
|
|
580
|
+
processedUpdates.blockedSince = now;
|
|
581
|
+
}
|
|
582
|
+
// Moving from completed/canceled to in_progress resets completion timestamp and progress
|
|
553
583
|
if (
|
|
554
|
-
|
|
555
|
-
processedUpdates.actualEndDate === undefined
|
|
584
|
+
existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled'
|
|
556
585
|
) {
|
|
557
|
-
processedUpdates.actualEndDate
|
|
586
|
+
if (processedUpdates.actualEndDate === undefined) {
|
|
587
|
+
processedUpdates.actualEndDate = undefined;
|
|
588
|
+
}
|
|
589
|
+
if (processedUpdates.completedAt === undefined) {
|
|
590
|
+
processedUpdates.completedAt = undefined;
|
|
591
|
+
}
|
|
592
|
+
if (existing.progress === 100 && processedUpdates.progress === undefined) {
|
|
593
|
+
processedUpdates.progress = 0;
|
|
594
|
+
}
|
|
558
595
|
}
|
|
559
596
|
} else if (
|
|
560
597
|
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
561
|
-
|
|
562
|
-
processedUpdates.actualEndDate === undefined
|
|
598
|
+
newStatusDef.category === 'not_started'
|
|
563
599
|
) {
|
|
564
|
-
|
|
600
|
+
// Moving from completed/canceled to not_started resets completion timestamp and progress
|
|
601
|
+
if (processedUpdates.actualEndDate === undefined) {
|
|
602
|
+
processedUpdates.actualEndDate = undefined;
|
|
603
|
+
}
|
|
604
|
+
if (processedUpdates.completedAt === undefined) {
|
|
605
|
+
processedUpdates.completedAt = undefined;
|
|
606
|
+
}
|
|
607
|
+
if (existing.progress === 100 && processedUpdates.progress === undefined) {
|
|
608
|
+
processedUpdates.progress = 0;
|
|
609
|
+
}
|
|
565
610
|
}
|
|
566
611
|
|
|
567
612
|
// 3. Entering completed or canceled
|
|
568
|
-
if (
|
|
569
|
-
processedUpdates.actualEndDate
|
|
570
|
-
|
|
613
|
+
if (newStatusDef.category === 'completed') {
|
|
614
|
+
if (!processedUpdates.actualEndDate) {
|
|
615
|
+
processedUpdates.actualEndDate = now;
|
|
616
|
+
}
|
|
617
|
+
if (!processedUpdates.completedAt) {
|
|
618
|
+
processedUpdates.completedAt = now;
|
|
619
|
+
}
|
|
620
|
+
if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
571
621
|
processedUpdates.progress = 100;
|
|
572
622
|
}
|
|
623
|
+
} else if (newStatusDef.category === 'canceled') {
|
|
624
|
+
if (!processedUpdates.actualEndDate) {
|
|
625
|
+
processedUpdates.actualEndDate = now;
|
|
626
|
+
}
|
|
627
|
+
if (processedUpdates.completedAt === undefined) {
|
|
628
|
+
processedUpdates.completedAt = undefined;
|
|
629
|
+
}
|
|
573
630
|
}
|
|
574
631
|
}
|
|
575
632
|
|
|
633
|
+
// Handle isBlocked transitions while in_progress (when in_progress, whether status changed or not)
|
|
634
|
+
if (effectiveCategory === 'in_progress') {
|
|
635
|
+
const wasBlocked = Boolean(existing.isBlocked);
|
|
636
|
+
const isNowBlocked = processedUpdates.isBlocked !== undefined
|
|
637
|
+
? Boolean(processedUpdates.isBlocked)
|
|
638
|
+
: wasBlocked;
|
|
639
|
+
|
|
640
|
+
if (!wasBlocked && isNowBlocked) {
|
|
641
|
+
if (!processedUpdates.blockedSince) {
|
|
642
|
+
processedUpdates.blockedSince = now;
|
|
643
|
+
}
|
|
644
|
+
} else if (wasBlocked && !isNowBlocked) {
|
|
645
|
+
const blockedSince = existing.blockedSince ?? processedUpdates.blockedSince;
|
|
646
|
+
if (blockedSince) {
|
|
647
|
+
const startMs = new Date(blockedSince).getTime();
|
|
648
|
+
const endMs = new Date(now).getTime();
|
|
649
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
650
|
+
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
651
|
+
processedUpdates.blockedDurationSeconds =
|
|
652
|
+
(processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
|
|
653
|
+
}
|
|
654
|
+
processedUpdates.blockedSince = null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
} else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
|
|
658
|
+
processedUpdates.blockedSince = null;
|
|
659
|
+
}
|
|
660
|
+
|
|
576
661
|
// Mutual sync for manual duration/effort edits
|
|
577
662
|
if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
|
|
578
663
|
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
@@ -591,7 +676,6 @@ export class CriticalPathEngine {
|
|
|
591
676
|
|
|
592
677
|
await this.plugins.runAfterTaskUpdate(updated, existing);
|
|
593
678
|
|
|
594
|
-
const now = new Date().toISOString();
|
|
595
679
|
const isStatusChange = existing.status !== updated.status;
|
|
596
680
|
|
|
597
681
|
// Publish typed Domain Events
|
|
@@ -741,9 +825,18 @@ export class CriticalPathEngine {
|
|
|
741
825
|
let updatedDownstream = downstream;
|
|
742
826
|
const updates: Partial<Task> = {};
|
|
743
827
|
|
|
744
|
-
if (downstream.isBlocked || downstream.blockedReason) {
|
|
828
|
+
if (downstream.isBlocked || downstream.blockedReason || downstream.blockedSince) {
|
|
745
829
|
updates.isBlocked = false;
|
|
746
830
|
updates.blockedReason = null;
|
|
831
|
+
if (downstream.blockedSince) {
|
|
832
|
+
const startMs = new Date(downstream.blockedSince).getTime();
|
|
833
|
+
const endMs = new Date().getTime();
|
|
834
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
835
|
+
const blockedSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
836
|
+
updates.blockedDurationSeconds = (downstream.blockedDurationSeconds ?? 0) + blockedSeconds;
|
|
837
|
+
}
|
|
838
|
+
updates.blockedSince = null;
|
|
839
|
+
}
|
|
747
840
|
}
|
|
748
841
|
|
|
749
842
|
if (Object.keys(updates).length > 0) {
|
package/src/status.test.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { CriticalPathEngine } from './engine/index.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
resolveStatusDefinition,
|
|
5
|
+
deriveTaskLifecycleState,
|
|
6
|
+
isDraftTask,
|
|
7
|
+
isArchivedTask,
|
|
8
|
+
isTrashedTask,
|
|
9
|
+
isTrashedOrArchivedTask,
|
|
10
|
+
isWorkflowTask,
|
|
11
|
+
getTaskSemanticStatus,
|
|
12
|
+
isTaskCompleted,
|
|
13
|
+
isTaskInProgress,
|
|
14
|
+
isTaskNotStarted,
|
|
15
|
+
isTaskCanceled,
|
|
16
|
+
isTaskActive,
|
|
17
|
+
isTaskUnassigned
|
|
18
|
+
} from './utils/status.js';
|
|
19
|
+
import type { Task, StatusDefinition, Workflow } from './types/index.js';
|
|
5
20
|
|
|
6
21
|
describe('Universal Semantic Statuses & Implied Statuses', () => {
|
|
7
22
|
describe('resolveStatusDefinition', () => {
|
|
@@ -223,4 +238,117 @@ describe('Universal Semantic Statuses & Implied Statuses', () => {
|
|
|
223
238
|
expect(stateB?.blockingTaskIds).toEqual([]);
|
|
224
239
|
});
|
|
225
240
|
});
|
|
241
|
+
|
|
242
|
+
describe('Canonical Task Lifecycle & Semantic Status Predicates', () => {
|
|
243
|
+
it('identifies draft, archived, and trashed tasks properly', () => {
|
|
244
|
+
expect(isDraftTask(null)).toBe(false);
|
|
245
|
+
expect(isDraftTask({ isDraft: true })).toBe(true);
|
|
246
|
+
expect(isDraftTask({ isDraft: false })).toBe(false);
|
|
247
|
+
|
|
248
|
+
expect(isArchivedTask(null)).toBe(false);
|
|
249
|
+
expect(isArchivedTask({ archived: true })).toBe(true);
|
|
250
|
+
expect(isArchivedTask({ archivedAt: '2026-09-01T00:00:00Z' })).toBe(true);
|
|
251
|
+
expect(isArchivedTask({ status: 'archived' })).toBe(true);
|
|
252
|
+
expect(isArchivedTask({ customFields: { isArchived: true } })).toBe(true);
|
|
253
|
+
expect(isArchivedTask({ status: 'todo' })).toBe(false);
|
|
254
|
+
|
|
255
|
+
expect(isTrashedTask(null)).toBe(false);
|
|
256
|
+
expect(isTrashedTask({ trashed: true })).toBe(true);
|
|
257
|
+
expect(isTrashedTask({ trashedAt: '2026-09-01T00:00:00Z' })).toBe(true);
|
|
258
|
+
expect(isTrashedTask({ status: 'trashed' })).toBe(true);
|
|
259
|
+
expect(isTrashedTask({ customFields: { isTrashed: true } })).toBe(true);
|
|
260
|
+
expect(isTrashedTask({ status: 'todo' })).toBe(false);
|
|
261
|
+
|
|
262
|
+
expect(isTrashedOrArchivedTask({ trashed: true })).toBe(true);
|
|
263
|
+
expect(isTrashedOrArchivedTask({ archived: true })).toBe(true);
|
|
264
|
+
expect(isTrashedOrArchivedTask({ status: 'todo' })).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('identifies workflow tasks excluding draft, trashed, and archived tasks (UCH-137)', () => {
|
|
268
|
+
expect(isWorkflowTask(null)).toBe(false);
|
|
269
|
+
expect(isWorkflowTask({ status: 'todo' })).toBe(true);
|
|
270
|
+
expect(isWorkflowTask({ status: 'in_progress' })).toBe(true);
|
|
271
|
+
expect(isWorkflowTask({ status: 'done' })).toBe(true);
|
|
272
|
+
|
|
273
|
+
// Draft tasks are excluded
|
|
274
|
+
expect(isWorkflowTask({ status: 'todo', isDraft: true })).toBe(false);
|
|
275
|
+
// Trashed tasks are excluded
|
|
276
|
+
expect(isWorkflowTask({ status: 'todo', trashed: true })).toBe(false);
|
|
277
|
+
// Archived tasks are excluded
|
|
278
|
+
expect(isWorkflowTask({ status: 'done', archived: true })).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('resolves semantic status using workflow, defaults, and custom definitions', () => {
|
|
282
|
+
const mockWorkflow: Workflow = {
|
|
283
|
+
id: 'wf_custom',
|
|
284
|
+
name: 'Custom Flow',
|
|
285
|
+
isDefault: true,
|
|
286
|
+
statuses: [
|
|
287
|
+
{ key: 'discovery', label: 'Discovery', category: 'not_started' },
|
|
288
|
+
{ key: 'developing', label: 'Developing', category: 'in_progress' },
|
|
289
|
+
{ key: 'delivered', label: 'Delivered', category: 'completed' },
|
|
290
|
+
{ key: 'dropped', label: 'Dropped', category: 'canceled' }
|
|
291
|
+
],
|
|
292
|
+
transitions: [],
|
|
293
|
+
createdAt: '2026-09-01T00:00:00Z',
|
|
294
|
+
updatedAt: '2026-09-01T00:00:00Z'
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
expect(getTaskSemanticStatus({ status: 'developing' }, mockWorkflow)).toBe('in_progress');
|
|
298
|
+
expect(getTaskSemanticStatus({ status: 'delivered' }, mockWorkflow)).toBe('completed');
|
|
299
|
+
expect(getTaskSemanticStatus({ status: 'dropped' }, mockWorkflow)).toBe('canceled');
|
|
300
|
+
expect(getTaskSemanticStatus({ status: 'discovery' }, mockWorkflow)).toBe('not_started');
|
|
301
|
+
|
|
302
|
+
// Falls back to standard defaults when not in custom workflow
|
|
303
|
+
expect(getTaskSemanticStatus({ status: 'in_progress' }, mockWorkflow)).toBe('in_progress');
|
|
304
|
+
expect(getTaskSemanticStatus({ status: 'done' })).toBe('completed');
|
|
305
|
+
expect(getTaskSemanticStatus({ status: 'todo' })).toBe('not_started');
|
|
306
|
+
expect(getTaskSemanticStatus({ status: 'canceled' })).toBe('canceled');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('verifies lifecycle predicates (completed, in_progress, not_started, canceled, active)', () => {
|
|
310
|
+
const taskDone = { status: 'done' };
|
|
311
|
+
const taskActive = { status: 'in_progress' };
|
|
312
|
+
const taskTodo = { status: 'todo' };
|
|
313
|
+
const taskCanceled = { status: 'canceled' };
|
|
314
|
+
|
|
315
|
+
expect(isTaskCompleted(taskDone)).toBe(true);
|
|
316
|
+
expect(isTaskCompleted(taskActive)).toBe(false);
|
|
317
|
+
|
|
318
|
+
expect(isTaskInProgress(taskActive)).toBe(true);
|
|
319
|
+
expect(isTaskInProgress(taskDone)).toBe(false);
|
|
320
|
+
|
|
321
|
+
expect(isTaskNotStarted(taskTodo)).toBe(true);
|
|
322
|
+
expect(isTaskNotStarted(taskActive)).toBe(false);
|
|
323
|
+
|
|
324
|
+
expect(isTaskCanceled(taskCanceled)).toBe(true);
|
|
325
|
+
expect(isTaskCanceled(taskDone)).toBe(false);
|
|
326
|
+
|
|
327
|
+
// Active tasks
|
|
328
|
+
expect(isTaskActive(taskTodo)).toBe(true);
|
|
329
|
+
expect(isTaskActive(taskActive)).toBe(true);
|
|
330
|
+
expect(isTaskActive(taskDone)).toBe(false);
|
|
331
|
+
expect(isTaskActive(taskCanceled)).toBe(false);
|
|
332
|
+
expect(isTaskActive({ ...taskActive, isDraft: true })).toBe(false);
|
|
333
|
+
expect(isTaskActive({ ...taskActive, archived: true })).toBe(false);
|
|
334
|
+
expect(isTaskActive({ ...taskActive, trashed: true })).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('identifies unassigned tasks properly', () => {
|
|
338
|
+
expect(isTaskUnassigned(null)).toBe(true);
|
|
339
|
+
expect(isTaskUnassigned({})).toBe(true);
|
|
340
|
+
expect(isTaskUnassigned({ assigneeId: undefined })).toBe(true);
|
|
341
|
+
expect(isTaskUnassigned({ assigneeId: '' })).toBe(true);
|
|
342
|
+
expect(isTaskUnassigned({ assigneeId: 'unassigned' })).toBe(true);
|
|
343
|
+
expect(isTaskUnassigned({ assigneeId: ' UNASSIGNED ' })).toBe(true);
|
|
344
|
+
expect(isTaskUnassigned({ assigneeId: 'user_123' })).toBe(false);
|
|
345
|
+
|
|
346
|
+
// Assignees array checks
|
|
347
|
+
expect(isTaskUnassigned({ assignees: [] })).toBe(true);
|
|
348
|
+
expect(isTaskUnassigned({ assignees: ['unassigned'] })).toBe(true);
|
|
349
|
+
expect(isTaskUnassigned({ assignees: [{ id: 'unassigned', name: 'unassigned' }] })).toBe(true);
|
|
350
|
+
expect(isTaskUnassigned({ assignees: ['user_456'] })).toBe(false);
|
|
351
|
+
expect(isTaskUnassigned({ assignees: [{ id: 'user_456', name: 'Alice' }] })).toBe(false);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
226
354
|
});
|