@critical-path/core 0.19.1 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/domain/calendar.d.ts +13 -0
- package/dist/domain/calendar.d.ts.map +1 -1
- package/dist/domain/calendar.js +29 -0
- package/dist/domain/calendar.js.map +1 -1
- package/dist/domain/calendar.test.js +46 -1
- package/dist/domain/calendar.test.js.map +1 -1
- package/dist/domain/entities.d.ts +2 -0
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +55 -7
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/metrics.d.ts.map +1 -1
- package/dist/domain/metrics.js +14 -2
- package/dist/domain/metrics.js.map +1 -1
- package/dist/domain/task-duration.test.d.ts +2 -0
- package/dist/domain/task-duration.test.d.ts.map +1 -0
- package/dist/domain/task-duration.test.js +146 -0
- package/dist/domain/task-duration.test.js.map +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +57 -19
- 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 +9 -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 +8 -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/calendar.test.ts +53 -1
- package/src/domain/calendar.ts +45 -0
- package/src/domain/entities.ts +57 -7
- package/src/domain/metrics.ts +14 -2
- package/src/domain/task-duration.test.ts +170 -0
- package/src/engine/index.ts +60 -21
- package/src/index.test.ts +4 -3
- package/src/store/sqlite.ts +11 -3
- package/src/task-state.test.ts +11 -11
- package/src/types/index.ts +8 -0
- package/src/utils/status.ts +1 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { TaskEntity } from './entities.js';
|
|
3
|
+
import { CriticalPathEngine } from '../engine/index.js';
|
|
4
|
+
import { InMemoryStore } from '../store/index.js';
|
|
5
|
+
import { DEFAULT_SOFTWARE_WORKFLOW } from '../utils/workflow.js';
|
|
6
|
+
|
|
7
|
+
describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
|
|
8
|
+
it('stamps actualStartDate and inProgressSince when moving to in_progress', () => {
|
|
9
|
+
const task = TaskEntity.create({
|
|
10
|
+
projectId: 'proj_1',
|
|
11
|
+
title: 'Implement Task Timestamps',
|
|
12
|
+
status: 'todo'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(task.actualStartDate).toBeUndefined();
|
|
16
|
+
expect(task.actualEndDate).toBeUndefined();
|
|
17
|
+
expect(task.inProgressSince).toBeUndefined();
|
|
18
|
+
expect(task.actualDurationSeconds).toBeUndefined();
|
|
19
|
+
|
|
20
|
+
task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
|
|
21
|
+
|
|
22
|
+
expect(task.actualStartDate).toBeDefined();
|
|
23
|
+
expect(task.inProgressSince).toBeDefined();
|
|
24
|
+
expect(task.actualEndDate).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('accumulates in-progress duration across pause/resume cycles and retains earliest actualStartDate', async () => {
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
try {
|
|
30
|
+
const now = new Date('2026-09-23T10:00:00Z');
|
|
31
|
+
vi.setSystemTime(now);
|
|
32
|
+
|
|
33
|
+
const task = TaskEntity.create({
|
|
34
|
+
projectId: 'proj_1',
|
|
35
|
+
title: 'Work Tracking',
|
|
36
|
+
status: 'todo'
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 1. Start working at 10:00
|
|
40
|
+
task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
|
|
41
|
+
const initialStartDate = task.actualStartDate;
|
|
42
|
+
expect(initialStartDate).toBe(now.toISOString());
|
|
43
|
+
expect(task.inProgressSince).toBe(now.toISOString());
|
|
44
|
+
|
|
45
|
+
// Fast-forward 30 minutes (1800s)
|
|
46
|
+
vi.setSystemTime(new Date('2026-09-23T10:30:00Z'));
|
|
47
|
+
|
|
48
|
+
// 2. Pause: move to todo
|
|
49
|
+
task.transitionTo('todo', DEFAULT_SOFTWARE_WORKFLOW);
|
|
50
|
+
expect(task.inProgressSince).toBeNull();
|
|
51
|
+
expect(task.actualDurationSeconds).toBe(1800);
|
|
52
|
+
expect(task.actualHours).toBe(0.5);
|
|
53
|
+
expect(task.actualDurationMinutes).toBe(30);
|
|
54
|
+
expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
|
|
55
|
+
|
|
56
|
+
// Paused for 1 hour
|
|
57
|
+
vi.setSystemTime(new Date('2026-09-23T11:30:00Z'));
|
|
58
|
+
|
|
59
|
+
// 3. Resume: move back to in_progress
|
|
60
|
+
task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
|
|
61
|
+
expect(task.actualStartDate).toBe(initialStartDate); // Still retains earliest
|
|
62
|
+
expect(task.inProgressSince).toBe(new Date('2026-09-23T11:30:00Z').toISOString());
|
|
63
|
+
expect(task.actualDurationSeconds).toBe(1800); // Unchanged until leaving
|
|
64
|
+
|
|
65
|
+
// Fast-forward another 45 minutes (2700s)
|
|
66
|
+
vi.setSystemTime(new Date('2026-09-23T12:15:00Z'));
|
|
67
|
+
|
|
68
|
+
// 4. Complete: move to done
|
|
69
|
+
task.transitionTo('done', DEFAULT_SOFTWARE_WORKFLOW);
|
|
70
|
+
expect(task.inProgressSince).toBeNull();
|
|
71
|
+
// Total duration = 1800s + 2700s = 4500s = 1.25h
|
|
72
|
+
expect(task.actualDurationSeconds).toBe(4500);
|
|
73
|
+
expect(task.actualHours).toBe(1.25);
|
|
74
|
+
expect(task.actualDurationMinutes).toBe(75);
|
|
75
|
+
expect(task.actualStartDate).toBe(initialStartDate);
|
|
76
|
+
expect(task.actualEndDate).toBe(new Date('2026-09-23T12:15:00Z').toISOString());
|
|
77
|
+
expect(task.progress).toBe(100);
|
|
78
|
+
|
|
79
|
+
// 5. Reopen: move from done to todo, then to in_progress
|
|
80
|
+
vi.setSystemTime(new Date('2026-09-23T14:00:00Z'));
|
|
81
|
+
task.transitionTo('todo', DEFAULT_SOFTWARE_WORKFLOW);
|
|
82
|
+
expect(task.actualEndDate).toBeUndefined(); // Reset upon reopen
|
|
83
|
+
expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
|
|
84
|
+
|
|
85
|
+
vi.setSystemTime(new Date('2026-09-23T14:15:00Z'));
|
|
86
|
+
task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
|
|
87
|
+
expect(task.actualEndDate).toBeUndefined();
|
|
88
|
+
expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
|
|
89
|
+
expect(task.inProgressSince).toBe(new Date('2026-09-23T14:15:00Z').toISOString());
|
|
90
|
+
} finally {
|
|
91
|
+
vi.useRealTimers();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('keeps actualDurationSeconds and actualHours synchronized on manual updates', () => {
|
|
96
|
+
const task = TaskEntity.create({
|
|
97
|
+
projectId: 'proj_1',
|
|
98
|
+
title: 'Manual Adjustments',
|
|
99
|
+
status: 'todo'
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Update actualDurationSeconds directly
|
|
103
|
+
task.update({ actualDurationSeconds: 7200 });
|
|
104
|
+
expect(task.actualHours).toBe(2);
|
|
105
|
+
expect(task.actualDurationMinutes).toBe(120);
|
|
106
|
+
|
|
107
|
+
// Update actualHours directly
|
|
108
|
+
task.update({ actualHours: 1.5 });
|
|
109
|
+
expect(task.actualDurationSeconds).toBe(5400);
|
|
110
|
+
expect(task.actualDurationMinutes).toBe(90);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('integrates with CriticalPathEngine updateTask and metrics calculation', async () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
try {
|
|
116
|
+
const store = new InMemoryStore();
|
|
117
|
+
const engine = new CriticalPathEngine({ store });
|
|
118
|
+
|
|
119
|
+
const project = await engine.createProject({ name: 'Engine Duration Test' });
|
|
120
|
+
|
|
121
|
+
vi.setSystemTime(new Date('2026-09-23T09:00:00Z'));
|
|
122
|
+
const task = await engine.createTask({
|
|
123
|
+
projectId: project.id,
|
|
124
|
+
title: 'Build Feature',
|
|
125
|
+
estimatedHours: 8
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// 1. Move to in_progress
|
|
129
|
+
const started = await engine.updateTask(task.id, { status: 'in_progress' });
|
|
130
|
+
expect(started?.actualStartDate).toBe('2026-09-23T09:00:00.000Z');
|
|
131
|
+
expect(started?.inProgressSince).toBe('2026-09-23T09:00:00.000Z');
|
|
132
|
+
expect(started?.actualEndDate).toBeUndefined();
|
|
133
|
+
|
|
134
|
+
// 2. Work for 2 hours (7200s), then move to todo (pause)
|
|
135
|
+
vi.setSystemTime(new Date('2026-09-23T11:00:00Z'));
|
|
136
|
+
const paused = await engine.updateTask(task.id, { status: 'todo' });
|
|
137
|
+
expect(paused?.inProgressSince).toBeNull();
|
|
138
|
+
expect(paused?.actualDurationSeconds).toBe(7200);
|
|
139
|
+
expect(paused?.actualHours).toBe(2);
|
|
140
|
+
|
|
141
|
+
// 3. Complete task at 14:00 (move from todo to done)
|
|
142
|
+
vi.setSystemTime(new Date('2026-09-23T14:00:00Z'));
|
|
143
|
+
const completed = await engine.updateTask(task.id, { status: 'done' });
|
|
144
|
+
expect(completed?.actualEndDate).toBe('2026-09-23T14:00:00.000Z');
|
|
145
|
+
// in-progress duration should still be 7200s (since time in todo was paused)
|
|
146
|
+
expect(completed?.actualDurationSeconds).toBe(7200);
|
|
147
|
+
|
|
148
|
+
// 4. Verify metrics calculation
|
|
149
|
+
const metrics = await engine.getTaskMetrics(task.id);
|
|
150
|
+
expect(metrics?.inferredActuals.actualDurationSeconds).toBe(7200);
|
|
151
|
+
expect(metrics?.inferredActuals.activeWorkingHours).toBe(2);
|
|
152
|
+
// Calendar duration: 09:00 to 14:00 = 5.0 hours
|
|
153
|
+
expect(metrics?.inferredActuals.calendarDurationHours).toBe(5);
|
|
154
|
+
|
|
155
|
+
// 5. Reopen task (done -> todo -> in_progress): actualEndDate must be cleared (undefined)
|
|
156
|
+
vi.setSystemTime(new Date('2026-09-23T15:00:00Z'));
|
|
157
|
+
const reopenedTodo = await engine.updateTask(task.id, { status: 'todo' });
|
|
158
|
+
expect(reopenedTodo?.actualEndDate).toBeUndefined();
|
|
159
|
+
|
|
160
|
+
vi.setSystemTime(new Date('2026-09-23T15:30:00Z'));
|
|
161
|
+
const reopenedProgress = await engine.updateTask(task.id, { status: 'in_progress' });
|
|
162
|
+
expect(reopenedProgress?.actualEndDate).toBeUndefined();
|
|
163
|
+
expect(reopenedProgress?.actualStartDate).toBe('2026-09-23T09:00:00.000Z'); // Earliest preserved
|
|
164
|
+
expect(reopenedProgress?.inProgressSince).toBe('2026-09-23T15:30:00.000Z');
|
|
165
|
+
expect(reopenedProgress?.actualDurationSeconds).toBe(7200);
|
|
166
|
+
} finally {
|
|
167
|
+
vi.useRealTimers();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/engine/index.ts
CHANGED
|
@@ -371,6 +371,7 @@ export class CriticalPathEngine {
|
|
|
371
371
|
const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
|
|
372
372
|
const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
|
|
373
373
|
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
374
|
+
const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
374
375
|
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
|
|
375
376
|
|
|
376
377
|
const created = await this.store.createTask({
|
|
@@ -400,7 +401,11 @@ export class CriticalPathEngine {
|
|
|
400
401
|
estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
|
|
401
402
|
actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
|
|
402
403
|
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
404
|
+
actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
|
|
405
|
+
inProgressSince,
|
|
403
406
|
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
|
|
407
|
+
isBlocked: processedInput.isBlocked ?? taskInput.isBlocked ?? false,
|
|
408
|
+
blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
|
|
404
409
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
405
410
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
406
411
|
parentId: processedInput.parentId ?? taskInput.parentId
|
|
@@ -515,17 +520,51 @@ export class CriticalPathEngine {
|
|
|
515
520
|
existing.status,
|
|
516
521
|
project?.statusDefinitions || workflow?.statuses
|
|
517
522
|
);
|
|
518
|
-
|
|
523
|
+
|
|
524
|
+
// 1. Leaving in_progress: finalize active session duration
|
|
525
|
+
if (existingStatusDef.category === 'in_progress' && statusDef.category !== 'in_progress') {
|
|
526
|
+
const inProgressSince = existing.inProgressSince;
|
|
527
|
+
if (inProgressSince) {
|
|
528
|
+
const startMs = new Date(inProgressSince).getTime();
|
|
529
|
+
const endMs = new Date(now).getTime();
|
|
530
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
531
|
+
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
532
|
+
processedUpdates.actualDurationSeconds =
|
|
533
|
+
(processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
|
|
534
|
+
if (processedUpdates.actualHours === undefined) {
|
|
535
|
+
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
536
|
+
}
|
|
537
|
+
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
538
|
+
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
processedUpdates.inProgressSince = null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 2. Entering in_progress: retain earliest start date & start session timer
|
|
546
|
+
if (statusDef.category === 'in_progress') {
|
|
547
|
+
if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
548
|
+
processedUpdates.actualStartDate = now;
|
|
549
|
+
}
|
|
550
|
+
if (!processedUpdates.inProgressSince) {
|
|
551
|
+
processedUpdates.inProgressSince = now;
|
|
552
|
+
}
|
|
553
|
+
if (
|
|
554
|
+
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
555
|
+
processedUpdates.actualEndDate === undefined
|
|
556
|
+
) {
|
|
557
|
+
processedUpdates.actualEndDate = undefined;
|
|
558
|
+
}
|
|
559
|
+
} else if (
|
|
519
560
|
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
520
|
-
|
|
561
|
+
statusDef.category === 'not_started' &&
|
|
521
562
|
processedUpdates.actualEndDate === undefined
|
|
522
563
|
) {
|
|
523
564
|
processedUpdates.actualEndDate = undefined;
|
|
524
565
|
}
|
|
525
566
|
|
|
526
|
-
|
|
527
|
-
processedUpdates.actualStartDate = now;
|
|
528
|
-
}
|
|
567
|
+
// 3. Entering completed or canceled
|
|
529
568
|
if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
|
|
530
569
|
processedUpdates.actualEndDate = now;
|
|
531
570
|
if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
@@ -534,6 +573,19 @@ export class CriticalPathEngine {
|
|
|
534
573
|
}
|
|
535
574
|
}
|
|
536
575
|
|
|
576
|
+
// Mutual sync for manual duration/effort edits
|
|
577
|
+
if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
|
|
578
|
+
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
579
|
+
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
580
|
+
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
581
|
+
}
|
|
582
|
+
} else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
|
|
583
|
+
processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
|
|
584
|
+
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
585
|
+
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
537
589
|
const updated = await this.store.updateTask(id, processedUpdates);
|
|
538
590
|
if (!updated) return null;
|
|
539
591
|
|
|
@@ -586,11 +638,11 @@ export class CriticalPathEngine {
|
|
|
586
638
|
await this.events.publish(updateEvent);
|
|
587
639
|
}
|
|
588
640
|
|
|
589
|
-
const wasBlocked = Boolean(existing.isBlocked
|
|
590
|
-
const isNowBlocked = Boolean(updated.isBlocked
|
|
641
|
+
const wasBlocked = Boolean(existing.isBlocked);
|
|
642
|
+
const isNowBlocked = Boolean(updated.isBlocked);
|
|
591
643
|
|
|
592
644
|
if (!wasBlocked && isNowBlocked) {
|
|
593
|
-
const blockedReason = updated.blockedReason ??
|
|
645
|
+
const blockedReason = updated.blockedReason ?? null;
|
|
594
646
|
const blockedEvent: TaskBlockedEvent = {
|
|
595
647
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
596
648
|
name: 'task.blocked',
|
|
@@ -689,24 +741,11 @@ export class CriticalPathEngine {
|
|
|
689
741
|
let updatedDownstream = downstream;
|
|
690
742
|
const updates: Partial<Task> = {};
|
|
691
743
|
|
|
692
|
-
if (downstream.status === 'blocked') {
|
|
693
|
-
const defaultStatus = downstreamWorkflow?.defaultStatusKey || 'todo';
|
|
694
|
-
updates.status = defaultStatus;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
744
|
if (downstream.isBlocked || downstream.blockedReason) {
|
|
698
745
|
updates.isBlocked = false;
|
|
699
746
|
updates.blockedReason = null;
|
|
700
747
|
}
|
|
701
748
|
|
|
702
|
-
if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
|
|
703
|
-
updates.customFields = {
|
|
704
|
-
...downstream.customFields,
|
|
705
|
-
isBlocked: false,
|
|
706
|
-
blockedReason: null
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
|
|
710
749
|
if (Object.keys(updates).length > 0) {
|
|
711
750
|
const res = await this.store.updateTask(downstream.id, updates);
|
|
712
751
|
if (res) updatedDownstream = res;
|
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
|
@@ -131,6 +131,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
131
131
|
estimatedDurationMinutes REAL,
|
|
132
132
|
actualDurationMinutes REAL,
|
|
133
133
|
billableDurationMinutes REAL,
|
|
134
|
+
actualDurationSeconds REAL,
|
|
135
|
+
inProgressSince TEXT,
|
|
134
136
|
progress REAL,
|
|
135
137
|
isBlocked INTEGER,
|
|
136
138
|
blockedReason TEXT,
|
|
@@ -660,10 +662,11 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
660
662
|
id, projectId, title, description, status, priority, taskType, assigneeId, assignees, reporterId,
|
|
661
663
|
reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
|
|
662
664
|
dueDate, estimatedHours, loggedHours, actualHours, billableHours,
|
|
663
|
-
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
|
|
665
|
+
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
|
|
666
|
+
actualDurationSeconds, inProgressSince, progress,
|
|
664
667
|
isBlocked, blockedReason,
|
|
665
668
|
tags, customFields, parentId, createdAt, updatedAt
|
|
666
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
669
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
667
670
|
`);
|
|
668
671
|
stmt.run(
|
|
669
672
|
newTask.id,
|
|
@@ -692,6 +695,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
692
695
|
newTask.estimatedDurationMinutes ?? null,
|
|
693
696
|
newTask.actualDurationMinutes ?? null,
|
|
694
697
|
newTask.billableDurationMinutes ?? null,
|
|
698
|
+
newTask.actualDurationSeconds ?? null,
|
|
699
|
+
newTask.inProgressSince ?? null,
|
|
695
700
|
newTask.progress ?? null,
|
|
696
701
|
newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
|
|
697
702
|
newTask.blockedReason ?? null,
|
|
@@ -720,7 +725,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
720
725
|
assigneeId = ?, assignees = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?, deliverableId = ?,
|
|
721
726
|
plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
|
|
722
727
|
estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
|
|
723
|
-
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
|
|
728
|
+
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
|
|
729
|
+
actualDurationSeconds = ?, inProgressSince = ?, progress = ?,
|
|
724
730
|
isBlocked = ?, blockedReason = ?,
|
|
725
731
|
tags = ?, customFields = ?, parentId = ?, updatedAt = ?
|
|
726
732
|
WHERE id = ?
|
|
@@ -751,6 +757,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
751
757
|
updated.estimatedDurationMinutes ?? null,
|
|
752
758
|
updated.actualDurationMinutes ?? null,
|
|
753
759
|
updated.billableDurationMinutes ?? null,
|
|
760
|
+
updated.actualDurationSeconds ?? null,
|
|
761
|
+
updated.inProgressSince ?? null,
|
|
754
762
|
updated.progress ?? null,
|
|
755
763
|
updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
|
|
756
764
|
updated.blockedReason ?? null,
|
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
|
@@ -214,6 +214,10 @@ export interface Task {
|
|
|
214
214
|
estimatedDurationMinutes?: number;
|
|
215
215
|
actualDurationMinutes?: number;
|
|
216
216
|
billableDurationMinutes?: number;
|
|
217
|
+
/** Cumulative active execution time spent in 'in_progress' status (in seconds) */
|
|
218
|
+
actualDurationSeconds?: number;
|
|
219
|
+
/** Transient ISO timestamp marking the start of current 'in_progress' session (null when not in_progress) */
|
|
220
|
+
inProgressSince?: string | null;
|
|
217
221
|
// Progress (0 to 100 percentage)
|
|
218
222
|
progress?: number;
|
|
219
223
|
isBlocked?: boolean;
|
|
@@ -626,6 +630,10 @@ export interface TaskInferredActuals {
|
|
|
626
630
|
isStartDateInferred: boolean;
|
|
627
631
|
isEndDateInferred: boolean;
|
|
628
632
|
activeWorkingHours?: number;
|
|
633
|
+
/** Calendar elapsed hours between actualStartDate and actualEndDate */
|
|
634
|
+
calendarDurationHours?: number;
|
|
635
|
+
/** Cumulative active execution duration in seconds */
|
|
636
|
+
actualDurationSeconds?: number;
|
|
629
637
|
}
|
|
630
638
|
|
|
631
639
|
export interface TaskMetrics {
|
package/src/utils/status.ts
CHANGED
|
@@ -88,11 +88,7 @@ export function deriveTaskLifecycleState(
|
|
|
88
88
|
return upstreamDef.category !== 'completed';
|
|
89
89
|
});
|
|
90
90
|
const blockingTaskIds = blockingTasks.map((t) => t.id);
|
|
91
|
-
const isExplicitlyBlocked = Boolean(
|
|
92
|
-
task.isBlocked === true ||
|
|
93
|
-
task.customFields?.isBlocked === true ||
|
|
94
|
-
task.status === 'blocked'
|
|
95
|
-
);
|
|
91
|
+
const isExplicitlyBlocked = Boolean(task.isBlocked === true);
|
|
96
92
|
const isBlocked = !isDone && (blockingTaskIds.length > 0 || isExplicitlyBlocked);
|
|
97
93
|
const isReady = semanticStatus === 'not_started' && !isBlocked;
|
|
98
94
|
|
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/calendar.test.ts","./src/domain/calendar.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/domain/workload.test.ts","./src/domain/workload.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/calendar.test.ts","./src/domain/calendar.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/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.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"}
|