@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/CHANGELOG.md +13 -0
- package/dist/domain/calendar.d.ts +13 -0
- package/dist/domain/calendar.d.ts.map +1 -1
- package/dist/domain/calendar.js +29 -0
- package/dist/domain/calendar.js.map +1 -1
- package/dist/domain/calendar.test.js +46 -1
- package/dist/domain/calendar.test.js.map +1 -1
- package/dist/domain/entities.d.ts +2 -0
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +55 -7
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/metrics.d.ts.map +1 -1
- package/dist/domain/metrics.js +14 -2
- package/dist/domain/metrics.js.map +1 -1
- package/dist/domain/task-duration.test.d.ts +2 -0
- package/dist/domain/task-duration.test.d.ts.map +1 -0
- package/dist/domain/task-duration.test.js +146 -0
- package/dist/domain/task-duration.test.js.map +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +52 -5
- package/dist/engine/index.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +9 -5
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/calendar.test.ts +53 -1
- package/src/domain/calendar.ts +45 -0
- package/src/domain/entities.ts +57 -7
- package/src/domain/metrics.ts +14 -2
- package/src/domain/task-duration.test.ts +170 -0
- package/src/engine/index.ts +55 -5
- package/src/store/sqlite.ts +11 -3
- package/src/types/index.ts +8 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -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
|
});
|
package/src/domain/calendar.ts
CHANGED
|
@@ -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
|
+
|
package/src/domain/entities.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
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 })) : [],
|
package/src/domain/metrics.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/engine/index.ts
CHANGED
|
@@ -371,6 +371,7 @@ export class CriticalPathEngine {
|
|
|
371
371
|
const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
|
|
372
372
|
const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
|
|
373
373
|
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
374
|
+
const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
374
375
|
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
|
|
375
376
|
|
|
376
377
|
const created = await this.store.createTask({
|
|
@@ -400,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
|
-
|
|
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
|
-
|
|
559
|
+
statusDef.category === 'not_started' &&
|
|
521
560
|
processedUpdates.actualEndDate === undefined
|
|
522
561
|
) {
|
|
523
562
|
processedUpdates.actualEndDate = undefined;
|
|
524
563
|
}
|
|
525
564
|
|
|
526
|
-
|
|
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
|
|
package/src/store/sqlite.ts
CHANGED
|
@@ -131,6 +131,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
131
131
|
estimatedDurationMinutes REAL,
|
|
132
132
|
actualDurationMinutes REAL,
|
|
133
133
|
billableDurationMinutes REAL,
|
|
134
|
+
actualDurationSeconds REAL,
|
|
135
|
+
inProgressSince TEXT,
|
|
134
136
|
progress REAL,
|
|
135
137
|
isBlocked INTEGER,
|
|
136
138
|
blockedReason TEXT,
|
|
@@ -660,10 +662,11 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
660
662
|
id, projectId, title, description, status, priority, taskType, assigneeId, assignees, reporterId,
|
|
661
663
|
reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
|
|
662
664
|
dueDate, estimatedHours, loggedHours, actualHours, billableHours,
|
|
663
|
-
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
|
|
665
|
+
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
|
|
666
|
+
actualDurationSeconds, inProgressSince, progress,
|
|
664
667
|
isBlocked, blockedReason,
|
|
665
668
|
tags, customFields, parentId, createdAt, updatedAt
|
|
666
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
669
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
667
670
|
`);
|
|
668
671
|
stmt.run(
|
|
669
672
|
newTask.id,
|
|
@@ -692,6 +695,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
692
695
|
newTask.estimatedDurationMinutes ?? null,
|
|
693
696
|
newTask.actualDurationMinutes ?? null,
|
|
694
697
|
newTask.billableDurationMinutes ?? null,
|
|
698
|
+
newTask.actualDurationSeconds ?? null,
|
|
699
|
+
newTask.inProgressSince ?? null,
|
|
695
700
|
newTask.progress ?? null,
|
|
696
701
|
newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
|
|
697
702
|
newTask.blockedReason ?? null,
|
|
@@ -720,7 +725,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
720
725
|
assigneeId = ?, assignees = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?, deliverableId = ?,
|
|
721
726
|
plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
|
|
722
727
|
estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
|
|
723
|
-
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
|
|
728
|
+
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
|
|
729
|
+
actualDurationSeconds = ?, inProgressSince = ?, progress = ?,
|
|
724
730
|
isBlocked = ?, blockedReason = ?,
|
|
725
731
|
tags = ?, customFields = ?, parentId = ?, updatedAt = ?
|
|
726
732
|
WHERE id = ?
|
|
@@ -751,6 +757,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
751
757
|
updated.estimatedDurationMinutes ?? null,
|
|
752
758
|
updated.actualDurationMinutes ?? null,
|
|
753
759
|
updated.billableDurationMinutes ?? null,
|
|
760
|
+
updated.actualDurationSeconds ?? null,
|
|
761
|
+
updated.inProgressSince ?? null,
|
|
754
762
|
updated.progress ?? null,
|
|
755
763
|
updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
|
|
756
764
|
updated.blockedReason ?? null,
|
package/src/types/index.ts
CHANGED
|
@@ -214,6 +214,10 @@ export interface Task {
|
|
|
214
214
|
estimatedDurationMinutes?: number;
|
|
215
215
|
actualDurationMinutes?: number;
|
|
216
216
|
billableDurationMinutes?: number;
|
|
217
|
+
/** Cumulative active execution time spent in 'in_progress' status (in seconds) */
|
|
218
|
+
actualDurationSeconds?: number;
|
|
219
|
+
/** Transient ISO timestamp marking the start of current 'in_progress' session (null when not in_progress) */
|
|
220
|
+
inProgressSince?: string | null;
|
|
217
221
|
// Progress (0 to 100 percentage)
|
|
218
222
|
progress?: number;
|
|
219
223
|
isBlocked?: boolean;
|
|
@@ -626,6 +630,10 @@ export interface TaskInferredActuals {
|
|
|
626
630
|
isStartDateInferred: boolean;
|
|
627
631
|
isEndDateInferred: boolean;
|
|
628
632
|
activeWorkingHours?: number;
|
|
633
|
+
/** Calendar elapsed hours between actualStartDate and actualEndDate */
|
|
634
|
+
calendarDurationHours?: number;
|
|
635
|
+
/** Cumulative active execution duration in seconds */
|
|
636
|
+
actualDurationSeconds?: number;
|
|
629
637
|
}
|
|
630
638
|
|
|
631
639
|
export interface TaskMetrics {
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
|