@critical-path/core 0.15.2 → 0.16.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -0
  3. package/dist/domain/cpm.d.ts +10 -0
  4. package/dist/domain/cpm.d.ts.map +1 -0
  5. package/dist/domain/cpm.js +176 -0
  6. package/dist/domain/cpm.js.map +1 -0
  7. package/dist/domain/cpm.test.d.ts +2 -0
  8. package/dist/domain/cpm.test.d.ts.map +1 -0
  9. package/dist/domain/cpm.test.js +113 -0
  10. package/dist/domain/cpm.test.js.map +1 -0
  11. package/dist/domain/index.d.ts +2 -0
  12. package/dist/domain/index.d.ts.map +1 -1
  13. package/dist/domain/index.js +2 -0
  14. package/dist/domain/index.js.map +1 -1
  15. package/dist/domain/ladder.d.ts +16 -0
  16. package/dist/domain/ladder.d.ts.map +1 -0
  17. package/dist/domain/ladder.js +338 -0
  18. package/dist/domain/ladder.js.map +1 -0
  19. package/dist/domain/ladder.test.d.ts +2 -0
  20. package/dist/domain/ladder.test.d.ts.map +1 -0
  21. package/dist/domain/ladder.test.js +213 -0
  22. package/dist/domain/ladder.test.js.map +1 -0
  23. package/dist/engine/index.d.ts +5 -1
  24. package/dist/engine/index.d.ts.map +1 -1
  25. package/dist/engine/index.js +84 -1
  26. package/dist/engine/index.js.map +1 -1
  27. package/dist/store/firebase.d.ts +3 -1
  28. package/dist/store/firebase.d.ts.map +1 -1
  29. package/dist/store/firebase.js +1 -1
  30. package/dist/store/firebase.js.map +1 -1
  31. package/dist/store/index.d.ts +6 -2
  32. package/dist/store/index.d.ts.map +1 -1
  33. package/dist/store/index.js +1 -1
  34. package/dist/store/index.js.map +1 -1
  35. package/dist/store/sqlite.d.ts +3 -1
  36. package/dist/store/sqlite.d.ts.map +1 -1
  37. package/dist/store/sqlite.js +1 -1
  38. package/dist/store/sqlite.js.map +1 -1
  39. package/dist/types/index.d.ts +123 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/domain/cpm.test.ts +128 -0
  43. package/src/domain/cpm.ts +215 -0
  44. package/src/domain/index.ts +3 -0
  45. package/src/domain/ladder.test.ts +251 -0
  46. package/src/domain/ladder.ts +437 -0
  47. package/src/engine/index.ts +110 -4
  48. package/src/store/firebase.ts +2 -2
  49. package/src/store/index.ts +3 -3
  50. package/src/store/sqlite.ts +2 -2
  51. package/src/types/index.ts +138 -0
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,437 @@
1
+ import type {
2
+ Project,
3
+ Task,
4
+ TaskDependency,
5
+ TaskContainer,
6
+ Iteration,
7
+ Deliverable,
8
+ Attachment,
9
+ TimeEntry,
10
+ Activity,
11
+ TimelineLadder,
12
+ TimelineLadderOptions,
13
+ MacroTimelineSummary,
14
+ MacroPhaseRollup,
15
+ MacroPhaseHealth,
16
+ StandardTaskTimelineItem,
17
+ ConcreteTaskEvidence,
18
+ ConcreteEvidenceSummary,
19
+ RealityDelta,
20
+ TaskLadderView
21
+ } from '../types/index.js';
22
+ import { calculateCPM, getTaskDurationHours } from './cpm.js';
23
+
24
+ export interface LadderContext {
25
+ project: Project;
26
+ tasks: Task[];
27
+ dependencies: TaskDependency[];
28
+ containers?: TaskContainer[];
29
+ iterations?: Iteration[];
30
+ deliverables?: Deliverable[];
31
+ attachments?: Attachment[];
32
+ timeEntries?: TimeEntry[];
33
+ activities?: Activity[];
34
+ }
35
+
36
+ export function aggregateConcreteEvidenceForTask(
37
+ task: Task,
38
+ attachments: Attachment[] = [],
39
+ deliverables: Deliverable[] = [],
40
+ timeEntries: TimeEntry[] = [],
41
+ activities: Activity[] = []
42
+ ): ConcreteTaskEvidence {
43
+ const taskAttachments = attachments.filter((a) => a.taskId === task.id);
44
+ const taskDeliverables = deliverables.filter(
45
+ (d) => d.id === task.deliverableId || (task.customFields?.deliverableId === d.id)
46
+ );
47
+ const taskTimeEntries = timeEntries.filter((te) => te.taskId === task.id);
48
+ const taskActivities = activities.filter((act) => act.taskId === task.id);
49
+ const todos = task.todos || [];
50
+
51
+ // Group time entries by date for daily effort distribution
52
+ const effortByDate = new Map<string, number>();
53
+ for (const entry of taskTimeEntries) {
54
+ const dateStr = entry.loggedAt.includes('T') ? entry.loggedAt.split('T')[0] : entry.loggedAt.substring(0, 10);
55
+ const existing = effortByDate.get(dateStr) || 0;
56
+ effortByDate.set(dateStr, Math.round((existing + entry.hours) * 100) / 100);
57
+ }
58
+
59
+ const dailyEffortDistribution = Array.from(effortByDate.entries())
60
+ .map(([date, hours]) => ({ date, hours }))
61
+ .sort((a, b) => a.date.localeCompare(b.date));
62
+
63
+ const loggedHoursFromEntries = taskTimeEntries.reduce((sum, e) => sum + e.hours, 0);
64
+ const effectiveLoggedHours = Math.max(loggedHoursFromEntries, task.loggedHours || 0, task.actualHours || 0);
65
+ const estimatedHours = task.estimatedHours || (task.estimatedDurationMinutes ? task.estimatedDurationMinutes / 60 : 0);
66
+ const varianceHours = Math.round((effectiveLoggedHours - estimatedHours) * 100) / 100;
67
+
68
+ const nowIso = new Date().toISOString();
69
+ const isOverdue = Boolean(task.dueDate && task.dueDate < nowIso && task.semanticStatus !== 'completed' && task.semanticStatus !== 'canceled');
70
+ const isOverEstimate = Boolean(estimatedHours > 0 && effectiveLoggedHours > estimatedHours);
71
+
72
+ let scheduleVarianceDays: number | undefined;
73
+ if (task.dueDate && task.actualEndDate) {
74
+ const dueTime = new Date(task.dueDate).getTime();
75
+ const actualTime = new Date(task.actualEndDate).getTime();
76
+ scheduleVarianceDays = Math.round((actualTime - dueTime) / (1000 * 60 * 60 * 24));
77
+ }
78
+
79
+ const realityDelta: RealityDelta = {
80
+ plannedStartDate: task.plannedStartDate,
81
+ actualStartDate: task.actualStartDate,
82
+ dueDate: task.dueDate,
83
+ actualEndDate: task.actualEndDate,
84
+ estimatedHours: Math.round(estimatedHours * 100) / 100,
85
+ loggedHours: Math.round(effectiveLoggedHours * 100) / 100,
86
+ varianceHours,
87
+ scheduleVarianceDays,
88
+ isOverdue,
89
+ isOverEstimate
90
+ };
91
+
92
+ const hasVisualAsset = taskAttachments.some(
93
+ (a) => a.mimeType?.startsWith('image/') || a.mimeType?.startsWith('video/')
94
+ ) || taskDeliverables.some((d) => (d.outputUrls && d.outputUrls.length > 0) || Boolean(d.format));
95
+
96
+ const timestamps = [
97
+ ...taskActivities.map((a) => a.createdAt),
98
+ ...taskTimeEntries.map((te) => te.loggedAt),
99
+ task.updatedAt
100
+ ].filter(Boolean);
101
+ timestamps.sort();
102
+ const lastActivityAt = timestamps.length > 0 ? timestamps[timestamps.length - 1] : undefined;
103
+
104
+ const completedTodoCount = todos.filter((t) => t.completed).length;
105
+
106
+ const evidenceSummary: ConcreteEvidenceSummary = {
107
+ attachmentCount: taskAttachments.length,
108
+ deliverableCount: taskDeliverables.length,
109
+ todoCount: todos.length,
110
+ completedTodoCount,
111
+ timeEntryCount: taskTimeEntries.length,
112
+ totalLoggedHours: Math.round(effectiveLoggedHours * 100) / 100,
113
+ hasVisualAsset,
114
+ lastActivityAt
115
+ };
116
+
117
+ return {
118
+ taskId: task.id,
119
+ attachments: taskAttachments,
120
+ deliverables: taskDeliverables,
121
+ todos,
122
+ timeEntries: taskTimeEntries,
123
+ dailyEffortDistribution,
124
+ activities: taskActivities,
125
+ realityDelta,
126
+ evidenceSummary
127
+ };
128
+ }
129
+
130
+ export function computeMacroPhases(
131
+ tasks: Task[],
132
+ containers: TaskContainer[] = [],
133
+ iterations: Iteration[] = [],
134
+ criticalTaskIds: Set<string>
135
+ ): MacroPhaseRollup[] {
136
+ const containerMap = new Map<string, TaskContainer>(containers.map((c) => [c.id, c]));
137
+ const iterationMap = new Map<string, Iteration>(iterations.map((it) => [it.id, it]));
138
+
139
+ // Group tasks by container if any exist; fallback to iteration or unassigned
140
+ const phaseBuckets = new Map<string, { type: 'container' | 'iteration' | 'phase'; name: string; description?: string; tasks: Task[] }>();
141
+
142
+ if (containers.length > 0) {
143
+ for (const c of containers) {
144
+ phaseBuckets.set(c.id, {
145
+ type: 'container',
146
+ name: c.name,
147
+ description: c.description,
148
+ tasks: []
149
+ });
150
+ }
151
+ } else if (iterations.length > 0) {
152
+ for (const it of iterations) {
153
+ phaseBuckets.set(it.id, {
154
+ type: 'iteration',
155
+ name: it.name,
156
+ description: it.goal,
157
+ tasks: []
158
+ });
159
+ }
160
+ }
161
+
162
+ // Populate tasks into buckets
163
+ for (const t of tasks) {
164
+ let bucketId: string | null = null;
165
+ if (t.containerId && phaseBuckets.has(t.containerId)) {
166
+ bucketId = t.containerId;
167
+ } else if (t.iterationId && phaseBuckets.has(t.iterationId)) {
168
+ bucketId = t.iterationId;
169
+ }
170
+
171
+ if (!bucketId) {
172
+ const fallbackId = 'unassigned';
173
+ if (!phaseBuckets.has(fallbackId)) {
174
+ phaseBuckets.set(fallbackId, {
175
+ type: 'phase',
176
+ name: containers.length > 0 || iterations.length > 0 ? 'General Tasks' : 'All Tasks',
177
+ tasks: []
178
+ });
179
+ }
180
+ bucketId = fallbackId;
181
+ }
182
+
183
+ phaseBuckets.get(bucketId)!.tasks.push(t);
184
+ }
185
+
186
+ const nowIso = new Date().toISOString();
187
+ const phases: MacroPhaseRollup[] = [];
188
+
189
+ for (const [id, bucket] of phaseBuckets.entries()) {
190
+ const bucketTasks = bucket.tasks;
191
+ if (bucketTasks.length === 0 && id === 'unassigned') continue;
192
+
193
+ const taskIds = bucketTasks.map((t) => t.id);
194
+ const totalTasks = bucketTasks.length;
195
+ const completedTasks = bucketTasks.filter((t) => t.semanticStatus === 'completed' || t.status === 'done').length;
196
+ const inProgressTasks = bucketTasks.filter((t) => t.semanticStatus === 'in_progress' || t.status === 'in_progress').length;
197
+ const blockedTasks = bucketTasks.filter((t) => t.tags?.includes('blocked') || (t as any).isBlocked).length;
198
+
199
+ let totalEstimatedHours = 0;
200
+ let totalLoggedHours = 0;
201
+ let weightedProgressSum = 0;
202
+ let totalProgressWeight = 0;
203
+
204
+ const startDates: string[] = [];
205
+ const endDates: string[] = [];
206
+
207
+ // Inherit container/iteration dates if set
208
+ if (bucket.type === 'iteration' && iterationMap.has(id)) {
209
+ const it = iterationMap.get(id)!;
210
+ if (it.startDate) startDates.push(it.startDate);
211
+ if (it.endDate) endDates.push(it.endDate);
212
+ }
213
+
214
+ let criticalCount = 0;
215
+
216
+ for (const t of bucketTasks) {
217
+ const duration = getTaskDurationHours(t);
218
+ const est = t.estimatedHours || duration;
219
+ const logged = t.loggedHours || 0;
220
+ totalEstimatedHours += est;
221
+ totalLoggedHours += logged;
222
+
223
+ const progress = typeof t.progress === 'number'
224
+ ? t.progress
225
+ : (t.semanticStatus === 'completed' || t.status === 'done' ? 100 : 0);
226
+
227
+ weightedProgressSum += progress * Math.max(est, 1);
228
+ totalProgressWeight += Math.max(est, 1);
229
+
230
+ if (t.plannedStartDate) startDates.push(t.plannedStartDate);
231
+ if (t.actualStartDate) startDates.push(t.actualStartDate);
232
+ if (t.dueDate) endDates.push(t.dueDate);
233
+ if (t.actualEndDate) endDates.push(t.actualEndDate);
234
+
235
+ if (criticalTaskIds.has(t.id)) {
236
+ criticalCount++;
237
+ }
238
+ }
239
+
240
+ startDates.sort();
241
+ endDates.sort();
242
+
243
+ const startDate = startDates.length > 0 ? startDates[0] : undefined;
244
+ const endDate = endDates.length > 0 ? endDates[endDates.length - 1] : undefined;
245
+
246
+ const progressPercentage = totalProgressWeight > 0
247
+ ? Math.round(weightedProgressSum / totalProgressWeight)
248
+ : (totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0);
249
+
250
+ // Derive health
251
+ let health: MacroPhaseHealth = 'on_track';
252
+ if (totalTasks > 0 && completedTasks === totalTasks) {
253
+ health = 'completed';
254
+ } else if (blockedTasks > 0) {
255
+ health = 'blocked';
256
+ } else if (endDate && endDate < nowIso) {
257
+ health = 'overdue';
258
+ } else if (criticalCount > 0 && progressPercentage < 50 && endDate && (new Date(endDate).getTime() - Date.now() < 3 * 24 * 3600 * 1000)) {
259
+ health = 'at_risk';
260
+ }
261
+
262
+ phases.push({
263
+ id,
264
+ type: bucket.type,
265
+ name: bucket.name,
266
+ description: bucket.description,
267
+ startDate,
268
+ endDate,
269
+ durationHours: Math.round(totalEstimatedHours * 100) / 100,
270
+ totalTasks,
271
+ completedTasks,
272
+ inProgressTasks,
273
+ blockedTasks,
274
+ progressPercentage,
275
+ totalEstimatedHours: Math.round(totalEstimatedHours * 100) / 100,
276
+ totalLoggedHours: Math.round(totalLoggedHours * 100) / 100,
277
+ isCritical: criticalCount > 0,
278
+ criticalTaskCount: criticalCount,
279
+ health,
280
+ taskIds
281
+ });
282
+ }
283
+
284
+ return phases;
285
+ }
286
+
287
+ export function buildTimelineLadder(
288
+ context: LadderContext,
289
+ options: TimelineLadderOptions = {}
290
+ ): TimelineLadder {
291
+ const { project, tasks, dependencies, containers = [], iterations = [], deliverables = [], attachments = [], timeEntries = [], activities = [] } = context;
292
+
293
+ const targetLevel = options.level || 'all';
294
+ const generatedAt = new Date().toISOString();
295
+
296
+ // Run Critical Path Method (CPM)
297
+ const cpm = calculateCPM(project.id, tasks, dependencies);
298
+ const criticalSet = new Set(cpm.criticalTaskIds);
299
+ const scheduleMap = new Map(cpm.tasks.map((s) => [s.taskId, s]));
300
+
301
+ // Adjacency for dependencies
302
+ const blockingMap = new Map<string, string[]>();
303
+ const dependentMap = new Map<string, string[]>();
304
+ for (const t of tasks) {
305
+ blockingMap.set(t.id, []);
306
+ dependentMap.set(t.id, []);
307
+ }
308
+
309
+ for (const dep of dependencies) {
310
+ if (dep.type === 'blocking') {
311
+ // dep.taskId depends on dep.dependsOnTaskId
312
+ // dep.dependsOnTaskId blocks dep.taskId
313
+ blockingMap.get(dep.taskId)?.push(dep.dependsOnTaskId);
314
+ dependentMap.get(dep.dependsOnTaskId)?.push(dep.taskId);
315
+ } else if (dep.type === 'blocked_by') {
316
+ blockingMap.get(dep.dependsOnTaskId)?.push(dep.taskId);
317
+ dependentMap.get(dep.taskId)?.push(dep.dependsOnTaskId);
318
+ }
319
+ }
320
+
321
+ // Subtask hierarchy (parentId -> children)
322
+ const childMap = new Map<string, string[]>();
323
+ for (const t of tasks) {
324
+ if (t.parentId) {
325
+ const list = childMap.get(t.parentId) || [];
326
+ list.push(t.id);
327
+ childMap.set(t.parentId, list);
328
+ }
329
+ }
330
+
331
+ // 1. Concrete View (Grounding)
332
+ const concreteRecords: Record<string, ConcreteTaskEvidence> = {};
333
+ for (const t of tasks) {
334
+ concreteRecords[t.id] = aggregateConcreteEvidenceForTask(
335
+ t,
336
+ attachments,
337
+ deliverables,
338
+ timeEntries,
339
+ activities
340
+ );
341
+ }
342
+
343
+ // 2. Standard View (Tasks & CPM)
344
+ const standardTasks: StandardTaskTimelineItem[] = tasks.map((t) => {
345
+ const evidence = concreteRecords[t.id];
346
+ return {
347
+ ...t,
348
+ cpm: scheduleMap.get(t.id),
349
+ blockingTaskIds: blockingMap.get(t.id) || [],
350
+ dependentTaskIds: dependentMap.get(t.id) || [],
351
+ childTaskIds: childMap.get(t.id) || [],
352
+ concreteEvidenceSummary: evidence.evidenceSummary
353
+ };
354
+ });
355
+
356
+ // 3. Macro View (Phases & Project Envelope)
357
+ const phases = computeMacroPhases(tasks, containers, iterations, criticalSet);
358
+
359
+ const startDates = phases.map((p) => p.startDate).filter(Boolean) as string[];
360
+ const endDates = phases.map((p) => p.endDate).filter(Boolean) as string[];
361
+ startDates.sort();
362
+ endDates.sort();
363
+
364
+ const overallStartDate = startDates.length > 0 ? startDates[0] : undefined;
365
+ const overallEndDate = endDates.length > 0 ? endDates[endDates.length - 1] : undefined;
366
+
367
+ const totalTasks = tasks.length;
368
+ const completedTasks = tasks.filter((t) => t.semanticStatus === 'completed' || t.status === 'done').length;
369
+ const inProgressTasks = tasks.filter((t) => t.semanticStatus === 'in_progress' || t.status === 'in_progress').length;
370
+ const blockedTasks = tasks.filter((t) => t.tags?.includes('blocked') || (t as any).isBlocked).length;
371
+ const totalEstimatedHours = standardTasks.reduce((sum, t) => sum + (t.estimatedHours || 0), 0);
372
+ const totalLoggedHours = standardTasks.reduce((sum, t) => sum + (t.loggedHours || 0), 0);
373
+
374
+ const weightedProgress = standardTasks.reduce(
375
+ (sum, t) => sum + ((t.progress || 0) * Math.max(t.estimatedHours || 1, 1)),
376
+ 0
377
+ );
378
+ const totalWeight = standardTasks.reduce((sum, t) => sum + Math.max(t.estimatedHours || 1, 1), 0);
379
+ const overallProgressPercentage = totalWeight > 0
380
+ ? Math.round(weightedProgress / totalWeight)
381
+ : (totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0);
382
+
383
+ let overallHealth: MacroPhaseHealth = 'on_track';
384
+ if (totalTasks > 0 && completedTasks === totalTasks) {
385
+ overallHealth = 'completed';
386
+ } else if (phases.some((p) => p.health === 'blocked')) {
387
+ overallHealth = 'blocked';
388
+ } else if (phases.some((p) => p.health === 'overdue')) {
389
+ overallHealth = 'overdue';
390
+ } else if (phases.some((p) => p.health === 'at_risk')) {
391
+ overallHealth = 'at_risk';
392
+ }
393
+
394
+ const macro: MacroTimelineSummary = {
395
+ projectId: project.id,
396
+ projectName: project.name,
397
+ overallStartDate,
398
+ overallEndDate,
399
+ projectedFinishDate: overallEndDate,
400
+ totalDurationHours: Math.round(totalEstimatedHours * 100) / 100,
401
+ criticalPathDurationHours: cpm.totalDurationHours,
402
+ overallProgressPercentage,
403
+ health: overallHealth,
404
+ totalTasks,
405
+ completedTasks,
406
+ inProgressTasks,
407
+ blockedTasks,
408
+ totalEstimatedHours: Math.round(totalEstimatedHours * 100) / 100,
409
+ totalLoggedHours: Math.round(totalLoggedHours * 100) / 100,
410
+ phases
411
+ };
412
+
413
+ const ladder: TimelineLadder = {
414
+ projectId: project.id,
415
+ generatedAt,
416
+ level: targetLevel
417
+ };
418
+
419
+ if (targetLevel === 'all' || targetLevel === 'macro') {
420
+ ladder.macro = macro;
421
+ }
422
+
423
+ if (targetLevel === 'all' || targetLevel === 'standard') {
424
+ ladder.standard = {
425
+ tasks: standardTasks,
426
+ criticalPathTaskIds: cpm.criticalTaskIds,
427
+ dependencies,
428
+ totalDurationHours: cpm.totalDurationHours
429
+ };
430
+ }
431
+
432
+ if (targetLevel === 'all' || targetLevel === 'concrete') {
433
+ ladder.concrete = concreteRecords;
434
+ }
435
+
436
+ return ladder;
437
+ }
@@ -21,7 +21,14 @@ import type {
21
21
  Deliverable,
22
22
  DeliverableSummary,
23
23
  CreateDeliverableInput,
24
- TaskLifecycleState
24
+ TaskLifecycleState,
25
+ CriticalPathAnalysis,
26
+ TimelineLadder,
27
+ TimelineLadderOptions,
28
+ TaskLadderView,
29
+ MacroPhaseRollup,
30
+ StandardTaskTimelineItem,
31
+ ConcreteTaskEvidence
25
32
  } from '../types/index.js';
26
33
  import { StorageAdapter, InMemoryStore } from '../store/index.js';
27
34
  import { PluginRegistry } from '../plugins/index.js';
@@ -68,6 +75,8 @@ import {
68
75
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
69
76
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
70
77
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
78
+ import { calculateCPM } from '../domain/cpm.js';
79
+ import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
71
80
 
72
81
  export class CriticalPathEngine {
73
82
  public readonly store: StorageAdapter;
@@ -709,7 +718,7 @@ export class CriticalPathEngine {
709
718
  }
710
719
 
711
720
  // --- Time Tracking ---
712
- async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string }): Promise<TimeEntry> {
721
+ async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string; loggedAt?: string }): Promise<TimeEntry> {
713
722
  if (entry.hours <= 0) {
714
723
  throw new Error('Logged hours must be a positive number.');
715
724
  }
@@ -726,9 +735,10 @@ export class CriticalPathEngine {
726
735
  });
727
736
  }
728
737
 
729
- const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> = {
738
+ const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string } = {
730
739
  ...entry,
731
- userId: entry.userId || task?.assigneeId || 'system'
740
+ userId: entry.userId || task?.assigneeId || 'system',
741
+ loggedAt: entry.loggedAt
732
742
  };
733
743
 
734
744
  const created = await this.store.logTime(fullEntry);
@@ -1345,6 +1355,101 @@ export class CriticalPathEngine {
1345
1355
  return this.store.deleteIteration(id);
1346
1356
  }
1347
1357
 
1358
+ // --- Bret Victor's Ladder of Abstraction & Critical Path Method ---
1359
+ async calculateCriticalPath(projectId: string): Promise<CriticalPathAnalysis> {
1360
+ const tasks = await this.store.getTasks(projectId);
1361
+ const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
1362
+ const seenDepIds = new Set<string>();
1363
+ const dependencies: TaskDependency[] = [];
1364
+ for (const deps of allDepArrays) {
1365
+ for (const d of deps) {
1366
+ if (!seenDepIds.has(d.id)) {
1367
+ seenDepIds.add(d.id);
1368
+ dependencies.push(d);
1369
+ }
1370
+ }
1371
+ }
1372
+ return calculateCPM(projectId, tasks, dependencies);
1373
+ }
1374
+
1375
+ async getTimelineLadder(
1376
+ projectId: string,
1377
+ options: TimelineLadderOptions = {}
1378
+ ): Promise<TimelineLadder> {
1379
+ const project = await this.store.getProject(projectId);
1380
+ if (!project) {
1381
+ throw new Error(`Project with ID "${projectId}" not found.`);
1382
+ }
1383
+
1384
+ let tasks = await this.store.getTasks(projectId);
1385
+ if (options.containerId) {
1386
+ tasks = tasks.filter((t) => t.containerId === options.containerId);
1387
+ }
1388
+ if (options.iterationId) {
1389
+ tasks = tasks.filter((t) => t.iterationId === options.iterationId);
1390
+ }
1391
+
1392
+ const [containers, iterations, deliverables, attachments, activities] = await Promise.all([
1393
+ this.store.getContainers(projectId),
1394
+ this.store.getIterations(projectId),
1395
+ this.store.getDeliverables(projectId),
1396
+ this.store.getAttachments({ projectId }),
1397
+ this.store.getActivities({ projectId })
1398
+ ]);
1399
+
1400
+ const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
1401
+ const seenDepIds = new Set<string>();
1402
+ const dependencies: TaskDependency[] = [];
1403
+ for (const deps of allDepArrays) {
1404
+ for (const d of deps) {
1405
+ if (!seenDepIds.has(d.id)) {
1406
+ seenDepIds.add(d.id);
1407
+ dependencies.push(d);
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
1413
+ const timeEntries: TimeEntry[] = timeEntriesNested.flat();
1414
+
1415
+ return buildTimelineLadder(
1416
+ {
1417
+ project,
1418
+ tasks,
1419
+ dependencies,
1420
+ containers,
1421
+ iterations,
1422
+ deliverables,
1423
+ attachments,
1424
+ timeEntries,
1425
+ activities
1426
+ },
1427
+ options
1428
+ );
1429
+ }
1430
+
1431
+ async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
1432
+ const task = await this.store.getTask(taskId);
1433
+ if (!task) return null;
1434
+
1435
+ const project = await this.store.getProject(task.projectId);
1436
+ if (!project) return null;
1437
+
1438
+ const ladder = await this.getTimelineLadder(task.projectId, { level: 'all' });
1439
+ const standard = ladder.standard?.tasks.find((t) => t.id === taskId);
1440
+ if (!standard) return null;
1441
+
1442
+ const concrete = ladder.concrete?.[taskId] || aggregateConcreteEvidenceForTask(task);
1443
+ const macroPhase = ladder.macro?.phases.find((p) => p.taskIds.includes(taskId));
1444
+
1445
+ return {
1446
+ taskId,
1447
+ macroPhase,
1448
+ standard,
1449
+ concrete
1450
+ };
1451
+ }
1452
+
1348
1453
  private async dispatchWebhook(event: WebhookEvent, payload: Record<string, unknown>): Promise<void> {
1349
1454
  const webhooks = await this.store.getWebhooks();
1350
1455
  const active = webhooks.filter((w) => w.active && w.events.includes(event));
@@ -1360,3 +1465,4 @@ export class CriticalPathEngine {
1360
1465
  }
1361
1466
  }
1362
1467
  }
1468
+
@@ -536,9 +536,9 @@ export class FirebaseStore implements StorageAdapter {
536
536
  return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
537
537
  }
538
538
 
539
- async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'>): Promise<TimeEntry> {
539
+ async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry> {
540
540
  const docRef = this.db.collection('time_entries').doc();
541
- const now = new Date().toISOString();
541
+ const now = entry.loggedAt || new Date().toISOString();
542
542
  const newEntry: TimeEntry = { ...entry, id: docRef.id, loggedAt: now };
543
543
  await docRef.set(sanitizeFirestoreData(newEntry));
544
544
  return newEntry;
@@ -91,7 +91,7 @@ export interface ActivityRepository {
91
91
 
92
92
  export interface TimeEntryRepository {
93
93
  getTimeEntries(taskId: string): Promise<TimeEntry[]>;
94
- logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'>): Promise<TimeEntry>;
94
+ logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry>;
95
95
  }
96
96
 
97
97
  export interface DependencyRepository {
@@ -512,9 +512,9 @@ export class InMemoryStore implements StorageAdapter {
512
512
  return Array.from(this.timeEntries.values()).filter((e) => e.taskId === taskId);
513
513
  }
514
514
 
515
- async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'>): Promise<TimeEntry> {
515
+ async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry> {
516
516
  const id = `time_${Math.random().toString(36).substring(2, 9)}`;
517
- const newEntry: TimeEntry = { ...entry, id, loggedAt: new Date().toISOString() };
517
+ const newEntry: TimeEntry = { ...entry, id, loggedAt: entry.loggedAt || new Date().toISOString() };
518
518
  this.timeEntries.set(id, newEntry);
519
519
  return newEntry;
520
520
  }
@@ -1084,9 +1084,9 @@ export class SQLiteStore implements StorageAdapter {
1084
1084
  return rows.map((r) => ({ ...r, isBillable: r.isBillable !== null ? Boolean(r.isBillable) : undefined }));
1085
1085
  }
1086
1086
 
1087
- async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'>): Promise<TimeEntry> {
1087
+ async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry> {
1088
1088
  const id = `time_${Math.random().toString(36).substring(2, 9)}`;
1089
- const now = new Date().toISOString();
1089
+ const now = entry.loggedAt || new Date().toISOString();
1090
1090
  const newEntry: TimeEntry = { ...entry, id, loggedAt: now };
1091
1091
 
1092
1092
  const stmt = this.db.prepare(`