@critical-path/core 0.19.1 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@critical-path/core",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Core headless domain engine, plugin architecture, and data stores for Critical Path",
5
5
  "author": "Jack James",
6
6
  "license": "MIT",
@@ -9,7 +9,8 @@ import {
9
9
  getWorkingHoursBetween,
10
10
  getWorkingDaysBetween,
11
11
  getWorkingDaysList,
12
- getNetAvailableCapacity
12
+ getNetAvailableCapacity,
13
+ formatTaskDuration
13
14
  } from './calendar.js';
14
15
  import type { WorkSchedule } from '../types/index.js';
15
16
 
@@ -161,4 +162,55 @@ describe('Calendar and Work Schedule Domain', () => {
161
162
  expect(cap).toBe(16);
162
163
  });
163
164
  });
165
+
166
+ describe('formatTaskDuration', () => {
167
+ it('handles null, undefined, NaN, and negative values', () => {
168
+ expect(formatTaskDuration(null)).toBe('0s');
169
+ expect(formatTaskDuration(undefined)).toBe('0s');
170
+ expect(formatTaskDuration(NaN)).toBe('0s');
171
+ expect(formatTaskDuration(-5)).toBe('0s');
172
+ expect(formatTaskDuration(0)).toBe('0s');
173
+ });
174
+
175
+ it('formats short durations under 120s with seconds', () => {
176
+ expect(formatTaskDuration(45)).toBe('45s');
177
+ expect(formatTaskDuration(110.3)).toBe('110.3s');
178
+ expect(formatTaskDuration(119.9)).toBe('119.9s');
179
+ expect(formatTaskDuration(1.5)).toBe('1.5s');
180
+ });
181
+
182
+ it('formats sub-day durations with hours and minutes', () => {
183
+ // 44 minutes = 2640s
184
+ expect(formatTaskDuration(2640)).toBe('0h 44m');
185
+ // 2h 15m = 8100s
186
+ expect(formatTaskDuration(8100)).toBe('2h 15m');
187
+ // 7h 59m = 28740s (< 8h working day)
188
+ expect(formatTaskDuration(28740)).toBe('7h 59m');
189
+ });
190
+
191
+ it('formats multi-day durations using default 8h working days', () => {
192
+ // 8 hours = 28800s -> 1 day
193
+ expect(formatTaskDuration(28800)).toBe('1 day');
194
+ // 12 hours = 43200s -> 1.5 days
195
+ expect(formatTaskDuration(43200)).toBe('1.5 days');
196
+ // 25.6 hours = 92160s -> 3.2 days
197
+ expect(formatTaskDuration(92160)).toBe('3.2 days');
198
+ });
199
+
200
+ it('supports calendar_days basis (24h per day)', () => {
201
+ // 12 hours on calendar days basis -> 12h 0m (< 24h)
202
+ expect(formatTaskDuration(43200, { dayBasis: 'calendar_days' })).toBe('12h 0m');
203
+ // 36 hours on calendar days basis -> 1.5 days
204
+ expect(formatTaskDuration(129600, { dayBasis: 'calendar_days' })).toBe('1.5 days');
205
+ });
206
+
207
+ it('supports custom work schedules', () => {
208
+ const fourHourDaySchedule: WorkSchedule = {
209
+ ...DEFAULT_WORK_SCHEDULE,
210
+ defaultHoursPerDay: 4
211
+ };
212
+ // 6 hours with 4h/day schedule = 1.5 days
213
+ expect(formatTaskDuration(21600, { schedule: fourHourDaySchedule })).toBe('1.5 days');
214
+ });
215
+ });
164
216
  });
@@ -455,3 +455,48 @@ export function getNetAvailableCapacity(
455
455
 
456
456
  return Math.round(totalCapacity * 100) / 100;
457
457
  }
458
+
459
+ export interface FormatDurationOptions {
460
+ /** Optional custom work schedule to determine working hours in a day */
461
+ schedule?: WorkSchedule;
462
+ /** 'working_days' uses schedule.defaultHoursPerDay (default 8h). 'calendar_days' uses 24h. Default: 'working_days' */
463
+ dayBasis?: 'working_days' | 'calendar_days';
464
+ }
465
+
466
+ /**
467
+ * Formats task execution duration dynamically based on magnitude:
468
+ * - < 120s: Seconds with 1 decimal place (e.g. '110.3s', '45s')
469
+ * - < 1 day (based on dayBasis): Hours and minutes (e.g. '0h 44m', '2h 15m')
470
+ * - >= 1 day: Days with 1 decimal place (e.g. '1.5 days', '3.2 days')
471
+ */
472
+ export function formatTaskDuration(
473
+ durationSeconds: number | null | undefined,
474
+ options?: FormatDurationOptions
475
+ ): string {
476
+ if (durationSeconds === null || durationSeconds === undefined || isNaN(durationSeconds) || durationSeconds <= 0) {
477
+ return '0s';
478
+ }
479
+
480
+ // Under 120 seconds: return seconds with 1 decimal place (or whole if integer)
481
+ if (durationSeconds < 120) {
482
+ const formatted = durationSeconds.toFixed(1);
483
+ return `${formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted}s`;
484
+ }
485
+
486
+ const hoursPerDay = options?.dayBasis === 'calendar_days'
487
+ ? 24
488
+ : (options?.schedule?.defaultHoursPerDay || DEFAULT_WORK_SCHEDULE.defaultHoursPerDay || 8);
489
+
490
+ const totalHours = durationSeconds / 3600;
491
+ if (totalHours >= hoursPerDay) {
492
+ const days = totalHours / hoursPerDay;
493
+ const formatted = days.toFixed(1);
494
+ const cleanDays = formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
495
+ return `${cleanDays} ${cleanDays === '1' ? 'day' : 'days'}`;
496
+ }
497
+
498
+ const hours = Math.floor(totalHours);
499
+ const minutes = Math.floor((durationSeconds % 3600) / 60);
500
+ return `${hours}h ${minutes}m`;
501
+ }
502
+
@@ -87,6 +87,8 @@ export class TaskEntity extends BaseEntity {
87
87
  public estimatedDurationMinutes?: number;
88
88
  public actualDurationMinutes?: number;
89
89
  public billableDurationMinutes?: number;
90
+ public actualDurationSeconds?: number;
91
+ public inProgressSince?: string | null;
90
92
  public progress?: number;
91
93
  public tags?: string[];
92
94
  public todos?: TaskTodoItem[];
@@ -121,6 +123,8 @@ export class TaskEntity extends BaseEntity {
121
123
  this.estimatedDurationMinutes = data.estimatedDurationMinutes;
122
124
  this.actualDurationMinutes = data.actualDurationMinutes;
123
125
  this.billableDurationMinutes = data.billableDurationMinutes;
126
+ this.actualDurationSeconds = data.actualDurationSeconds;
127
+ this.inProgressSince = data.inProgressSince;
124
128
  this.progress = data.progress ?? 0;
125
129
  this.tags = data.tags ? [...data.tags] : [];
126
130
  this.todos = data.todos ? data.todos.map((t) => ({ ...t })) : [];
@@ -141,6 +145,10 @@ export class TaskEntity extends BaseEntity {
141
145
 
142
146
  const status = input.status || options?.workflow?.defaultStatusKey || 'todo';
143
147
  const statusDef = resolveStatusDefinition(status, options?.workflow?.statuses);
148
+ const semanticStatus = input.semanticStatus || statusDef.category;
149
+ const actualStartDate = input.actualStartDate ?? (semanticStatus === 'in_progress' ? now : undefined);
150
+ const inProgressSince = input.inProgressSince ?? (semanticStatus === 'in_progress' ? now : undefined);
151
+ const actualEndDate = input.actualEndDate ?? ((semanticStatus === 'completed' || semanticStatus === 'canceled') ? now : undefined);
144
152
 
145
153
  const task = new TaskEntity({
146
154
  ...input,
@@ -148,7 +156,11 @@ export class TaskEntity extends BaseEntity {
148
156
  createdAt: now,
149
157
  updatedAt: now,
150
158
  status,
151
- semanticStatus: input.semanticStatus || statusDef.category,
159
+ semanticStatus,
160
+ actualStartDate,
161
+ inProgressSince,
162
+ actualEndDate,
163
+ actualDurationSeconds: input.actualDurationSeconds,
152
164
  priority: input.priority || 'medium',
153
165
  loggedHours: input.loggedHours ?? 0,
154
166
  progress: input.progress ?? 0,
@@ -190,16 +202,45 @@ export class TaskEntity extends BaseEntity {
190
202
  this.semanticStatus = statusDef.category;
191
203
 
192
204
  const prevStatusDef = resolveStatusDefinition(previousStatus, options?.statusDefs || workflow?.statuses);
193
- if ((prevStatusDef.category === 'completed' || prevStatusDef.category === 'canceled') &&
194
- (statusDef.category === 'in_progress' || statusDef.category === 'not_started')) {
195
- this.actualEndDate = undefined;
205
+
206
+ // 1. Leaving in_progress: finalize current session duration
207
+ if (prevStatusDef.category === 'in_progress' && statusDef.category !== 'in_progress') {
208
+ if (this.inProgressSince) {
209
+ const startMs = new Date(this.inProgressSince).getTime();
210
+ const endMs = new Date(now).getTime();
211
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
212
+ const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
213
+ this.actualDurationSeconds = (this.actualDurationSeconds || 0) + sessionSeconds;
214
+ this.actualHours = Math.round(((this.actualDurationSeconds || 0) / 3600) * 100) / 100;
215
+ this.actualDurationMinutes = Math.round((this.actualDurationSeconds || 0) / 60);
216
+ }
217
+ this.inProgressSince = null;
218
+ }
196
219
  }
197
220
 
198
- if (statusDef.category === 'in_progress' && !this.actualStartDate) {
199
- this.actualStartDate = now;
221
+ // 2. Entering in_progress: retain earliest start date & start session timer
222
+ if (statusDef.category === 'in_progress') {
223
+ if (!this.actualStartDate) {
224
+ this.actualStartDate = now;
225
+ }
226
+ if (!this.inProgressSince) {
227
+ this.inProgressSince = now;
228
+ }
229
+ if (prevStatusDef.category === 'completed' || prevStatusDef.category === 'canceled') {
230
+ this.actualEndDate = undefined;
231
+ }
232
+ } else if (
233
+ (prevStatusDef.category === 'completed' || prevStatusDef.category === 'canceled') &&
234
+ statusDef.category === 'not_started'
235
+ ) {
236
+ this.actualEndDate = undefined;
200
237
  }
238
+
239
+ // 3. Entering completed or canceled
201
240
  if (statusDef.category === 'completed' || statusDef.category === 'canceled') {
202
- this.actualEndDate = now;
241
+ if (!this.actualEndDate) {
242
+ this.actualEndDate = now;
243
+ }
203
244
  if (statusDef.category === 'completed' && this.progress !== undefined && this.progress < 100) {
204
245
  this.progress = 100;
205
246
  }
@@ -354,6 +395,13 @@ export class TaskEntity extends BaseEntity {
354
395
  }
355
396
 
356
397
  Object.assign(this, updates);
398
+ if (updates.actualDurationSeconds !== undefined && updates.actualHours === undefined) {
399
+ this.actualHours = Math.round((updates.actualDurationSeconds / 3600) * 100) / 100;
400
+ this.actualDurationMinutes = updates.actualDurationMinutes ?? Math.round(updates.actualDurationSeconds / 60);
401
+ } else if (updates.actualHours !== undefined && updates.actualDurationSeconds === undefined) {
402
+ this.actualDurationSeconds = Math.round(updates.actualHours * 3600);
403
+ this.actualDurationMinutes = updates.actualDurationMinutes ?? Math.round(updates.actualHours * 60);
404
+ }
357
405
  this.markUpdated();
358
406
 
359
407
  const event: TaskUpdatedEvent = {
@@ -401,6 +449,8 @@ export class TaskEntity extends BaseEntity {
401
449
  estimatedDurationMinutes: this.estimatedDurationMinutes,
402
450
  actualDurationMinutes: this.actualDurationMinutes,
403
451
  billableDurationMinutes: this.billableDurationMinutes,
452
+ actualDurationSeconds: this.actualDurationSeconds,
453
+ inProgressSince: this.inProgressSince,
404
454
  progress: this.progress,
405
455
  tags: this.tags ? [...this.tags] : [],
406
456
  todos: this.todos ? this.todos.map((t) => ({ ...t })) : [],
@@ -453,20 +453,32 @@ export function calculateTaskMetrics(
453
453
 
454
454
  // Compute Inferred Actuals
455
455
  let activeWorkingHours: number | undefined;
456
+ let calendarDurationHours: number | undefined;
457
+
456
458
  if (task.actualStartDate && task.actualEndDate) {
457
459
  const startMs = new Date(task.actualStartDate).getTime();
458
460
  const endMs = new Date(task.actualEndDate).getTime();
459
461
  if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
460
- activeWorkingHours = Math.round(((endMs - startMs) / (1000 * 60 * 60)) * 100) / 100;
462
+ calendarDurationHours = Math.round(((endMs - startMs) / (1000 * 60 * 60)) * 100) / 100;
461
463
  }
462
464
  }
463
465
 
466
+ if (task.actualDurationSeconds !== undefined) {
467
+ activeWorkingHours = Math.round((task.actualDurationSeconds / 3600) * 100) / 100;
468
+ } else if (task.actualHours !== undefined) {
469
+ activeWorkingHours = task.actualHours;
470
+ } else {
471
+ activeWorkingHours = calendarDurationHours;
472
+ }
473
+
464
474
  const inferredActuals: TaskInferredActuals = {
465
475
  actualStartDate: task.actualStartDate,
466
476
  actualEndDate: task.actualEndDate,
467
477
  isStartDateInferred: Boolean(task.actualStartDate && !task.plannedStartDate),
468
478
  isEndDateInferred: Boolean(task.actualEndDate && task.semanticStatus === 'completed'),
469
- activeWorkingHours
479
+ activeWorkingHours,
480
+ calendarDurationHours,
481
+ actualDurationSeconds: task.actualDurationSeconds
470
482
  };
471
483
 
472
484
  const progress = inferTaskProgress(task, options);
@@ -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
+ });
@@ -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,6 +401,8 @@ 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),
404
407
  tags: processedInput.tags ?? taskInput.tags ?? [],
405
408
  customFields: processedInput.customFields ?? taskInput.customFields ?? {},
@@ -515,17 +518,51 @@ export class CriticalPathEngine {
515
518
  existing.status,
516
519
  project?.statusDefinitions || workflow?.statuses
517
520
  );
518
- if (
521
+
522
+ // 1. Leaving in_progress: finalize active session duration
523
+ if (existingStatusDef.category === 'in_progress' && statusDef.category !== 'in_progress') {
524
+ const inProgressSince = existing.inProgressSince;
525
+ if (inProgressSince) {
526
+ const startMs = new Date(inProgressSince).getTime();
527
+ const endMs = new Date(now).getTime();
528
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
529
+ const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
530
+ processedUpdates.actualDurationSeconds =
531
+ (processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
532
+ if (processedUpdates.actualHours === undefined) {
533
+ processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
534
+ }
535
+ if (processedUpdates.actualDurationMinutes === undefined) {
536
+ processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
537
+ }
538
+ }
539
+ processedUpdates.inProgressSince = null;
540
+ }
541
+ }
542
+
543
+ // 2. Entering in_progress: retain earliest start date & start session timer
544
+ if (statusDef.category === 'in_progress') {
545
+ if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
546
+ processedUpdates.actualStartDate = now;
547
+ }
548
+ if (!processedUpdates.inProgressSince) {
549
+ processedUpdates.inProgressSince = now;
550
+ }
551
+ if (
552
+ (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
553
+ processedUpdates.actualEndDate === undefined
554
+ ) {
555
+ processedUpdates.actualEndDate = undefined;
556
+ }
557
+ } else if (
519
558
  (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
520
- (statusDef.category === 'in_progress' || statusDef.category === 'not_started') &&
559
+ statusDef.category === 'not_started' &&
521
560
  processedUpdates.actualEndDate === undefined
522
561
  ) {
523
562
  processedUpdates.actualEndDate = undefined;
524
563
  }
525
564
 
526
- if (statusDef.category === 'in_progress' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
527
- processedUpdates.actualStartDate = now;
528
- }
565
+ // 3. Entering completed or canceled
529
566
  if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
530
567
  processedUpdates.actualEndDate = now;
531
568
  if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
@@ -534,6 +571,19 @@ export class CriticalPathEngine {
534
571
  }
535
572
  }
536
573
 
574
+ // Mutual sync for manual duration/effort edits
575
+ if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
576
+ processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
577
+ if (processedUpdates.actualDurationMinutes === undefined) {
578
+ processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
579
+ }
580
+ } else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
581
+ processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
582
+ if (processedUpdates.actualDurationMinutes === undefined) {
583
+ processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
584
+ }
585
+ }
586
+
537
587
  const updated = await this.store.updateTask(id, processedUpdates);
538
588
  if (!updated) return null;
539
589
 
@@ -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, progress,
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 = ?, progress = ?,
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,
@@ -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 {
@@ -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"}