@critical-path/core 0.14.0 → 0.15.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 +18 -0
- package/README.md +22 -0
- package/dist/deliverables.test.js +2 -2
- package/dist/deliverables.test.js.map +1 -1
- package/dist/domain/entities.d.ts +2 -1
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +11 -4
- package/dist/domain/entities.js.map +1 -1
- package/dist/engine/index.d.ts +5 -3
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +22 -12
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +8 -8
- package/dist/index.test.js.map +1 -1
- package/dist/status.test.d.ts +2 -0
- package/dist/status.test.d.ts.map +1 -0
- package/dist/status.test.js +199 -0
- package/dist/status.test.js.map +1 -0
- package/dist/store/firebase.test.js +1 -1
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/sqlite.test.js +1 -1
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +20 -5
- package/dist/types/index.d.ts.map +1 -1
- package/dist/use-case-validation.test.js +1 -1
- package/dist/use-case-validation.test.js.map +1 -1
- package/dist/utils/status.d.ts +7 -10
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +104 -21
- package/dist/utils/status.js.map +1 -1
- package/dist/utils/workflow.js +15 -15
- package/dist/utils/workflow.js.map +1 -1
- package/package.json +1 -1
- package/src/deliverables.test.ts +2 -2
- package/src/domain/entities.ts +15 -5
- package/src/engine/index.ts +30 -14
- package/src/index.test.ts +8 -8
- package/src/status.test.ts +226 -0
- package/src/store/firebase.test.ts +1 -1
- package/src/store/sqlite.test.ts +1 -1
- package/src/types/index.ts +22 -5
- package/src/use-case-validation.test.ts +1 -1
- package/src/utils/status.ts +119 -33
- package/src/utils/workflow.ts +15 -15
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -20,11 +20,12 @@ import type {
|
|
|
20
20
|
CreateProjectInput,
|
|
21
21
|
Deliverable,
|
|
22
22
|
DeliverableSummary,
|
|
23
|
-
CreateDeliverableInput
|
|
23
|
+
CreateDeliverableInput,
|
|
24
|
+
TaskLifecycleState
|
|
24
25
|
} from '../types/index.js';
|
|
25
26
|
import { StorageAdapter, InMemoryStore } from '../store/index.js';
|
|
26
27
|
import { PluginRegistry } from '../plugins/index.js';
|
|
27
|
-
import { deriveTaskLifecycleState,
|
|
28
|
+
import { deriveTaskLifecycleState, resolveStatusDefinition } from '../utils/status.js';
|
|
28
29
|
import {
|
|
29
30
|
validateTransition,
|
|
30
31
|
getAllowedNextStatuses,
|
|
@@ -335,16 +336,18 @@ export class CriticalPathEngine {
|
|
|
335
336
|
const initialStatus = processedInput.status || taskInput.status || defaultStatus;
|
|
336
337
|
const now = new Date().toISOString();
|
|
337
338
|
|
|
338
|
-
// Derive initial lifecycle timestamps
|
|
339
|
+
// Derive initial lifecycle timestamps and semantic status
|
|
339
340
|
const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
|
|
340
|
-
const
|
|
341
|
-
const
|
|
341
|
+
const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
|
|
342
|
+
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
343
|
+
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
|
|
342
344
|
|
|
343
345
|
const created = await this.store.createTask({
|
|
344
346
|
projectId,
|
|
345
347
|
title: processedInput.title || taskInput.title,
|
|
346
348
|
description: processedInput.description ?? taskInput.description,
|
|
347
349
|
status: initialStatus,
|
|
350
|
+
semanticStatus,
|
|
348
351
|
priority: processedInput.priority || taskInput.priority || 'medium',
|
|
349
352
|
taskType: processedInput.taskType || taskInput.taskType || 'task',
|
|
350
353
|
assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
|
|
@@ -366,7 +369,7 @@ export class CriticalPathEngine {
|
|
|
366
369
|
estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
|
|
367
370
|
actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
|
|
368
371
|
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
369
|
-
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.
|
|
372
|
+
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
|
|
370
373
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
371
374
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
372
375
|
parentId: processedInput.parentId ?? taskInput.parentId
|
|
@@ -428,13 +431,14 @@ export class CriticalPathEngine {
|
|
|
428
431
|
processedUpdates.status,
|
|
429
432
|
project?.statusDefinitions || workflow?.statuses
|
|
430
433
|
);
|
|
434
|
+
processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? statusDef.category;
|
|
431
435
|
const now = new Date().toISOString();
|
|
432
|
-
if (statusDef.
|
|
436
|
+
if (statusDef.category === 'in_progress' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
433
437
|
processedUpdates.actualStartDate = now;
|
|
434
438
|
}
|
|
435
|
-
if (statusDef.
|
|
439
|
+
if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
|
|
436
440
|
processedUpdates.actualEndDate = now;
|
|
437
|
-
if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
441
|
+
if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
438
442
|
processedUpdates.progress = 100;
|
|
439
443
|
}
|
|
440
444
|
}
|
|
@@ -590,13 +594,25 @@ export class CriticalPathEngine {
|
|
|
590
594
|
};
|
|
591
595
|
}
|
|
592
596
|
|
|
593
|
-
async getTaskLifecycleState(
|
|
597
|
+
async getTaskLifecycleState(
|
|
598
|
+
taskId: string,
|
|
599
|
+
options?: { referenceDate?: Date; stalledThresholdDays?: number }
|
|
600
|
+
): Promise<TaskLifecycleState | null> {
|
|
594
601
|
const task = await this.getTask(taskId);
|
|
595
602
|
if (!task) return null;
|
|
596
603
|
const project = await this.getProject(task.projectId);
|
|
597
604
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
598
605
|
const statusDefs = project?.statusDefinitions || workflow?.statuses;
|
|
599
|
-
|
|
606
|
+
|
|
607
|
+
const graph = await this.getTaskDependencyGraph(taskId);
|
|
608
|
+
const upstreamTasks = graph.upstreamTasks;
|
|
609
|
+
|
|
610
|
+
return deriveTaskLifecycleState(task, {
|
|
611
|
+
customDefinitions: statusDefs,
|
|
612
|
+
referenceDate: options?.referenceDate,
|
|
613
|
+
stalledThresholdDays: options?.stalledThresholdDays,
|
|
614
|
+
upstreamTasks
|
|
615
|
+
});
|
|
600
616
|
}
|
|
601
617
|
|
|
602
618
|
// --- Time Tracking ---
|
|
@@ -1142,9 +1158,9 @@ export class CriticalPathEngine {
|
|
|
1142
1158
|
|
|
1143
1159
|
for (const task of tasks) {
|
|
1144
1160
|
const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
|
|
1145
|
-
if (statusDef.
|
|
1161
|
+
if (statusDef.category === 'completed') {
|
|
1146
1162
|
completedTasks++;
|
|
1147
|
-
} else if (statusDef.
|
|
1163
|
+
} else if (statusDef.category === 'in_progress') {
|
|
1148
1164
|
activeTasks++;
|
|
1149
1165
|
}
|
|
1150
1166
|
|
|
@@ -1154,7 +1170,7 @@ export class CriticalPathEngine {
|
|
|
1154
1170
|
if (typeof task.progress === 'number') {
|
|
1155
1171
|
totalProgress += task.progress;
|
|
1156
1172
|
} else {
|
|
1157
|
-
totalProgress += statusDef.
|
|
1173
|
+
totalProgress += statusDef.category === 'completed' ? 100 : 0;
|
|
1158
1174
|
}
|
|
1159
1175
|
}
|
|
1160
1176
|
|
package/src/index.test.ts
CHANGED
|
@@ -128,9 +128,9 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
128
128
|
key: 'CUST',
|
|
129
129
|
name: 'Custom Workflow Project',
|
|
130
130
|
statusDefinitions: [
|
|
131
|
-
{ key: 'draft', label: 'Draft',
|
|
132
|
-
{ key: 'active_work', label: 'Active Work',
|
|
133
|
-
{ key: 'finished', label: 'Finished',
|
|
131
|
+
{ key: 'draft', label: 'Draft', category: 'not_started' },
|
|
132
|
+
{ key: 'active_work', label: 'Active Work', category: 'in_progress' },
|
|
133
|
+
{ key: 'finished', label: 'Finished', category: 'completed' }
|
|
134
134
|
]
|
|
135
135
|
});
|
|
136
136
|
|
|
@@ -145,10 +145,10 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
145
145
|
|
|
146
146
|
const state = await engine.getTaskLifecycleState(task.id);
|
|
147
147
|
expect(state).not.toBeNull();
|
|
148
|
-
expect(state?.
|
|
149
|
-
expect(state?.executionState).toBe('active');
|
|
148
|
+
expect(state?.semanticStatus).toBe('in_progress');
|
|
150
149
|
expect(state?.isActive).toBe(true);
|
|
151
150
|
expect(state?.isDone).toBe(false);
|
|
151
|
+
expect(state?.isBlocked).toBe(false);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
it('builds task dependency graph with upstream and downstream tasks', async () => {
|
|
@@ -181,9 +181,9 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
181
181
|
name: 'Strict Software Workflow',
|
|
182
182
|
defaultStatusKey: 'backlog',
|
|
183
183
|
statuses: [
|
|
184
|
-
{ key: 'backlog', label: 'Backlog',
|
|
185
|
-
{ key: 'in_progress', label: 'In Progress',
|
|
186
|
-
{ key: 'done', label: 'Done',
|
|
184
|
+
{ key: 'backlog', label: 'Backlog', category: 'not_started' },
|
|
185
|
+
{ key: 'in_progress', label: 'In Progress', category: 'in_progress' },
|
|
186
|
+
{ key: 'done', label: 'Done', category: 'completed' }
|
|
187
187
|
],
|
|
188
188
|
transitions: [
|
|
189
189
|
{ id: 't1', fromStatusKey: 'backlog', toStatusKey: 'in_progress', name: 'Start Work' },
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CriticalPathEngine } from './engine/index.js';
|
|
3
|
+
import { resolveStatusDefinition, deriveTaskLifecycleState } from './utils/status.js';
|
|
4
|
+
import type { Task, StatusDefinition } from './types/index.js';
|
|
5
|
+
|
|
6
|
+
describe('Universal Semantic Statuses & Implied Statuses', () => {
|
|
7
|
+
describe('resolveStatusDefinition', () => {
|
|
8
|
+
it('resolves core standard statuses to their semantic category', () => {
|
|
9
|
+
expect(resolveStatusDefinition('todo').category).toBe('not_started');
|
|
10
|
+
expect(resolveStatusDefinition('backlog').category).toBe('not_started');
|
|
11
|
+
expect(resolveStatusDefinition('in_progress').category).toBe('in_progress');
|
|
12
|
+
expect(resolveStatusDefinition('in_review').category).toBe('in_progress');
|
|
13
|
+
expect(resolveStatusDefinition('done').category).toBe('completed');
|
|
14
|
+
expect(resolveStatusDefinition('canceled').category).toBe('canceled');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('resolves custom status definitions when provided', () => {
|
|
18
|
+
const customDefs: StatusDefinition[] = [
|
|
19
|
+
{ key: 'concept', label: 'Concept & Brainstorming', category: 'not_started' },
|
|
20
|
+
{ key: 'client_review', label: 'Client Feedback Session', category: 'in_progress' },
|
|
21
|
+
{ key: 'shipped', label: 'Shipped to Customer', category: 'completed' },
|
|
22
|
+
{ key: 'abandoned', label: 'Abandoned Initiative', category: 'canceled' }
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
expect(resolveStatusDefinition('concept', customDefs).category).toBe('not_started');
|
|
26
|
+
expect(resolveStatusDefinition('client_review', customDefs).category).toBe('in_progress');
|
|
27
|
+
expect(resolveStatusDefinition('shipped', customDefs).category).toBe('completed');
|
|
28
|
+
expect(resolveStatusDefinition('abandoned', customDefs).category).toBe('canceled');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('infers category heuristically when unknown status key is provided', () => {
|
|
32
|
+
expect(resolveStatusDefinition('draft_v2').category).toBe('not_started');
|
|
33
|
+
expect(resolveStatusDefinition('final_approved').category).toBe('completed');
|
|
34
|
+
expect(resolveStatusDefinition('active_development').category).toBe('in_progress');
|
|
35
|
+
expect(resolveStatusDefinition('rejected_proposal').category).toBe('canceled');
|
|
36
|
+
expect(resolveStatusDefinition('custom_arbitrary_key').category).toBe('not_started');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('deriveTaskLifecycleState', () => {
|
|
41
|
+
const baseTask: Task = {
|
|
42
|
+
id: 'task_1',
|
|
43
|
+
projectId: 'proj_1',
|
|
44
|
+
title: 'Design Wireframes',
|
|
45
|
+
status: 'todo',
|
|
46
|
+
priority: 'high',
|
|
47
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
48
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
it('derives basic state for not_started task', () => {
|
|
52
|
+
const derived = deriveTaskLifecycleState(baseTask);
|
|
53
|
+
expect(derived.semanticStatus).toBe('not_started');
|
|
54
|
+
expect(derived.isActive).toBe(false);
|
|
55
|
+
expect(derived.isDone).toBe(false);
|
|
56
|
+
expect(derived.isCancelled).toBe(false);
|
|
57
|
+
expect(derived.isReady).toBe(true);
|
|
58
|
+
expect(derived.isBlocked).toBe(false);
|
|
59
|
+
expect(derived.isUnassigned).toBe(true);
|
|
60
|
+
expect(derived.isUnplanned).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('derives blocked and ready states based on upstream tasks', () => {
|
|
64
|
+
const upstream1: Task = {
|
|
65
|
+
...baseTask,
|
|
66
|
+
id: 'task_up1',
|
|
67
|
+
status: 'in_progress',
|
|
68
|
+
semanticStatus: 'in_progress'
|
|
69
|
+
};
|
|
70
|
+
const upstream2: Task = {
|
|
71
|
+
...baseTask,
|
|
72
|
+
id: 'task_up2',
|
|
73
|
+
status: 'done',
|
|
74
|
+
semanticStatus: 'completed'
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// When upstream1 is in progress, task_1 is blocked
|
|
78
|
+
const blockedState = deriveTaskLifecycleState(baseTask, {
|
|
79
|
+
upstreamTasks: [upstream1, upstream2]
|
|
80
|
+
});
|
|
81
|
+
expect(blockedState.isBlocked).toBe(true);
|
|
82
|
+
expect(blockedState.isReady).toBe(false);
|
|
83
|
+
expect(blockedState.blockingTaskIds).toEqual(['task_up1']);
|
|
84
|
+
|
|
85
|
+
// When all upstream tasks are completed, task_1 becomes ready
|
|
86
|
+
const allDoneState = deriveTaskLifecycleState(baseTask, {
|
|
87
|
+
upstreamTasks: [
|
|
88
|
+
{ ...upstream1, status: 'done', semanticStatus: 'completed' },
|
|
89
|
+
upstream2
|
|
90
|
+
]
|
|
91
|
+
});
|
|
92
|
+
expect(allDoneState.isBlocked).toBe(false);
|
|
93
|
+
expect(allDoneState.isReady).toBe(true);
|
|
94
|
+
expect(allDoneState.blockingTaskIds).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('derives schedule indicators: overdue, upcoming, unplanned', () => {
|
|
98
|
+
const refDate = new Date('2026-09-10T12:00:00.000Z');
|
|
99
|
+
|
|
100
|
+
// Overdue task
|
|
101
|
+
const overdueTask: Task = {
|
|
102
|
+
...baseTask,
|
|
103
|
+
dueDate: '2026-09-08T00:00:00.000Z'
|
|
104
|
+
};
|
|
105
|
+
const overdueDerived = deriveTaskLifecycleState(overdueTask, { referenceDate: refDate });
|
|
106
|
+
expect(overdueDerived.isOverdue).toBe(true);
|
|
107
|
+
expect(overdueDerived.isUnplanned).toBe(false);
|
|
108
|
+
|
|
109
|
+
// Completed task is not overdue even if dueDate is past
|
|
110
|
+
const completedTask: Task = {
|
|
111
|
+
...overdueTask,
|
|
112
|
+
status: 'done'
|
|
113
|
+
};
|
|
114
|
+
const completedDerived = deriveTaskLifecycleState(completedTask, { referenceDate: refDate });
|
|
115
|
+
expect(completedDerived.isOverdue).toBe(false);
|
|
116
|
+
expect(completedDerived.isDone).toBe(true);
|
|
117
|
+
|
|
118
|
+
// Upcoming task
|
|
119
|
+
const upcomingTask: Task = {
|
|
120
|
+
...baseTask,
|
|
121
|
+
plannedStartDate: '2026-09-12T00:00:00.000Z' // within 3 days of Sept 10
|
|
122
|
+
};
|
|
123
|
+
const upcomingDerived = deriveTaskLifecycleState(upcomingTask, { referenceDate: refDate });
|
|
124
|
+
expect(upcomingDerived.isUpcoming).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('derives resource indicators: unassigned and stalled', () => {
|
|
128
|
+
const refDate = new Date('2026-09-15T12:00:00.000Z');
|
|
129
|
+
|
|
130
|
+
// Assigned via assigneeId
|
|
131
|
+
const assignedTask1: Task = {
|
|
132
|
+
...baseTask,
|
|
133
|
+
assigneeId: 'user_alice'
|
|
134
|
+
};
|
|
135
|
+
expect(deriveTaskLifecycleState(assignedTask1).isUnassigned).toBe(false);
|
|
136
|
+
|
|
137
|
+
// Assigned via assignees array
|
|
138
|
+
const assignedTask2: Task = {
|
|
139
|
+
...baseTask,
|
|
140
|
+
assignees: [{ id: 'user_bob' }]
|
|
141
|
+
};
|
|
142
|
+
expect(deriveTaskLifecycleState(assignedTask2).isUnassigned).toBe(false);
|
|
143
|
+
|
|
144
|
+
// Stalled task: in_progress with no update in > 7 days
|
|
145
|
+
const stalledTask: Task = {
|
|
146
|
+
...baseTask,
|
|
147
|
+
status: 'in_progress',
|
|
148
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
149
|
+
};
|
|
150
|
+
const stalledDerived = deriveTaskLifecycleState(stalledTask, { referenceDate: refDate, stalledThresholdDays: 7 });
|
|
151
|
+
expect(stalledDerived.isStalled).toBe(true);
|
|
152
|
+
|
|
153
|
+
// Not stalled if updated recently
|
|
154
|
+
const activeTask: Task = {
|
|
155
|
+
...stalledTask,
|
|
156
|
+
updatedAt: '2026-09-14T00:00:00.000Z'
|
|
157
|
+
};
|
|
158
|
+
expect(deriveTaskLifecycleState(activeTask, { referenceDate: refDate, stalledThresholdDays: 7 }).isStalled).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('derives estimate indicators: isOverEstimate and isPaceWarning', () => {
|
|
162
|
+
// Over estimate by logged hours
|
|
163
|
+
const overEstimateTask: Task = {
|
|
164
|
+
...baseTask,
|
|
165
|
+
status: 'in_progress',
|
|
166
|
+
estimatedHours: 10,
|
|
167
|
+
loggedHours: 12
|
|
168
|
+
};
|
|
169
|
+
const overEstDerived = deriveTaskLifecycleState(overEstimateTask);
|
|
170
|
+
expect(overEstDerived.isOverEstimate).toBe(true);
|
|
171
|
+
|
|
172
|
+
// Pace warning: in_progress task that has been active longer than estimated duration
|
|
173
|
+
const refDate = new Date('2026-09-10T00:00:00.000Z');
|
|
174
|
+
const paceWarningTask: Task = {
|
|
175
|
+
...baseTask,
|
|
176
|
+
status: 'in_progress',
|
|
177
|
+
actualStartDate: '2026-09-01T00:00:00.000Z', // 9 days = 216 hours elapsed
|
|
178
|
+
estimatedHours: 40 // only 40 hours estimated
|
|
179
|
+
};
|
|
180
|
+
const paceDerived = deriveTaskLifecycleState(paceWarningTask, { referenceDate: refDate });
|
|
181
|
+
expect(paceDerived.isPaceWarning).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('Engine Integration', () => {
|
|
186
|
+
it('computes full lifecycle state with dependency graph integration', async () => {
|
|
187
|
+
const engine = new CriticalPathEngine();
|
|
188
|
+
const proj = await engine.createProject({ key: 'LIFECYCLE', name: 'Lifecycle Test' });
|
|
189
|
+
|
|
190
|
+
const taskA = await engine.createTask({
|
|
191
|
+
projectId: proj.id,
|
|
192
|
+
title: 'Task A (Dependency)',
|
|
193
|
+
status: 'todo',
|
|
194
|
+
priority: 'high'
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const taskB = await engine.createTask({
|
|
198
|
+
projectId: proj.id,
|
|
199
|
+
title: 'Task B (Dependent)',
|
|
200
|
+
status: 'todo',
|
|
201
|
+
priority: 'medium'
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await engine.store.addDependency({
|
|
205
|
+
taskId: taskB.id,
|
|
206
|
+
dependsOnTaskId: taskA.id,
|
|
207
|
+
type: 'blocking'
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Task B should be blocked by Task A
|
|
211
|
+
let stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
212
|
+
expect(stateB?.isBlocked).toBe(true);
|
|
213
|
+
expect(stateB?.isReady).toBe(false);
|
|
214
|
+
expect(stateB?.blockingTaskIds).toEqual([taskA.id]);
|
|
215
|
+
|
|
216
|
+
// Complete Task A
|
|
217
|
+
await engine.updateTask(taskA.id, { status: 'done' });
|
|
218
|
+
|
|
219
|
+
// Task B should now be ready
|
|
220
|
+
stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
221
|
+
expect(stateB?.isBlocked).toBe(false);
|
|
222
|
+
expect(stateB?.isReady).toBe(true);
|
|
223
|
+
expect(stateB?.blockingTaskIds).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -62,7 +62,7 @@ describe('FirebaseStore', () => {
|
|
|
62
62
|
name: 'Firebase Workflow',
|
|
63
63
|
isDefault: false,
|
|
64
64
|
defaultStatusKey: 'backlog',
|
|
65
|
-
statuses: [{ key: 'backlog', label: 'Backlog',
|
|
65
|
+
statuses: [{ key: 'backlog', label: 'Backlog', category: 'not_started' }],
|
|
66
66
|
transitions: []
|
|
67
67
|
});
|
|
68
68
|
|
package/src/store/sqlite.test.ts
CHANGED
|
@@ -155,7 +155,7 @@ describe('SQLiteStore', () => {
|
|
|
155
155
|
name: 'SQLite Workflow',
|
|
156
156
|
isDefault: true,
|
|
157
157
|
defaultStatusKey: 'todo',
|
|
158
|
-
statuses: [{ key: 'todo', label: 'To Do',
|
|
158
|
+
statuses: [{ key: 'todo', label: 'To Do', category: 'not_started' }],
|
|
159
159
|
transitions: []
|
|
160
160
|
});
|
|
161
161
|
|
package/src/types/index.ts
CHANGED
|
@@ -4,17 +4,33 @@ export type Priority = DefaultPriority | (string & {});
|
|
|
4
4
|
export type DefaultTaskStatus = 'backlog' | 'todo' | 'in_progress' | 'in_review' | 'done' | 'canceled';
|
|
5
5
|
export type TaskStatus = DefaultTaskStatus | (string & {});
|
|
6
6
|
|
|
7
|
-
export type
|
|
8
|
-
export type ExecutionState = 'active' | 'inactive';
|
|
7
|
+
export type SemanticStatus = 'not_started' | 'in_progress' | 'completed' | 'canceled';
|
|
9
8
|
|
|
10
9
|
export interface StatusDefinition {
|
|
11
10
|
key: string;
|
|
12
11
|
label: string;
|
|
13
|
-
|
|
14
|
-
executionState: ExecutionState;
|
|
15
|
-
isCancelled?: boolean;
|
|
12
|
+
category: SemanticStatus;
|
|
16
13
|
}
|
|
17
14
|
|
|
15
|
+
export interface TaskDerivedStatus {
|
|
16
|
+
semanticStatus: SemanticStatus;
|
|
17
|
+
isReady: boolean;
|
|
18
|
+
isBlocked: boolean;
|
|
19
|
+
blockingTaskIds: string[];
|
|
20
|
+
isOverdue: boolean;
|
|
21
|
+
isUpcoming: boolean;
|
|
22
|
+
isUnplanned: boolean;
|
|
23
|
+
isUnassigned: boolean;
|
|
24
|
+
isStalled: boolean;
|
|
25
|
+
isOverEstimate: boolean;
|
|
26
|
+
isPaceWarning: boolean;
|
|
27
|
+
isDone: boolean;
|
|
28
|
+
isActive: boolean;
|
|
29
|
+
isCancelled: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type TaskLifecycleState = TaskDerivedStatus;
|
|
33
|
+
|
|
18
34
|
export type Role = 'admin' | 'project_manager' | 'contributor' | 'viewer';
|
|
19
35
|
|
|
20
36
|
export interface User {
|
|
@@ -142,6 +158,7 @@ export interface Task {
|
|
|
142
158
|
title: string;
|
|
143
159
|
description?: string;
|
|
144
160
|
status: TaskStatus;
|
|
161
|
+
semanticStatus?: SemanticStatus;
|
|
145
162
|
priority: Priority;
|
|
146
163
|
taskType?: string; // e.g. "bug", "feature", "task", "epic"
|
|
147
164
|
assigneeId?: string;
|
|
@@ -138,7 +138,7 @@ describe('Critical Path Use Case Validation Suite', () => {
|
|
|
138
138
|
// Check Lifecycle State
|
|
139
139
|
const lifecycle = await engine.getTaskLifecycleState(featureTask.id);
|
|
140
140
|
expect(lifecycle?.isDone).toBe(true);
|
|
141
|
-
expect(lifecycle?.
|
|
141
|
+
expect(lifecycle?.semanticStatus).toBe('completed');
|
|
142
142
|
});
|
|
143
143
|
});
|
|
144
144
|
|
package/src/utils/status.ts
CHANGED
|
@@ -1,27 +1,24 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Task,
|
|
3
3
|
StatusDefinition,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
SemanticStatus,
|
|
5
|
+
TaskDerivedStatus,
|
|
6
6
|
} from '../types/index.js';
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_STATUS_DEFINITIONS: Record<string, StatusDefinition> = {
|
|
9
|
-
backlog: { key: 'backlog', label: 'Backlog',
|
|
10
|
-
todo: { key: 'todo', label: 'To Do',
|
|
11
|
-
in_progress: { key: 'in_progress', label: 'In Progress',
|
|
12
|
-
in_review: { key: 'in_review', label: 'In Review',
|
|
13
|
-
done: { key: 'done', label: 'Done',
|
|
14
|
-
canceled: { key: 'canceled', label: 'Canceled',
|
|
9
|
+
backlog: { key: 'backlog', label: 'Backlog', category: 'not_started' },
|
|
10
|
+
todo: { key: 'todo', label: 'To Do', category: 'not_started' },
|
|
11
|
+
in_progress: { key: 'in_progress', label: 'In Progress', category: 'in_progress' },
|
|
12
|
+
in_review: { key: 'in_review', label: 'In Review', category: 'in_progress' },
|
|
13
|
+
done: { key: 'done', label: 'Done', category: 'completed' },
|
|
14
|
+
canceled: { key: 'canceled', label: 'Canceled', category: 'canceled' }
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
export interface
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
isCancelled: boolean;
|
|
23
|
-
isOverdue: boolean;
|
|
24
|
-
isUpcoming: boolean;
|
|
17
|
+
export interface DeriveTaskLifecycleOptions {
|
|
18
|
+
customDefinitions?: StatusDefinition[];
|
|
19
|
+
referenceDate?: Date;
|
|
20
|
+
upstreamTasks?: Task[];
|
|
21
|
+
stalledThresholdDays?: number;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
export function resolveStatusDefinition(
|
|
@@ -35,28 +32,66 @@ export function resolveStatusDefinition(
|
|
|
35
32
|
if (DEFAULT_STATUS_DEFINITIONS[statusKey]) {
|
|
36
33
|
return DEFAULT_STATUS_DEFINITIONS[statusKey];
|
|
37
34
|
}
|
|
35
|
+
|
|
38
36
|
// Fallback heuristic for unknown custom statuses:
|
|
39
|
-
const
|
|
40
|
-
|
|
37
|
+
const lower = statusKey.toLowerCase();
|
|
38
|
+
let category: SemanticStatus = 'not_started';
|
|
39
|
+
if (['canceled', 'cancelled', 'rejected', 'abandoned', 'void', 'dropped'].some((k) => lower.includes(k))) {
|
|
40
|
+
category = 'canceled';
|
|
41
|
+
} else if (['done', 'completed', 'finished', 'closed', 'approved', 'final', 'delivered', 'shipped'].some((k) => lower.includes(k))) {
|
|
42
|
+
category = 'completed';
|
|
43
|
+
} else if (['in_progress', 'active', 'working', 'in_review', 'review', 'testing', 'qa', 'building', 'editing'].some((k) => lower.includes(k))) {
|
|
44
|
+
category = 'in_progress';
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
return {
|
|
42
48
|
key: statusKey,
|
|
43
49
|
label: statusKey,
|
|
44
|
-
|
|
45
|
-
executionState: isActiveKey ? 'active' : 'inactive',
|
|
46
|
-
isCancelled: statusKey.toLowerCase().includes('cancel')
|
|
50
|
+
category
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
export function deriveTaskLifecycleState(
|
|
51
55
|
task: Task,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
):
|
|
56
|
+
customDefinitionsOrOptions?: StatusDefinition[] | DeriveTaskLifecycleOptions,
|
|
57
|
+
legacyReferenceDate?: Date
|
|
58
|
+
): TaskDerivedStatus {
|
|
59
|
+
let customDefinitions: StatusDefinition[] | undefined;
|
|
60
|
+
let referenceDate = legacyReferenceDate ?? new Date();
|
|
61
|
+
let upstreamTasks: Task[] = [];
|
|
62
|
+
let stalledThresholdDays = 5;
|
|
63
|
+
|
|
64
|
+
if (Array.isArray(customDefinitionsOrOptions)) {
|
|
65
|
+
customDefinitions = customDefinitionsOrOptions;
|
|
66
|
+
} else if (customDefinitionsOrOptions) {
|
|
67
|
+
customDefinitions = customDefinitionsOrOptions.customDefinitions;
|
|
68
|
+
if (customDefinitionsOrOptions.referenceDate) {
|
|
69
|
+
referenceDate = customDefinitionsOrOptions.referenceDate;
|
|
70
|
+
}
|
|
71
|
+
if (customDefinitionsOrOptions.upstreamTasks) {
|
|
72
|
+
upstreamTasks = customDefinitionsOrOptions.upstreamTasks;
|
|
73
|
+
}
|
|
74
|
+
if (customDefinitionsOrOptions.stalledThresholdDays !== undefined) {
|
|
75
|
+
stalledThresholdDays = customDefinitionsOrOptions.stalledThresholdDays;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
55
79
|
const def = resolveStatusDefinition(task.status, customDefinitions);
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
80
|
+
const semanticStatus: SemanticStatus = def.category;
|
|
81
|
+
const isDone = semanticStatus === 'completed' || semanticStatus === 'canceled';
|
|
82
|
+
const isActive = semanticStatus === 'in_progress';
|
|
83
|
+
const isCancelled = semanticStatus === 'canceled';
|
|
59
84
|
|
|
85
|
+
// Dependency & Flow Indicators
|
|
86
|
+
const blockingTasks = upstreamTasks.filter((t) => {
|
|
87
|
+
const upstreamDef = resolveStatusDefinition(t.status, customDefinitions);
|
|
88
|
+
return upstreamDef.category !== 'completed';
|
|
89
|
+
});
|
|
90
|
+
const blockingTaskIds = blockingTasks.map((t) => t.id);
|
|
91
|
+
const isBlocked = !isDone && blockingTaskIds.length > 0;
|
|
92
|
+
const isReady = semanticStatus === 'not_started' && blockingTaskIds.length === 0;
|
|
93
|
+
|
|
94
|
+
// Time & Schedule Indicators
|
|
60
95
|
let isOverdue = false;
|
|
61
96
|
if (!isDone && task.dueDate) {
|
|
62
97
|
const due = new Date(task.dueDate);
|
|
@@ -66,20 +101,71 @@ export function deriveTaskLifecycleState(
|
|
|
66
101
|
}
|
|
67
102
|
|
|
68
103
|
let isUpcoming = false;
|
|
69
|
-
if (
|
|
104
|
+
if (semanticStatus === 'not_started' && task.plannedStartDate) {
|
|
70
105
|
const plannedStart = new Date(task.plannedStartDate);
|
|
71
106
|
if (!isNaN(plannedStart.getTime()) && plannedStart > referenceDate) {
|
|
72
107
|
isUpcoming = true;
|
|
73
108
|
}
|
|
74
109
|
}
|
|
75
110
|
|
|
111
|
+
const isUnplanned = semanticStatus === 'not_started' && !task.iterationId && !task.plannedStartDate && !task.dueDate;
|
|
112
|
+
|
|
113
|
+
// Resource & Activity Indicators
|
|
114
|
+
const hasAssignee = Boolean(task.assigneeId || (task.assignees && task.assignees.length > 0));
|
|
115
|
+
const isUnassigned = !isDone && !hasAssignee;
|
|
116
|
+
|
|
117
|
+
let isStalled = false;
|
|
118
|
+
if (semanticStatus === 'in_progress') {
|
|
119
|
+
const lastActivity = task.updatedAt ? new Date(task.updatedAt) : (task.createdAt ? new Date(task.createdAt) : null);
|
|
120
|
+
if (lastActivity && !isNaN(lastActivity.getTime())) {
|
|
121
|
+
const thresholdMs = stalledThresholdDays * 24 * 60 * 60 * 1000;
|
|
122
|
+
if (referenceDate.getTime() - lastActivity.getTime() > thresholdMs) {
|
|
123
|
+
isStalled = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Effort & Estimate Indicators
|
|
129
|
+
let isOverEstimate = false;
|
|
130
|
+
const loggedH = task.loggedHours ?? task.actualHours;
|
|
131
|
+
const estimatedH = task.estimatedHours;
|
|
132
|
+
if (estimatedH !== undefined && estimatedH > 0 && loggedH !== undefined && loggedH > estimatedH) {
|
|
133
|
+
isOverEstimate = true;
|
|
134
|
+
}
|
|
135
|
+
const actualM = task.actualDurationMinutes;
|
|
136
|
+
const estimatedM = task.estimatedDurationMinutes;
|
|
137
|
+
if (estimatedM !== undefined && estimatedM > 0 && actualM !== undefined && actualM > estimatedM) {
|
|
138
|
+
isOverEstimate = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let isPaceWarning = false;
|
|
142
|
+
if (semanticStatus === 'in_progress' && task.actualStartDate) {
|
|
143
|
+
const start = new Date(task.actualStartDate);
|
|
144
|
+
if (!isNaN(start.getTime())) {
|
|
145
|
+
const elapsedMs = referenceDate.getTime() - start.getTime();
|
|
146
|
+
const elapsedHours = elapsedMs / (1000 * 60 * 60);
|
|
147
|
+
if (estimatedH !== undefined && estimatedH > 0 && elapsedHours > estimatedH) {
|
|
148
|
+
isPaceWarning = true;
|
|
149
|
+
} else if (estimatedM !== undefined && estimatedM > 0 && (elapsedMs / (1000 * 60)) > estimatedM) {
|
|
150
|
+
isPaceWarning = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
76
155
|
return {
|
|
77
|
-
|
|
78
|
-
|
|
156
|
+
semanticStatus,
|
|
157
|
+
isReady,
|
|
158
|
+
isBlocked,
|
|
159
|
+
blockingTaskIds,
|
|
160
|
+
isOverdue,
|
|
161
|
+
isUpcoming,
|
|
162
|
+
isUnplanned,
|
|
163
|
+
isUnassigned,
|
|
164
|
+
isStalled,
|
|
165
|
+
isOverEstimate,
|
|
166
|
+
isPaceWarning,
|
|
79
167
|
isDone,
|
|
80
168
|
isActive,
|
|
81
|
-
isCancelled
|
|
82
|
-
isOverdue,
|
|
83
|
-
isUpcoming
|
|
169
|
+
isCancelled
|
|
84
170
|
};
|
|
85
171
|
}
|