@critical-path/core 0.2.0 → 0.2.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 +6 -0
- package/dist/engine/index.d.ts +19 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +129 -6
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +96 -1
- package/dist/index.test.js.map +1 -1
- package/dist/store/firebase.d.ts +19 -7
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +95 -13
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +5 -2
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/index.d.ts +34 -8
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +76 -12
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +17 -4
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +176 -38
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +68 -8
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/status.d.ts +14 -0
- package/dist/utils/status.d.ts.map +1 -0
- package/dist/utils/status.js +58 -0
- package/dist/utils/status.js.map +1 -0
- package/package.json +5 -1
- package/src/engine/index.ts +161 -8
- package/src/index.test.ts +113 -1
- package/src/index.ts +1 -0
- package/src/store/firebase.test.ts +8 -2
- package/src/store/firebase.ts +109 -17
- package/src/store/index.ts +110 -18
- package/src/store/sqlite.ts +272 -44
- package/src/types/index.ts +74 -9
- package/src/utils/status.ts +85 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const DEFAULT_STATUS_DEFINITIONS = {
|
|
2
|
+
backlog: { key: 'backlog', label: 'Backlog', completionState: 'not_done', executionState: 'inactive' },
|
|
3
|
+
todo: { key: 'todo', label: 'To Do', completionState: 'not_done', executionState: 'inactive' },
|
|
4
|
+
in_progress: { key: 'in_progress', label: 'In Progress', completionState: 'not_done', executionState: 'active' },
|
|
5
|
+
in_review: { key: 'in_review', label: 'In Review', completionState: 'not_done', executionState: 'active' },
|
|
6
|
+
done: { key: 'done', label: 'Done', completionState: 'done', executionState: 'inactive' },
|
|
7
|
+
canceled: { key: 'canceled', label: 'Canceled', completionState: 'done', executionState: 'inactive', isCancelled: true }
|
|
8
|
+
};
|
|
9
|
+
export function resolveStatusDefinition(statusKey, customDefinitions) {
|
|
10
|
+
if (customDefinitions) {
|
|
11
|
+
const found = customDefinitions.find((def) => def.key === statusKey);
|
|
12
|
+
if (found)
|
|
13
|
+
return found;
|
|
14
|
+
}
|
|
15
|
+
if (DEFAULT_STATUS_DEFINITIONS[statusKey]) {
|
|
16
|
+
return DEFAULT_STATUS_DEFINITIONS[statusKey];
|
|
17
|
+
}
|
|
18
|
+
// Fallback heuristic for unknown custom statuses:
|
|
19
|
+
const isDoneKey = ['done', 'completed', 'finished', 'closed', 'canceled', 'cancelled'].includes(statusKey.toLowerCase());
|
|
20
|
+
const isActiveKey = ['in_progress', 'active', 'working', 'in_review', 'review'].includes(statusKey.toLowerCase());
|
|
21
|
+
return {
|
|
22
|
+
key: statusKey,
|
|
23
|
+
label: statusKey,
|
|
24
|
+
completionState: isDoneKey ? 'done' : 'not_done',
|
|
25
|
+
executionState: isActiveKey ? 'active' : 'inactive',
|
|
26
|
+
isCancelled: statusKey.toLowerCase().includes('cancel')
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function deriveTaskLifecycleState(task, customDefinitions, referenceDate = new Date()) {
|
|
30
|
+
const def = resolveStatusDefinition(task.status, customDefinitions);
|
|
31
|
+
const isDone = def.completionState === 'done';
|
|
32
|
+
const isActive = def.executionState === 'active';
|
|
33
|
+
const isCancelled = Boolean(def.isCancelled);
|
|
34
|
+
let isOverdue = false;
|
|
35
|
+
if (!isDone && task.dueDate) {
|
|
36
|
+
const due = new Date(task.dueDate);
|
|
37
|
+
if (!isNaN(due.getTime()) && due < referenceDate) {
|
|
38
|
+
isOverdue = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let isUpcoming = false;
|
|
42
|
+
if (!isDone && !isActive && task.plannedStartDate) {
|
|
43
|
+
const plannedStart = new Date(task.plannedStartDate);
|
|
44
|
+
if (!isNaN(plannedStart.getTime()) && plannedStart > referenceDate) {
|
|
45
|
+
isUpcoming = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
completionState: def.completionState,
|
|
50
|
+
executionState: def.executionState,
|
|
51
|
+
isDone,
|
|
52
|
+
isActive,
|
|
53
|
+
isCancelled,
|
|
54
|
+
isOverdue,
|
|
55
|
+
isUpcoming
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/utils/status.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@critical-path/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Core headless domain engine, plugin architecture, and data stores for Critical Path",
|
|
5
5
|
"author": "Jack James",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Pixerate/critical-path.git"
|
|
10
|
+
},
|
|
7
11
|
"type": "module",
|
|
8
12
|
"main": "./dist/index.js",
|
|
9
13
|
"types": "./dist/index.d.ts",
|
package/src/engine/index.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CriticalPathConfig,
|
|
3
|
+
Project,
|
|
4
|
+
Task,
|
|
5
|
+
TaskDependencyGraph,
|
|
6
|
+
Team,
|
|
7
|
+
TaskContainer,
|
|
8
|
+
Iteration,
|
|
9
|
+
WebhookEvent
|
|
10
|
+
} from '../types/index.js';
|
|
2
11
|
import { StorageAdapter, InMemoryStore } from '../store/index.js';
|
|
3
12
|
import { PluginRegistry } from '../plugins/index.js';
|
|
13
|
+
import { deriveTaskLifecycleState, type TaskLifecycleState } from '../utils/status.js';
|
|
4
14
|
|
|
5
15
|
export class CriticalPathEngine {
|
|
6
16
|
public readonly store: StorageAdapter;
|
|
@@ -30,6 +40,21 @@ export class CriticalPathEngine {
|
|
|
30
40
|
await this.store.createProject(p);
|
|
31
41
|
}
|
|
32
42
|
}
|
|
43
|
+
if (data.teams) {
|
|
44
|
+
for (const tm of data.teams) {
|
|
45
|
+
await this.store.createTeam(tm);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (data.containers) {
|
|
49
|
+
for (const c of data.containers) {
|
|
50
|
+
await this.store.createContainer(c);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (data.iterations) {
|
|
54
|
+
for (const it of data.iterations) {
|
|
55
|
+
await this.store.createIteration(it);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
33
58
|
if (data.tasks) {
|
|
34
59
|
for (const t of data.tasks) {
|
|
35
60
|
await this.store.createTask(t);
|
|
@@ -37,8 +62,7 @@ export class CriticalPathEngine {
|
|
|
37
62
|
}
|
|
38
63
|
}
|
|
39
64
|
|
|
40
|
-
// ---
|
|
41
|
-
|
|
65
|
+
// --- Projects ---
|
|
42
66
|
async getProjects(): Promise<Project[]> {
|
|
43
67
|
return this.store.getProjects();
|
|
44
68
|
}
|
|
@@ -59,6 +83,7 @@ export class CriticalPathEngine {
|
|
|
59
83
|
return created;
|
|
60
84
|
}
|
|
61
85
|
|
|
86
|
+
// --- Tasks ---
|
|
62
87
|
async getTasks(projectId?: string): Promise<Task[]> {
|
|
63
88
|
return this.store.getTasks(projectId);
|
|
64
89
|
}
|
|
@@ -68,7 +93,6 @@ export class CriticalPathEngine {
|
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
async createTask(taskInput: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task> {
|
|
71
|
-
// Run plugin beforeTaskCreate hooks
|
|
72
96
|
const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
|
|
73
97
|
|
|
74
98
|
const created = await this.store.createTask({
|
|
@@ -79,16 +103,27 @@ export class CriticalPathEngine {
|
|
|
79
103
|
priority: processedInput.priority || taskInput.priority || 'medium',
|
|
80
104
|
assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
|
|
81
105
|
reporterId: processedInput.reporterId ?? taskInput.reporterId,
|
|
82
|
-
|
|
106
|
+
reviewerId: processedInput.reviewerId ?? taskInput.reviewerId,
|
|
107
|
+
iterationId: processedInput.iterationId ?? taskInput.iterationId,
|
|
108
|
+
teamId: processedInput.teamId ?? taskInput.teamId,
|
|
109
|
+
containerId: processedInput.containerId ?? taskInput.containerId,
|
|
110
|
+
plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
|
|
111
|
+
actualStartDate: processedInput.actualStartDate ?? taskInput.actualStartDate,
|
|
112
|
+
actualEndDate: processedInput.actualEndDate ?? taskInput.actualEndDate,
|
|
83
113
|
dueDate: processedInput.dueDate ?? taskInput.dueDate,
|
|
84
114
|
estimatedHours: processedInput.estimatedHours ?? taskInput.estimatedHours,
|
|
85
115
|
loggedHours: processedInput.loggedHours ?? taskInput.loggedHours ?? 0,
|
|
116
|
+
actualHours: processedInput.actualHours ?? taskInput.actualHours,
|
|
117
|
+
billableHours: processedInput.billableHours ?? taskInput.billableHours,
|
|
118
|
+
estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
|
|
119
|
+
actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
|
|
120
|
+
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
121
|
+
progress: processedInput.progress ?? taskInput.progress ?? 0,
|
|
86
122
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
87
123
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
88
124
|
parentId: processedInput.parentId ?? taskInput.parentId
|
|
89
125
|
});
|
|
90
126
|
|
|
91
|
-
// Run plugin afterTaskCreate hooks
|
|
92
127
|
await this.plugins.runAfterTaskCreate(created);
|
|
93
128
|
|
|
94
129
|
await this.store.logActivity({
|
|
@@ -151,18 +186,136 @@ export class CriticalPathEngine {
|
|
|
151
186
|
return deleted;
|
|
152
187
|
}
|
|
153
188
|
|
|
189
|
+
// --- Task Dependencies & Graph ---
|
|
190
|
+
async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
|
|
191
|
+
const dependencies = await this.store.getDependencies(taskId);
|
|
192
|
+
const upstreamTaskIds = new Set<string>();
|
|
193
|
+
const downstreamTaskIds = new Set<string>();
|
|
194
|
+
|
|
195
|
+
for (const dep of dependencies) {
|
|
196
|
+
if (dep.taskId === taskId) {
|
|
197
|
+
upstreamTaskIds.add(dep.dependsOnTaskId);
|
|
198
|
+
} else if (dep.dependsOnTaskId === taskId) {
|
|
199
|
+
downstreamTaskIds.add(dep.taskId);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const upstreamTasks = (
|
|
204
|
+
await Promise.all(Array.from(upstreamTaskIds).map((id) => this.store.getTask(id)))
|
|
205
|
+
).filter((t): t is Task => t !== null);
|
|
206
|
+
|
|
207
|
+
const downstreamTasks = (
|
|
208
|
+
await Promise.all(Array.from(downstreamTaskIds).map((id) => this.store.getTask(id)))
|
|
209
|
+
).filter((t): t is Task => t !== null);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
taskId,
|
|
213
|
+
upstreamTasks,
|
|
214
|
+
downstreamTasks,
|
|
215
|
+
dependencies
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async getTaskLifecycleState(taskId: string): Promise<TaskLifecycleState | null> {
|
|
220
|
+
const task = await this.getTask(taskId);
|
|
221
|
+
if (!task) return null;
|
|
222
|
+
const project = await this.getProject(task.projectId);
|
|
223
|
+
return deriveTaskLifecycleState(task, project?.statusDefinitions);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- Teams ---
|
|
227
|
+
async getTeams(): Promise<Team[]> {
|
|
228
|
+
return this.store.getTeams();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async getTeam(id: string): Promise<Team | null> {
|
|
232
|
+
return this.store.getTeam(id);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
|
|
236
|
+
const created = await this.store.createTeam(team);
|
|
237
|
+
this.dispatchWebhook('team.created', { team: created });
|
|
238
|
+
return created;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
|
|
242
|
+
return this.store.updateTeam(id, updates);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async deleteTeam(id: string): Promise<boolean> {
|
|
246
|
+
return this.store.deleteTeam(id);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- Containers ---
|
|
250
|
+
async getContainers(projectId: string): Promise<TaskContainer[]> {
|
|
251
|
+
return this.store.getContainers(projectId);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async getContainer(id: string): Promise<TaskContainer | null> {
|
|
255
|
+
return this.store.getContainer(id);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
|
|
259
|
+
const created = await this.store.createContainer(container);
|
|
260
|
+
this.dispatchWebhook('container.created', { container: created });
|
|
261
|
+
return created;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null> {
|
|
265
|
+
return this.store.updateContainer(id, updates);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async deleteContainer(id: string): Promise<boolean> {
|
|
269
|
+
return this.store.deleteContainer(id);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- Iterations ---
|
|
273
|
+
async getIterations(projectId: string): Promise<Iteration[]> {
|
|
274
|
+
return this.store.getIterations(projectId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async getIteration(id: string): Promise<Iteration | null> {
|
|
278
|
+
return this.store.getIteration(id);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
|
|
282
|
+
const created = await this.store.createIteration(iteration);
|
|
283
|
+
if (created.status === 'active') {
|
|
284
|
+
this.dispatchWebhook('iteration.started', { iteration: created });
|
|
285
|
+
}
|
|
286
|
+
return created;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
|
|
290
|
+
const existing = await this.store.getIteration(id);
|
|
291
|
+
if (!existing) return null;
|
|
292
|
+
|
|
293
|
+
const updated = await this.store.updateIteration(id, updates);
|
|
294
|
+
if (updated) {
|
|
295
|
+
if (existing.status !== 'active' && updated.status === 'active') {
|
|
296
|
+
this.dispatchWebhook('iteration.started', { iteration: updated });
|
|
297
|
+
} else if (existing.status !== 'completed' && updated.status === 'completed') {
|
|
298
|
+
this.dispatchWebhook('iteration.completed', { iteration: updated });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return updated;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async deleteIteration(id: string): Promise<boolean> {
|
|
305
|
+
return this.store.deleteIteration(id);
|
|
306
|
+
}
|
|
307
|
+
|
|
154
308
|
private async dispatchWebhook(event: WebhookEvent, payload: Record<string, unknown>): Promise<void> {
|
|
155
309
|
const webhooks = await this.store.getWebhooks();
|
|
156
310
|
const active = webhooks.filter((w) => w.active && w.events.includes(event));
|
|
157
311
|
|
|
158
312
|
for (const wh of active) {
|
|
159
|
-
// Fire-and-forget webhook post
|
|
160
313
|
fetch(wh.url, {
|
|
161
314
|
method: 'POST',
|
|
162
315
|
headers: { 'Content-Type': 'application/json' },
|
|
163
316
|
body: JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload })
|
|
164
317
|
}).catch(() => {
|
|
165
|
-
// Silent catch for webhook
|
|
318
|
+
// Silent catch for webhook errors in dev
|
|
166
319
|
});
|
|
167
320
|
}
|
|
168
321
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { CriticalPathEngine } from './
|
|
2
|
+
import { CriticalPathEngine, deriveTaskLifecycleState } from './index.js';
|
|
3
3
|
import type { CriticalPathPlugin } from './types/index.js';
|
|
4
4
|
|
|
5
5
|
describe('CriticalPathEngine Core Tests', () => {
|
|
@@ -60,4 +60,116 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
60
60
|
expect(hookExecuted).toBe(true);
|
|
61
61
|
expect(task.tags).toContain('auto-tagged');
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
it('handles teams, containers, and iterations', async () => {
|
|
65
|
+
const engine = new CriticalPathEngine();
|
|
66
|
+
|
|
67
|
+
const team = await engine.createTeam({
|
|
68
|
+
name: 'Engineering',
|
|
69
|
+
memberIds: ['user_1', 'user_2']
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const proj = await engine.createProject({
|
|
73
|
+
key: 'ENG',
|
|
74
|
+
name: 'Engineering Project',
|
|
75
|
+
teamIds: [team.id]
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const container = await engine.createContainer({
|
|
79
|
+
projectId: proj.id,
|
|
80
|
+
name: 'Core Platform Epic',
|
|
81
|
+
type: 'epic'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const iteration = await engine.createIteration({
|
|
85
|
+
projectId: proj.id,
|
|
86
|
+
name: 'Sprint 1',
|
|
87
|
+
type: 'sprint',
|
|
88
|
+
status: 'active'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const task = await engine.createTask({
|
|
92
|
+
projectId: proj.id,
|
|
93
|
+
title: 'Setup Database Schema',
|
|
94
|
+
status: 'in_progress',
|
|
95
|
+
priority: 'urgent',
|
|
96
|
+
reviewerId: 'user_2',
|
|
97
|
+
teamId: team.id,
|
|
98
|
+
containerId: container.id,
|
|
99
|
+
iterationId: iteration.id,
|
|
100
|
+
plannedStartDate: '2026-09-01T00:00:00.000Z',
|
|
101
|
+
dueDate: '2026-09-15T00:00:00.000Z',
|
|
102
|
+
estimatedHours: 10,
|
|
103
|
+
actualHours: 8,
|
|
104
|
+
billableHours: 8,
|
|
105
|
+
estimatedDurationMinutes: 600,
|
|
106
|
+
actualDurationMinutes: 480,
|
|
107
|
+
billableDurationMinutes: 480,
|
|
108
|
+
progress: 80
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(task.reviewerId).toBe('user_2');
|
|
112
|
+
expect(task.teamId).toBe(team.id);
|
|
113
|
+
expect(task.containerId).toBe(container.id);
|
|
114
|
+
expect(task.iterationId).toBe(iteration.id);
|
|
115
|
+
expect(task.estimatedHours).toBe(10);
|
|
116
|
+
expect(task.actualHours).toBe(8);
|
|
117
|
+
expect(task.billableHours).toBe(8);
|
|
118
|
+
expect(task.estimatedDurationMinutes).toBe(600);
|
|
119
|
+
expect(task.actualDurationMinutes).toBe(480);
|
|
120
|
+
expect(task.billableDurationMinutes).toBe(480);
|
|
121
|
+
expect(task.progress).toBe(80);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('evaluates status definitions and task lifecycle state', async () => {
|
|
125
|
+
const engine = new CriticalPathEngine();
|
|
126
|
+
|
|
127
|
+
const proj = await engine.createProject({
|
|
128
|
+
key: 'CUST',
|
|
129
|
+
name: 'Custom Workflow Project',
|
|
130
|
+
statusDefinitions: [
|
|
131
|
+
{ key: 'draft', label: 'Draft', completionState: 'not_done', executionState: 'inactive' },
|
|
132
|
+
{ key: 'active_work', label: 'Active Work', completionState: 'not_done', executionState: 'active' },
|
|
133
|
+
{ key: 'finished', label: 'Finished', completionState: 'done', executionState: 'inactive' }
|
|
134
|
+
]
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const task = await engine.createTask({
|
|
138
|
+
projectId: proj.id,
|
|
139
|
+
title: 'Workflow Task',
|
|
140
|
+
status: 'active_work',
|
|
141
|
+
priority: 'medium',
|
|
142
|
+
plannedStartDate: '2026-08-01T00:00:00.000Z',
|
|
143
|
+
dueDate: '2026-08-10T00:00:00.000Z'
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const state = await engine.getTaskLifecycleState(task.id);
|
|
147
|
+
expect(state).not.toBeNull();
|
|
148
|
+
expect(state?.completionState).toBe('not_done');
|
|
149
|
+
expect(state?.executionState).toBe('active');
|
|
150
|
+
expect(state?.isActive).toBe(true);
|
|
151
|
+
expect(state?.isDone).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('builds task dependency graph with upstream and downstream tasks', async () => {
|
|
155
|
+
const engine = new CriticalPathEngine();
|
|
156
|
+
|
|
157
|
+
const proj = await engine.createProject({ key: 'DEP', name: 'Dependency Project' });
|
|
158
|
+
|
|
159
|
+
const taskA = await engine.createTask({ projectId: proj.id, title: 'Task A', status: 'todo', priority: 'medium' });
|
|
160
|
+
const taskB = await engine.createTask({ projectId: proj.id, title: 'Task B', status: 'todo', priority: 'medium' });
|
|
161
|
+
|
|
162
|
+
// Task B depends on Task A
|
|
163
|
+
await engine.store.addDependency({
|
|
164
|
+
taskId: taskB.id,
|
|
165
|
+
dependsOnTaskId: taskA.id,
|
|
166
|
+
type: 'blocking'
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const graphA = await engine.getTaskDependencyGraph(taskA.id);
|
|
170
|
+
expect(graphA.downstreamTasks.map((t) => t.id)).toContain(taskB.id);
|
|
171
|
+
|
|
172
|
+
const graphB = await engine.getTaskDependencyGraph(taskB.id);
|
|
173
|
+
expect(graphB.upstreamTasks.map((t) => t.id)).toContain(taskA.id);
|
|
174
|
+
});
|
|
63
175
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { FirebaseStore } from './firebase.js';
|
|
2
|
+
import { FirebaseStore, InMemoryFirestoreMock } from './firebase.js';
|
|
3
3
|
|
|
4
4
|
describe('FirebaseStore', () => {
|
|
5
5
|
let store: FirebaseStore;
|
|
6
6
|
|
|
7
7
|
beforeEach(() => {
|
|
8
|
-
store = new FirebaseStore();
|
|
8
|
+
store = new FirebaseStore({ db: new InMemoryFirestoreMock() });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should throw an error if db is missing in config', () => {
|
|
12
|
+
expect(() => new FirebaseStore({} as any)).toThrow(
|
|
13
|
+
'FirebaseStore requires a valid Firestore db instance'
|
|
14
|
+
);
|
|
9
15
|
});
|
|
10
16
|
|
|
11
17
|
it('should create and retrieve projects in FirebaseStore', async () => {
|
package/src/store/firebase.ts
CHANGED
|
@@ -2,7 +2,9 @@ import type { StorageAdapter } from './index.js';
|
|
|
2
2
|
import type {
|
|
3
3
|
Project,
|
|
4
4
|
Task,
|
|
5
|
-
|
|
5
|
+
Iteration,
|
|
6
|
+
Team,
|
|
7
|
+
TaskContainer,
|
|
6
8
|
Comment,
|
|
7
9
|
TimeEntry,
|
|
8
10
|
Activity,
|
|
@@ -92,16 +94,21 @@ export class InMemoryFirestoreMock implements FirestoreDBInterface {
|
|
|
92
94
|
|
|
93
95
|
export interface FirebaseStoreConfig {
|
|
94
96
|
/**
|
|
95
|
-
* Firestore instance or mockable DB interface.
|
|
97
|
+
* Firestore instance or mockable DB interface. Required.
|
|
96
98
|
*/
|
|
97
|
-
db
|
|
99
|
+
db: FirestoreDBInterface;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export class FirebaseStore implements StorageAdapter {
|
|
101
103
|
private db: FirestoreDBInterface;
|
|
102
104
|
|
|
103
|
-
constructor(config: FirebaseStoreConfig
|
|
104
|
-
|
|
105
|
+
constructor(config: FirebaseStoreConfig) {
|
|
106
|
+
if (!config?.db) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
'FirebaseStore requires a valid Firestore db instance (e.g. FirebaseStoreConfig.db). Automatic fallback to InMemoryFirestoreMock has been removed.'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
this.db = config.db;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
// --- Projects ---
|
|
@@ -144,6 +151,78 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
144
151
|
return true;
|
|
145
152
|
}
|
|
146
153
|
|
|
154
|
+
// --- Teams ---
|
|
155
|
+
async getTeams(): Promise<Team[]> {
|
|
156
|
+
const snap = await this.db.collection('teams').get();
|
|
157
|
+
return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async getTeam(id: string): Promise<Team | null> {
|
|
161
|
+
const snap = await this.db.collection('teams').doc(id).get();
|
|
162
|
+
if (!snap.exists) return null;
|
|
163
|
+
return { ...snap.data(), id: snap.id };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
|
|
167
|
+
const docRef = this.db.collection('teams').doc();
|
|
168
|
+
const now = new Date().toISOString();
|
|
169
|
+
const newTeam: Team = { ...team, id: docRef.id, createdAt: now, updatedAt: now };
|
|
170
|
+
await docRef.set(newTeam);
|
|
171
|
+
return newTeam;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
|
|
175
|
+
const existing = await this.getTeam(id);
|
|
176
|
+
if (!existing) return null;
|
|
177
|
+
|
|
178
|
+
const updated: Team = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
|
179
|
+
await this.db.collection('teams').doc(id).set(updated, { merge: true });
|
|
180
|
+
return updated;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async deleteTeam(id: string): Promise<boolean> {
|
|
184
|
+
const existing = await this.getTeam(id);
|
|
185
|
+
if (!existing) return false;
|
|
186
|
+
await this.db.collection('teams').doc(id).delete();
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- Containers ---
|
|
191
|
+
async getContainers(projectId: string): Promise<TaskContainer[]> {
|
|
192
|
+
const snap = await this.db.collection('containers').where('projectId', '==', projectId).get();
|
|
193
|
+
return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async getContainer(id: string): Promise<TaskContainer | null> {
|
|
197
|
+
const snap = await this.db.collection('containers').doc(id).get();
|
|
198
|
+
if (!snap.exists) return null;
|
|
199
|
+
return { ...snap.data(), id: snap.id };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
|
|
203
|
+
const docRef = this.db.collection('containers').doc();
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
const newContainer: TaskContainer = { ...container, id: docRef.id, createdAt: now, updatedAt: now };
|
|
206
|
+
await docRef.set(newContainer);
|
|
207
|
+
return newContainer;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null> {
|
|
211
|
+
const existing = await this.getContainer(id);
|
|
212
|
+
if (!existing) return null;
|
|
213
|
+
|
|
214
|
+
const updated: TaskContainer = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
|
215
|
+
await this.db.collection('containers').doc(id).set(updated, { merge: true });
|
|
216
|
+
return updated;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async deleteContainer(id: string): Promise<boolean> {
|
|
220
|
+
const existing = await this.getContainer(id);
|
|
221
|
+
if (!existing) return false;
|
|
222
|
+
await this.db.collection('containers').doc(id).delete();
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
147
226
|
// --- Tasks ---
|
|
148
227
|
async getTasks(projectId?: string): Promise<Task[]> {
|
|
149
228
|
let snap;
|
|
@@ -189,29 +268,42 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
189
268
|
return true;
|
|
190
269
|
}
|
|
191
270
|
|
|
192
|
-
// ---
|
|
193
|
-
async
|
|
194
|
-
const snap = await this.db.collection('
|
|
271
|
+
// --- Iterations ---
|
|
272
|
+
async getIterations(projectId: string): Promise<Iteration[]> {
|
|
273
|
+
const snap = await this.db.collection('iterations').where('projectId', '==', projectId).get();
|
|
195
274
|
return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
|
|
196
275
|
}
|
|
197
276
|
|
|
198
|
-
async
|
|
199
|
-
const
|
|
277
|
+
async getIteration(id: string): Promise<Iteration | null> {
|
|
278
|
+
const snap = await this.db.collection('iterations').doc(id).get();
|
|
279
|
+
if (!snap.exists) return null;
|
|
280
|
+
return { ...snap.data(), id: snap.id };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
|
|
284
|
+
const docRef = this.db.collection('iterations').doc();
|
|
200
285
|
const now = new Date().toISOString();
|
|
201
|
-
const
|
|
202
|
-
await docRef.set(
|
|
203
|
-
return
|
|
286
|
+
const newIteration: Iteration = { ...iteration, id: docRef.id, createdAt: now };
|
|
287
|
+
await docRef.set(newIteration);
|
|
288
|
+
return newIteration;
|
|
204
289
|
}
|
|
205
290
|
|
|
206
|
-
async
|
|
207
|
-
const snap = await this.db.collection('
|
|
291
|
+
async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
|
|
292
|
+
const snap = await this.db.collection('iterations').doc(id).get();
|
|
208
293
|
if (!snap.exists) return null;
|
|
209
294
|
|
|
210
|
-
const updated:
|
|
211
|
-
await this.db.collection('
|
|
295
|
+
const updated: Iteration = { ...snap.data(), id, ...updates };
|
|
296
|
+
await this.db.collection('iterations').doc(id).set(updated, { merge: true });
|
|
212
297
|
return updated;
|
|
213
298
|
}
|
|
214
299
|
|
|
300
|
+
async deleteIteration(id: string): Promise<boolean> {
|
|
301
|
+
const existing = await this.getIteration(id);
|
|
302
|
+
if (!existing) return false;
|
|
303
|
+
await this.db.collection('iterations').doc(id).delete();
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
215
307
|
// --- Comments & Activity ---
|
|
216
308
|
async getComments(taskId: string): Promise<Comment[]> {
|
|
217
309
|
const snap = await this.db.collection('comments').where('taskId', '==', taskId).get();
|