@critical-path/core 0.36.0 → 0.37.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 (214) hide show
  1. package/README.md +1 -1
  2. package/dist/domain/authorization.d.ts.map +1 -1
  3. package/dist/domain/authorization.js +10 -7
  4. package/dist/domain/authorization.js.map +1 -1
  5. package/dist/engine/index.d.ts +6 -0
  6. package/dist/engine/index.d.ts.map +1 -1
  7. package/dist/engine/index.js +28 -2
  8. package/dist/engine/index.js.map +1 -1
  9. package/dist/schemas/index.d.ts +42 -12
  10. package/dist/schemas/index.d.ts.map +1 -1
  11. package/dist/schemas/index.js +4 -1
  12. package/dist/schemas/index.js.map +1 -1
  13. package/dist/store/firebase.d.ts +2 -2
  14. package/dist/store/firebase.d.ts.map +1 -1
  15. package/dist/store/firebase.js +4 -2
  16. package/dist/store/firebase.js.map +1 -1
  17. package/dist/store/index.d.ts +7 -2
  18. package/dist/store/index.d.ts.map +1 -1
  19. package/dist/store/index.js +3 -2
  20. package/dist/store/index.js.map +1 -1
  21. package/dist/store/sqlite.d.ts +2 -2
  22. package/dist/store/sqlite.d.ts.map +1 -1
  23. package/dist/store/sqlite.js +5 -3
  24. package/dist/store/sqlite.js.map +1 -1
  25. package/dist/testing/conformance.d.ts.map +1 -1
  26. package/dist/testing/conformance.js +9 -0
  27. package/dist/testing/conformance.js.map +1 -1
  28. package/dist/types/index.d.ts +13 -2
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/package.json +5 -1
  31. package/CHANGELOG.md +0 -593
  32. package/dist/deliverables.test.d.ts +0 -2
  33. package/dist/deliverables.test.d.ts.map +0 -1
  34. package/dist/deliverables.test.js +0 -232
  35. package/dist/deliverables.test.js.map +0 -1
  36. package/dist/domain/calendar.test.d.ts +0 -2
  37. package/dist/domain/calendar.test.d.ts.map +0 -1
  38. package/dist/domain/calendar.test.js +0 -181
  39. package/dist/domain/calendar.test.js.map +0 -1
  40. package/dist/domain/cpm.test.d.ts +0 -2
  41. package/dist/domain/cpm.test.d.ts.map +0 -1
  42. package/dist/domain/cpm.test.js +0 -166
  43. package/dist/domain/cpm.test.js.map +0 -1
  44. package/dist/domain/ddd.test.d.ts +0 -2
  45. package/dist/domain/ddd.test.d.ts.map +0 -1
  46. package/dist/domain/ddd.test.js +0 -349
  47. package/dist/domain/ddd.test.js.map +0 -1
  48. package/dist/domain/ladder.test.d.ts +0 -2
  49. package/dist/domain/ladder.test.d.ts.map +0 -1
  50. package/dist/domain/ladder.test.js +0 -213
  51. package/dist/domain/ladder.test.js.map +0 -1
  52. package/dist/domain/metrics.test.d.ts +0 -2
  53. package/dist/domain/metrics.test.d.ts.map +0 -1
  54. package/dist/domain/metrics.test.js +0 -252
  55. package/dist/domain/metrics.test.js.map +0 -1
  56. package/dist/domain/task-duration.test.d.ts +0 -2
  57. package/dist/domain/task-duration.test.d.ts.map +0 -1
  58. package/dist/domain/task-duration.test.js +0 -307
  59. package/dist/domain/task-duration.test.js.map +0 -1
  60. package/dist/domain/workload.test.d.ts +0 -2
  61. package/dist/domain/workload.test.d.ts.map +0 -1
  62. package/dist/domain/workload.test.js +0 -261
  63. package/dist/domain/workload.test.js.map +0 -1
  64. package/dist/engine/actor.test.d.ts +0 -2
  65. package/dist/engine/actor.test.d.ts.map +0 -1
  66. package/dist/engine/actor.test.js +0 -54
  67. package/dist/engine/actor.test.js.map +0 -1
  68. package/dist/engine/authorization.test.d.ts +0 -2
  69. package/dist/engine/authorization.test.d.ts.map +0 -1
  70. package/dist/engine/authorization.test.js +0 -154
  71. package/dist/engine/authorization.test.js.map +0 -1
  72. package/dist/engine/cascade.test.d.ts +0 -2
  73. package/dist/engine/cascade.test.d.ts.map +0 -1
  74. package/dist/engine/cascade.test.js +0 -99
  75. package/dist/engine/cascade.test.js.map +0 -1
  76. package/dist/engine/config.test.d.ts +0 -2
  77. package/dist/engine/config.test.d.ts.map +0 -1
  78. package/dist/engine/config.test.js +0 -46
  79. package/dist/engine/config.test.js.map +0 -1
  80. package/dist/engine/entity-events.test.d.ts +0 -2
  81. package/dist/engine/entity-events.test.d.ts.map +0 -1
  82. package/dist/engine/entity-events.test.js +0 -61
  83. package/dist/engine/entity-events.test.js.map +0 -1
  84. package/dist/index.test.d.ts +0 -2
  85. package/dist/index.test.d.ts.map +0 -1
  86. package/dist/index.test.js +0 -429
  87. package/dist/index.test.js.map +0 -1
  88. package/dist/plugins/plugins.test.d.ts +0 -2
  89. package/dist/plugins/plugins.test.d.ts.map +0 -1
  90. package/dist/plugins/plugins.test.js +0 -137
  91. package/dist/plugins/plugins.test.js.map +0 -1
  92. package/dist/schemas/schemas.test.d.ts +0 -2
  93. package/dist/schemas/schemas.test.d.ts.map +0 -1
  94. package/dist/schemas/schemas.test.js +0 -58
  95. package/dist/schemas/schemas.test.js.map +0 -1
  96. package/dist/status.test.d.ts +0 -2
  97. package/dist/status.test.d.ts.map +0 -1
  98. package/dist/status.test.js +0 -295
  99. package/dist/status.test.js.map +0 -1
  100. package/dist/storage/storage.test.d.ts +0 -2
  101. package/dist/storage/storage.test.d.ts.map +0 -1
  102. package/dist/storage/storage.test.js +0 -236
  103. package/dist/storage/storage.test.js.map +0 -1
  104. package/dist/storage/upload-limits.test.d.ts +0 -2
  105. package/dist/storage/upload-limits.test.d.ts.map +0 -1
  106. package/dist/storage/upload-limits.test.js +0 -31
  107. package/dist/storage/upload-limits.test.js.map +0 -1
  108. package/dist/store/conformance.test.d.ts +0 -2
  109. package/dist/store/conformance.test.d.ts.map +0 -1
  110. package/dist/store/conformance.test.js +0 -15
  111. package/dist/store/conformance.test.js.map +0 -1
  112. package/dist/store/firebase.test.d.ts +0 -2
  113. package/dist/store/firebase.test.d.ts.map +0 -1
  114. package/dist/store/firebase.test.js +0 -103
  115. package/dist/store/firebase.test.js.map +0 -1
  116. package/dist/store/query.test.d.ts +0 -2
  117. package/dist/store/query.test.d.ts.map +0 -1
  118. package/dist/store/query.test.js +0 -89
  119. package/dist/store/query.test.js.map +0 -1
  120. package/dist/store/sqlite.test.d.ts +0 -2
  121. package/dist/store/sqlite.test.d.ts.map +0 -1
  122. package/dist/store/sqlite.test.js +0 -177
  123. package/dist/store/sqlite.test.js.map +0 -1
  124. package/dist/task-state.test.d.ts +0 -2
  125. package/dist/task-state.test.d.ts.map +0 -1
  126. package/dist/task-state.test.js +0 -423
  127. package/dist/task-state.test.js.map +0 -1
  128. package/dist/use-case-validation.test.d.ts +0 -2
  129. package/dist/use-case-validation.test.d.ts.map +0 -1
  130. package/dist/use-case-validation.test.js +0 -281
  131. package/dist/use-case-validation.test.js.map +0 -1
  132. package/dist/utils/fractional-index.test.d.ts +0 -2
  133. package/dist/utils/fractional-index.test.d.ts.map +0 -1
  134. package/dist/utils/fractional-index.test.js +0 -182
  135. package/dist/utils/fractional-index.test.js.map +0 -1
  136. package/dist/utils/key.test.d.ts +0 -2
  137. package/dist/utils/key.test.d.ts.map +0 -1
  138. package/dist/utils/key.test.js +0 -65
  139. package/dist/utils/key.test.js.map +0 -1
  140. package/dist/utils/mentions.test.d.ts +0 -2
  141. package/dist/utils/mentions.test.d.ts.map +0 -1
  142. package/dist/utils/mentions.test.js +0 -114
  143. package/dist/utils/mentions.test.js.map +0 -1
  144. package/dist/webhooks/webhooks.test.d.ts +0 -2
  145. package/dist/webhooks/webhooks.test.d.ts.map +0 -1
  146. package/dist/webhooks/webhooks.test.js +0 -154
  147. package/dist/webhooks/webhooks.test.js.map +0 -1
  148. package/src/deliverables.test.ts +0 -284
  149. package/src/domain/authorization.ts +0 -82
  150. package/src/domain/calendar.test.ts +0 -216
  151. package/src/domain/calendar.ts +0 -502
  152. package/src/domain/cpm.test.ts +0 -189
  153. package/src/domain/cpm.ts +0 -258
  154. package/src/domain/custom-fields.ts +0 -172
  155. package/src/domain/ddd.test.ts +0 -442
  156. package/src/domain/entities.ts +0 -798
  157. package/src/domain/errors.ts +0 -38
  158. package/src/domain/events.ts +0 -369
  159. package/src/domain/graph.ts +0 -70
  160. package/src/domain/index.ts +0 -12
  161. package/src/domain/ladder.test.ts +0 -251
  162. package/src/domain/ladder.ts +0 -474
  163. package/src/domain/metrics.test.ts +0 -288
  164. package/src/domain/metrics.ts +0 -497
  165. package/src/domain/task-duration.test.ts +0 -354
  166. package/src/domain/workload.test.ts +0 -311
  167. package/src/domain/workload.ts +0 -457
  168. package/src/engine/actor.test.ts +0 -63
  169. package/src/engine/authorization.test.ts +0 -199
  170. package/src/engine/cascade.test.ts +0 -121
  171. package/src/engine/config.test.ts +0 -51
  172. package/src/engine/entity-events.test.ts +0 -67
  173. package/src/engine/index.ts +0 -2495
  174. package/src/index.test.ts +0 -514
  175. package/src/index.ts +0 -9
  176. package/src/plugins/index.ts +0 -103
  177. package/src/plugins/plugins.test.ts +0 -161
  178. package/src/schemas/index.ts +0 -402
  179. package/src/schemas/schemas.test.ts +0 -116
  180. package/src/status.test.ts +0 -354
  181. package/src/storage/file-storage.ts +0 -200
  182. package/src/storage/firebase-storage.ts +0 -209
  183. package/src/storage/index.ts +0 -3
  184. package/src/storage/s3.ts +0 -151
  185. package/src/storage/storage.test.ts +0 -274
  186. package/src/storage/upload-limits.test.ts +0 -41
  187. package/src/store/conformance.test.ts +0 -15
  188. package/src/store/firebase.test.ts +0 -131
  189. package/src/store/firebase.ts +0 -658
  190. package/src/store/index.ts +0 -623
  191. package/src/store/query.test.ts +0 -110
  192. package/src/store/query.ts +0 -113
  193. package/src/store/sqlite.test.ts +0 -218
  194. package/src/store/sqlite.ts +0 -1480
  195. package/src/task-state.test.ts +0 -506
  196. package/src/testing/conformance.ts +0 -319
  197. package/src/testing/index.ts +0 -1
  198. package/src/types/index.ts +0 -871
  199. package/src/use-case-validation.test.ts +0 -324
  200. package/src/utils/fractional-index.test.ts +0 -228
  201. package/src/utils/fractional-index.ts +0 -254
  202. package/src/utils/index.ts +0 -7
  203. package/src/utils/key.test.ts +0 -76
  204. package/src/utils/key.ts +0 -62
  205. package/src/utils/mentions.test.ts +0 -132
  206. package/src/utils/mentions.ts +0 -131
  207. package/src/utils/status.ts +0 -419
  208. package/src/utils/workflow.ts +0 -306
  209. package/src/webhooks/dispatcher.ts +0 -190
  210. package/src/webhooks/index.ts +0 -2
  211. package/src/webhooks/signature.ts +0 -57
  212. package/src/webhooks/webhooks.test.ts +0 -201
  213. package/tsconfig.json +0 -8
  214. package/tsconfig.tsbuildinfo +0 -1
@@ -1,497 +0,0 @@
1
- import type {
2
- Task,
3
- Activity,
4
- TimeEntry,
5
- TaskProgressInference,
6
- ProgressInferenceSource,
7
- ProgressCurveProfile,
8
- TaskProgressHistoryPoint,
9
- TaskProgressHistory,
10
- TaskEVM,
11
- TaskInferredActuals,
12
- TaskMetrics,
13
- RealityDelta
14
- } from '../types/index.js';
15
- import { resolveStatusDefinition } from '../utils/status.js';
16
-
17
- export interface MetricOptions {
18
- referenceDate?: Date;
19
- customStatusDefinitions?: Array<{ key: string; category: any; label: string }>;
20
- }
21
-
22
- /**
23
- * Infer task progress percentage from explicit user input, todos/checklists,
24
- * logged effort vs estimate, and elapsed schedule duration.
25
- */
26
- export function inferTaskProgress(
27
- task: Task,
28
- options?: MetricOptions
29
- ): TaskProgressInference {
30
- const referenceDate = options?.referenceDate ?? new Date();
31
- const statusDef = resolveStatusDefinition(task.status, options?.customStatusDefinitions);
32
- const semanticStatus = task.semanticStatus || statusDef.category;
33
-
34
- // Completed tasks are 100% complete
35
- if (semanticStatus === 'completed') {
36
- return {
37
- progressPercentage: 100,
38
- source: task.progress === 100 ? 'explicit' : 'blended',
39
- isExplicit: task.progress !== undefined && task.progress !== null,
40
- breakdown: {
41
- explicitProgress: task.progress,
42
- todoProgress: task.todos && task.todos.length > 0 ? 100 : undefined,
43
- effortProgress: 100,
44
- scheduleProgress: 100
45
- }
46
- };
47
- }
48
-
49
- // 1. Explicit Progress
50
- let explicitProgress: number | undefined;
51
- if (task.progress !== undefined && task.progress !== null) {
52
- explicitProgress = Math.min(100, Math.max(0, Math.round(task.progress * 10) / 10));
53
- }
54
-
55
- // 2. Checklist / Todo Progress
56
- let todoProgress: number | undefined;
57
- if (task.todos && task.todos.length > 0) {
58
- const completed = task.todos.filter((t) => t.completed).length;
59
- todoProgress = Math.round((completed / task.todos.length) * 1000) / 10;
60
- }
61
-
62
- // 3. Effort / Hours Progress (Logged vs Estimated)
63
- let effortProgress: number | undefined;
64
- const estimatedH = task.estimatedHours || (task.estimatedDurationMinutes ? task.estimatedDurationMinutes / 60 : 0);
65
- const loggedH = task.loggedHours || task.actualHours || 0;
66
- if (estimatedH > 0) {
67
- effortProgress = Math.min(100, Math.round((loggedH / estimatedH) * 1000) / 10);
68
- }
69
-
70
- // 4. Schedule Elapsed Progress
71
- let scheduleProgress: number | undefined;
72
- const startDateStr = task.actualStartDate || task.plannedStartDate;
73
- if (startDateStr && task.dueDate && semanticStatus === 'in_progress') {
74
- const startTime = new Date(startDateStr).getTime();
75
- const dueTime = new Date(task.dueDate).getTime();
76
- const nowTime = referenceDate.getTime();
77
-
78
- if (!isNaN(startTime) && !isNaN(dueTime) && dueTime > startTime) {
79
- const elapsed = nowTime - startTime;
80
- const total = dueTime - startTime;
81
- const ratio = Math.min(1, Math.max(0, elapsed / total));
82
- scheduleProgress = Math.round(ratio * 1000) / 10;
83
- }
84
- }
85
-
86
- // Select canonical progress percentage and primary inference source
87
- let progressPercentage = 0;
88
- let source: ProgressInferenceSource = 'explicit';
89
-
90
- if (explicitProgress !== undefined && explicitProgress > 0) {
91
- progressPercentage = explicitProgress;
92
- source = 'explicit';
93
- } else if (todoProgress !== undefined && todoProgress > 0) {
94
- progressPercentage = todoProgress;
95
- source = 'todos';
96
- } else if (effortProgress !== undefined && effortProgress > 0 && semanticStatus === 'in_progress') {
97
- progressPercentage = effortProgress;
98
- source = 'time_effort';
99
- } else if (scheduleProgress !== undefined && scheduleProgress > 0 && semanticStatus === 'in_progress') {
100
- progressPercentage = scheduleProgress;
101
- source = 'schedule_elapsed';
102
- } else if (explicitProgress !== undefined) {
103
- progressPercentage = explicitProgress;
104
- source = 'explicit';
105
- } else {
106
- progressPercentage = 0;
107
- source = 'explicit';
108
- }
109
-
110
- return {
111
- progressPercentage,
112
- source,
113
- isExplicit: explicitProgress !== undefined,
114
- breakdown: {
115
- explicitProgress,
116
- todoProgress,
117
- effortProgress,
118
- scheduleProgress
119
- }
120
- };
121
- }
122
-
123
- /**
124
- * Calculate Earned Value Management (EVM) metrics for a task.
125
- */
126
- export function calculateTaskEVM(
127
- task: Task,
128
- options?: MetricOptions & { progressInference?: TaskProgressInference }
129
- ): TaskEVM {
130
- const referenceDate = options?.referenceDate ?? new Date();
131
- const statusDef = resolveStatusDefinition(task.status, options?.customStatusDefinitions);
132
- const semanticStatus = task.semanticStatus || statusDef.category;
133
-
134
- const estimatedHours = task.estimatedHours || (task.estimatedDurationMinutes ? task.estimatedDurationMinutes / 60 : 0);
135
- const actualCost = Math.round(((task.loggedHours ?? task.actualHours) || 0) * 100) / 100;
136
-
137
- if (estimatedHours <= 0) {
138
- return {
139
- plannedValue: actualCost,
140
- earnedValue: actualCost,
141
- actualCost,
142
- costVariance: 0,
143
- scheduleVariance: 0,
144
- costPerformanceIndex: 1.0,
145
- schedulePerformanceIndex: 1.0
146
- };
147
- }
148
-
149
- const progress = options?.progressInference ?? inferTaskProgress(task, options);
150
- const actualProgressRatio = Math.min(1, Math.max(0, progress.progressPercentage / 100));
151
- const earnedValue = Math.round(actualProgressRatio * estimatedHours * 100) / 100;
152
-
153
- // Compute Planned Value (PV) based on expected progress at reference date
154
- let plannedRatio = 0;
155
- if (semanticStatus === 'completed') {
156
- plannedRatio = 1.0;
157
- } else {
158
- const startStr = task.plannedStartDate || task.actualStartDate;
159
- if (startStr && task.dueDate) {
160
- const startTime = new Date(startStr).getTime();
161
- const dueTime = new Date(task.dueDate).getTime();
162
- const nowTime = referenceDate.getTime();
163
-
164
- if (!isNaN(startTime) && !isNaN(dueTime)) {
165
- if (nowTime <= startTime) {
166
- plannedRatio = 0;
167
- } else if (nowTime >= dueTime) {
168
- plannedRatio = 1.0;
169
- } else {
170
- plannedRatio = (nowTime - startTime) / (dueTime - startTime);
171
- }
172
- }
173
- } else if (task.dueDate) {
174
- const dueTime = new Date(task.dueDate).getTime();
175
- if (!isNaN(dueTime) && referenceDate.getTime() >= dueTime) {
176
- plannedRatio = 1.0;
177
- } else if (semanticStatus === 'in_progress') {
178
- plannedRatio = 0.5;
179
- }
180
- } else if (semanticStatus === 'in_progress') {
181
- plannedRatio = 0.5;
182
- }
183
- }
184
-
185
- const plannedValue = Math.round(plannedRatio * estimatedHours * 100) / 100;
186
- const costVariance = Math.round((earnedValue - actualCost) * 100) / 100;
187
- const scheduleVariance = Math.round((earnedValue - plannedValue) * 100) / 100;
188
-
189
- const costPerformanceIndex = actualCost > 0
190
- ? Math.round((earnedValue / actualCost) * 100) / 100
191
- : earnedValue > 0 ? 1.0 : 1.0;
192
-
193
- const schedulePerformanceIndex = plannedValue > 0
194
- ? Math.round((earnedValue / plannedValue) * 100) / 100
195
- : earnedValue > 0 ? 1.0 : 1.0;
196
-
197
- return {
198
- plannedValue,
199
- earnedValue,
200
- actualCost,
201
- costVariance,
202
- scheduleVariance,
203
- costPerformanceIndex,
204
- schedulePerformanceIndex
205
- };
206
- }
207
-
208
- /**
209
- * Reconstruct task progress history and curve shape from activity logs.
210
- */
211
- export function reconstructTaskProgressHistory(
212
- task: Task,
213
- activities: Activity[] = [],
214
- options?: MetricOptions
215
- ): TaskProgressHistory {
216
- const referenceDate = options?.referenceDate ?? new Date();
217
- const taskActivities = activities
218
- .filter((a) => a.taskId === task.id)
219
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
220
-
221
- const points: TaskProgressHistoryPoint[] = [];
222
-
223
- // Point 0: Task creation
224
- points.push({
225
- timestamp: task.createdAt,
226
- progress: 0,
227
- inferredProgress: 0,
228
- status: 'todo',
229
- semanticStatus: 'not_started',
230
- action: 'task.created'
231
- });
232
-
233
- let runningProgress = 0;
234
- let runningStatus = task.status;
235
-
236
- for (const act of taskActivities) {
237
- let pointProgress = runningProgress;
238
- let pointInferred: number | undefined;
239
-
240
- const details = (act.details || {}) as Record<string, unknown>;
241
-
242
- if (act.action === 'task.status_changed') {
243
- const newStatus = (details.toStatus as string) || (details.newStatus as string) || runningStatus;
244
- runningStatus = newStatus;
245
- const def = resolveStatusDefinition(newStatus, options?.customStatusDefinitions);
246
- if (def.category === 'completed') {
247
- pointProgress = 100;
248
- pointInferred = 100;
249
- }
250
- } else if (act.action === 'task.updated') {
251
- if (typeof details.progress === 'number') {
252
- pointProgress = Math.min(100, Math.max(0, details.progress));
253
- } else if (details.updates && typeof (details.updates as Record<string, unknown>).progress === 'number') {
254
- pointProgress = Math.min(100, Math.max(0, (details.updates as Record<string, unknown>).progress as number));
255
- }
256
- }
257
-
258
- runningProgress = pointProgress;
259
- const def = resolveStatusDefinition(runningStatus, options?.customStatusDefinitions);
260
-
261
- points.push({
262
- timestamp: act.createdAt,
263
- progress: pointProgress,
264
- inferredProgress: pointInferred,
265
- status: runningStatus,
266
- semanticStatus: def.category,
267
- action: act.action
268
- });
269
- }
270
-
271
- // Ensure latest current state is reflected at the end if different from last point
272
- const currentInferred = inferTaskProgress(task, options);
273
- const currentProgress = task.progress ?? currentInferred.progressPercentage;
274
- const lastPoint = points[points.length - 1];
275
-
276
- if (!lastPoint || lastPoint.progress !== currentProgress || lastPoint.status !== task.status) {
277
- points.push({
278
- timestamp: task.updatedAt || referenceDate.toISOString(),
279
- progress: currentProgress,
280
- inferredProgress: currentInferred.progressPercentage,
281
- status: task.status,
282
- semanticStatus: task.semanticStatus || resolveStatusDefinition(task.status, options?.customStatusDefinitions).category,
283
- action: 'snapshot.current'
284
- });
285
- }
286
-
287
- // Deduplicate any points with exact same timestamp
288
- const uniquePoints: TaskProgressHistoryPoint[] = [];
289
- const seenTimestamps = new Set<string>();
290
- for (const pt of points) {
291
- if (!seenTimestamps.has(pt.timestamp + pt.action)) {
292
- seenTimestamps.add(pt.timestamp + pt.action);
293
- uniquePoints.push(pt);
294
- }
295
- }
296
-
297
- // Classify curve profile
298
- const curveProfile = classifyCurveProfile(uniquePoints, task, referenceDate);
299
-
300
- return {
301
- taskId: task.id,
302
- points: uniquePoints,
303
- curveProfile
304
- };
305
- }
306
-
307
- function classifyCurveProfile(
308
- points: TaskProgressHistoryPoint[],
309
- task: Task,
310
- referenceDate: Date
311
- ): ProgressCurveProfile {
312
- if (points.length < 3) {
313
- return 'insufficient_data';
314
- }
315
-
316
- const startPt = points[0];
317
- const endPt = points[points.length - 1];
318
- const startTime = new Date(startPt.timestamp).getTime();
319
- const endTime = new Date(endPt.timestamp).getTime();
320
- const totalDuration = endTime - startTime;
321
-
322
- if (totalDuration <= 0) {
323
- return 'insufficient_data';
324
- }
325
-
326
- // Check if stalled: last 2 or more points have same progress for an extended span while in progress
327
- const statusDef = resolveStatusDefinition(task.status);
328
- if (statusDef.category === 'in_progress') {
329
- const lastActivity = new Date(endPt.timestamp).getTime();
330
- const stallThresholdMs = 5 * 24 * 60 * 60 * 1000;
331
- if (referenceDate.getTime() - lastActivity > stallThresholdMs) {
332
- return 'stalled';
333
- }
334
- }
335
-
336
- // Sample progress at 25%, 50%, and 75% marks of timeline
337
- const p25Time = startTime + totalDuration * 0.25;
338
- const p50Time = startTime + totalDuration * 0.50;
339
- const p75Time = startTime + totalDuration * 0.75;
340
-
341
- const getInterpolatedProgress = (t: number): number => {
342
- if (t <= startTime) return points[0].progress;
343
- if (t >= endTime) return endPt.progress;
344
-
345
- for (let i = 0; i < points.length - 1; i++) {
346
- const pA = points[i];
347
- const pB = points[i + 1];
348
- const tA = new Date(pA.timestamp).getTime();
349
- const tB = new Date(pB.timestamp).getTime();
350
- if (t >= tA && t <= tB) {
351
- if (tB === tA) return pA.progress;
352
- const fraction = (t - tA) / (tB - tA);
353
- return pA.progress + fraction * (pB.progress - pA.progress);
354
- }
355
- }
356
- return endPt.progress;
357
- };
358
-
359
- const p25 = getInterpolatedProgress(p25Time);
360
- const p50 = getInterpolatedProgress(p50Time);
361
- const p75 = getInterpolatedProgress(p75Time);
362
- const finalP = endPt.progress;
363
-
364
- const totalGain = finalP - startPt.progress;
365
- if (totalGain <= 5) {
366
- return 'stalled';
367
- }
368
-
369
- // Early Surge: Reached > 60% early on (by 25% or 50% time) and flattened
370
- if (p25 >= 60 || (p50 >= 65 && finalP - p50 <= 15)) {
371
- return 'early_surge';
372
- }
373
-
374
- // Late Rush: Little progress (< 25%) until late in the timeline (75%), then steep jump
375
- if (p50 <= 20 && p75 <= 35 && finalP >= 70) {
376
- return 'late_rush';
377
- }
378
-
379
- // S-Curve: Slow start (p25 <= 25), steep middle acceleration (p75 - p25 >= 55), slow finish (finalP - p75 <= 20)
380
- if (p25 <= 25 && (p75 - p25) >= 55 && (finalP - p75) <= 20) {
381
- return 's_curve';
382
- }
383
-
384
- // Default steady progression
385
- return 'linear';
386
- }
387
-
388
- /**
389
- * Calculate comprehensive multi-dimensional metrics for a task.
390
- */
391
- export function calculateTaskMetrics(
392
- task: Task,
393
- activities: Activity[] = [],
394
- timeEntries: TimeEntry[] = [],
395
- options?: MetricOptions
396
- ): TaskMetrics {
397
- const referenceDate = options?.referenceDate ?? new Date();
398
- const taskTimeEntries = timeEntries.filter((te) => te.taskId === task.id);
399
- const taskActivities = activities.filter((a) => a.taskId === task.id);
400
-
401
- const loggedHoursFromEntries = taskTimeEntries.reduce((sum, e) => sum + e.hours, 0);
402
- const effectiveLoggedHours = Math.max(loggedHoursFromEntries, task.loggedHours || 0, task.actualHours || 0);
403
- const estimatedHours = task.estimatedHours || (task.estimatedDurationMinutes ? task.estimatedDurationMinutes / 60 : 0);
404
- const varianceHours = Math.round((effectiveLoggedHours - estimatedHours) * 100) / 100;
405
-
406
- // Multi-dimensional variance
407
- let scheduleVarianceDays: number | undefined;
408
- if (task.dueDate && task.actualEndDate) {
409
- const dueTime = new Date(task.dueDate).getTime();
410
- const actualTime = new Date(task.actualEndDate).getTime();
411
- if (!isNaN(dueTime) && !isNaN(actualTime)) {
412
- scheduleVarianceDays = Math.round((actualTime - dueTime) / (1000 * 60 * 60 * 24));
413
- }
414
- }
415
-
416
- let durationVarianceHours: number | undefined;
417
- if (task.actualStartDate && task.actualEndDate && task.plannedStartDate && task.dueDate) {
418
- const actualDuration = (new Date(task.actualEndDate).getTime() - new Date(task.actualStartDate).getTime()) / (1000 * 60 * 60);
419
- const plannedDuration = (new Date(task.dueDate).getTime() - new Date(task.plannedStartDate).getTime()) / (1000 * 60 * 60);
420
- if (!isNaN(actualDuration) && !isNaN(plannedDuration)) {
421
- durationVarianceHours = Math.round((actualDuration - plannedDuration) * 100) / 100;
422
- }
423
- }
424
-
425
- const accuracyRatio = estimatedHours > 0
426
- ? Math.round((effectiveLoggedHours / estimatedHours) * 100) / 100
427
- : undefined;
428
-
429
- const nowIso = referenceDate.toISOString();
430
- const isOverdue = Boolean(
431
- task.dueDate &&
432
- task.dueDate < nowIso &&
433
- task.semanticStatus !== 'completed' &&
434
- task.semanticStatus !== 'canceled'
435
- );
436
- const isOverEstimate = Boolean(estimatedHours > 0 && effectiveLoggedHours > estimatedHours);
437
-
438
- const realityDelta: RealityDelta = {
439
- plannedStartDate: task.plannedStartDate,
440
- actualStartDate: task.actualStartDate,
441
- dueDate: task.dueDate,
442
- actualEndDate: task.actualEndDate,
443
- estimatedHours: Math.round(estimatedHours * 100) / 100,
444
- loggedHours: Math.round(effectiveLoggedHours * 100) / 100,
445
- varianceHours,
446
- effortVarianceHours: varianceHours,
447
- durationVarianceHours,
448
- scheduleVarianceDays,
449
- accuracyRatio,
450
- isOverdue,
451
- isOverEstimate
452
- };
453
-
454
- // Compute Inferred Actuals
455
- let activeWorkingHours: number | undefined;
456
- let calendarDurationHours: number | undefined;
457
-
458
- if (task.actualStartDate && task.actualEndDate) {
459
- const startMs = new Date(task.actualStartDate).getTime();
460
- const endMs = new Date(task.actualEndDate).getTime();
461
- if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
462
- calendarDurationHours = Math.round(((endMs - startMs) / (1000 * 60 * 60)) * 100) / 100;
463
- }
464
- }
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
-
474
- const inferredActuals: TaskInferredActuals = {
475
- actualStartDate: task.actualStartDate,
476
- actualEndDate: task.actualEndDate,
477
- isStartDateInferred: Boolean(task.actualStartDate && !task.plannedStartDate),
478
- isEndDateInferred: Boolean(task.actualEndDate && task.semanticStatus === 'completed'),
479
- activeWorkingHours,
480
- calendarDurationHours,
481
- actualDurationSeconds: task.actualDurationSeconds,
482
- blockedDurationSeconds: task.blockedDurationSeconds
483
- };
484
-
485
- const progress = inferTaskProgress(task, options);
486
- const evm = calculateTaskEVM(task, { ...options, progressInference: progress });
487
- const progressHistory = reconstructTaskProgressHistory(task, taskActivities, options);
488
-
489
- return {
490
- taskId: task.id,
491
- inferredActuals,
492
- realityDelta,
493
- progress,
494
- evm,
495
- progressHistory
496
- };
497
- }