claude-autopm 1.28.0 → 1.29.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/README.md +47 -15
- package/autopm/.claude/scripts/pm/analytics.js +425 -0
- package/autopm/.claude/scripts/pm/sync-batch.js +337 -0
- package/lib/README-FILTER-SEARCH.md +285 -0
- package/lib/analytics-engine.js +689 -0
- package/lib/batch-processor-integration.js +366 -0
- package/lib/batch-processor.js +278 -0
- package/lib/burndown-chart.js +415 -0
- package/lib/dependency-analyzer.js +466 -0
- package/lib/filter-engine.js +414 -0
- package/lib/query-parser.js +322 -0
- package/package.json +5 -4
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalyticsEngine - Comprehensive analytics and insights for PRDs, Epics, and Tasks
|
|
3
|
+
*
|
|
4
|
+
* Provides metrics, velocity tracking, completion rates, and team performance analytics.
|
|
5
|
+
*
|
|
6
|
+
* @example Epic Analysis
|
|
7
|
+
* ```javascript
|
|
8
|
+
* const AnalyticsEngine = require('./lib/analytics-engine');
|
|
9
|
+
* const engine = new AnalyticsEngine({ basePath: '.claude' });
|
|
10
|
+
*
|
|
11
|
+
* const analytics = await engine.analyzeEpic('epic-001');
|
|
12
|
+
* console.log(analytics.status); // Status breakdown
|
|
13
|
+
* console.log(analytics.velocity); // Velocity metrics
|
|
14
|
+
* console.log(analytics.progress); // Progress percentage
|
|
15
|
+
* console.log(analytics.blockers); // Blocked tasks
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @example Team Metrics
|
|
19
|
+
* ```javascript
|
|
20
|
+
* const metrics = await engine.getTeamMetrics({ period: 30 });
|
|
21
|
+
* console.log(metrics.completion); // Completion rate
|
|
22
|
+
* console.log(metrics.velocity); // Tasks per week
|
|
23
|
+
* console.log(metrics.duration); // Average duration
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @module AnalyticsEngine
|
|
27
|
+
* @version 1.0.0
|
|
28
|
+
* @since v1.29.0
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const FilterEngine = require('./filter-engine');
|
|
32
|
+
const fs = require('fs').promises;
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
class AnalyticsEngine {
|
|
36
|
+
/**
|
|
37
|
+
* Create AnalyticsEngine instance
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} options - Configuration options
|
|
40
|
+
* @param {string} options.basePath - Base path for file operations (default: '.claude')
|
|
41
|
+
*/
|
|
42
|
+
constructor(options = {}) {
|
|
43
|
+
this.basePath = options.basePath || '.claude';
|
|
44
|
+
this.filterEngine = new FilterEngine({ basePath: this.basePath });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Analyze epic with comprehensive metrics
|
|
49
|
+
*
|
|
50
|
+
* @param {string} epicId - Epic ID to analyze
|
|
51
|
+
* @returns {Promise<Object|null>} - Analytics object or null if epic not found
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* const analytics = await engine.analyzeEpic('epic-001');
|
|
55
|
+
* // Returns:
|
|
56
|
+
* // {
|
|
57
|
+
* // epicId: 'epic-001',
|
|
58
|
+
* // title: 'User Authentication',
|
|
59
|
+
* // status: { total: 25, completed: 15, in_progress: 7, pending: 3 },
|
|
60
|
+
* // velocity: { current: 3.5, average: 3.2, trend: 'increasing' },
|
|
61
|
+
* // progress: { percentage: 60, remainingTasks: 10, completedTasks: 15 },
|
|
62
|
+
* // timeline: { started: '2025-01-15', lastUpdate: '2025-10-06', daysActive: 265 },
|
|
63
|
+
* // blockers: [{ taskId: 'task-003', reason: 'Waiting for API' }],
|
|
64
|
+
* // dependencies: { blocked: 2, blocking: 3 }
|
|
65
|
+
* // }
|
|
66
|
+
*/
|
|
67
|
+
async analyzeEpic(epicId) {
|
|
68
|
+
// Load epic and tasks
|
|
69
|
+
const epicDir = path.join(this.basePath, 'epics', epicId);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(epicDir);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return null; // Epic doesn't exist
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Load epic metadata
|
|
78
|
+
const epicFile = path.join(epicDir, 'epic.md');
|
|
79
|
+
let epicMetadata = {};
|
|
80
|
+
try {
|
|
81
|
+
const epicContent = await fs.readFile(epicFile, 'utf8');
|
|
82
|
+
const parsed = this.filterEngine.parseFrontmatter(epicContent);
|
|
83
|
+
epicMetadata = parsed.frontmatter;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
epicMetadata = { id: epicId, title: epicId };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Load all tasks for this epic
|
|
89
|
+
const tasks = await this.filterEngine.loadFiles(epicDir);
|
|
90
|
+
const taskFiles = tasks.filter(t => t.frontmatter.id && t.frontmatter.id !== epicId);
|
|
91
|
+
|
|
92
|
+
if (taskFiles.length === 0) {
|
|
93
|
+
return {
|
|
94
|
+
epicId,
|
|
95
|
+
title: epicMetadata.title || epicId,
|
|
96
|
+
status: { total: 0, completed: 0, in_progress: 0, pending: 0, blocked: 0 },
|
|
97
|
+
velocity: { current: 0, average: 0, trend: 'stable' },
|
|
98
|
+
progress: { percentage: 0, remainingTasks: 0, completedTasks: 0 },
|
|
99
|
+
timeline: {
|
|
100
|
+
started: epicMetadata.created || null,
|
|
101
|
+
lastUpdate: epicMetadata.updated || null,
|
|
102
|
+
daysActive: 0,
|
|
103
|
+
estimatedCompletion: null
|
|
104
|
+
},
|
|
105
|
+
blockers: [],
|
|
106
|
+
dependencies: { blocked: 0, blocking: 0 }
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Calculate status breakdown
|
|
111
|
+
const status = this._calculateStatusBreakdown(taskFiles);
|
|
112
|
+
|
|
113
|
+
// Calculate progress
|
|
114
|
+
const progress = this._calculateProgress(taskFiles);
|
|
115
|
+
|
|
116
|
+
// Calculate velocity
|
|
117
|
+
const velocity = await this._calculateVelocityForEpic(taskFiles);
|
|
118
|
+
|
|
119
|
+
// Calculate timeline
|
|
120
|
+
const timeline = this._calculateTimeline(epicMetadata, taskFiles, velocity.average);
|
|
121
|
+
|
|
122
|
+
// Find blockers
|
|
123
|
+
const blockers = this._findBlockersInTasks(taskFiles);
|
|
124
|
+
|
|
125
|
+
// Calculate dependencies
|
|
126
|
+
const dependencies = this._calculateDependencies(taskFiles);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
epicId,
|
|
130
|
+
title: epicMetadata.title || epicId,
|
|
131
|
+
status,
|
|
132
|
+
velocity,
|
|
133
|
+
progress,
|
|
134
|
+
timeline,
|
|
135
|
+
blockers,
|
|
136
|
+
dependencies
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get team metrics for specified period
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} options - Options
|
|
144
|
+
* @param {number} options.period - Number of days (default: 30)
|
|
145
|
+
* @param {string[]} options.types - Types to include (default: ['prd', 'epic', 'task'])
|
|
146
|
+
* @returns {Promise<Object>} - Team metrics
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* const metrics = await engine.getTeamMetrics({ period: 30 });
|
|
150
|
+
* // Returns:
|
|
151
|
+
* // {
|
|
152
|
+
* // period: { start: '2025-09-06', end: '2025-10-06', days: 30 },
|
|
153
|
+
* // completion: { total: 150, completed: 120, rate: 0.80 },
|
|
154
|
+
* // velocity: { tasksPerWeek: 28, epicsPerMonth: 6 },
|
|
155
|
+
* // duration: { averageTaskDays: 2.5, averageEpicDays: 21 },
|
|
156
|
+
* // breakdown: { prd: {...}, epic: {...}, task: {...} }
|
|
157
|
+
* // }
|
|
158
|
+
*/
|
|
159
|
+
async getTeamMetrics(options = {}) {
|
|
160
|
+
const period = options.period || 30;
|
|
161
|
+
const types = options.types || ['prd', 'epic', 'task'];
|
|
162
|
+
|
|
163
|
+
const endDate = new Date();
|
|
164
|
+
const startDate = new Date(endDate);
|
|
165
|
+
startDate.setDate(startDate.getDate() - period);
|
|
166
|
+
|
|
167
|
+
const startDateStr = this._formatDate(startDate);
|
|
168
|
+
const endDateStr = this._formatDate(endDate);
|
|
169
|
+
|
|
170
|
+
// Load all files
|
|
171
|
+
const allFiles = [];
|
|
172
|
+
|
|
173
|
+
for (const type of types) {
|
|
174
|
+
let typeFiles;
|
|
175
|
+
if (type === 'task') {
|
|
176
|
+
// Load tasks from all epics
|
|
177
|
+
typeFiles = await this._loadAllTasks();
|
|
178
|
+
} else if (type === 'epic') {
|
|
179
|
+
// Load epics from epic directories
|
|
180
|
+
typeFiles = await this._loadAllEpics();
|
|
181
|
+
} else {
|
|
182
|
+
const dir = path.join(this.basePath, `${type}s`);
|
|
183
|
+
typeFiles = await this.filterEngine.loadFiles(dir);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Filter by date range
|
|
187
|
+
const filteredFiles = typeFiles.filter(f => {
|
|
188
|
+
const created = f.frontmatter.created;
|
|
189
|
+
if (!created) return false;
|
|
190
|
+
return created >= startDateStr && created <= endDateStr;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
allFiles.push(...filteredFiles.map(f => ({ ...f, type })));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Calculate completion metrics
|
|
197
|
+
const completion = this._calculateCompletionMetrics(allFiles);
|
|
198
|
+
|
|
199
|
+
// Calculate velocity
|
|
200
|
+
const velocity = this._calculateVelocityMetrics(allFiles, period);
|
|
201
|
+
|
|
202
|
+
// Calculate duration
|
|
203
|
+
const duration = this._calculateDurationMetrics(allFiles);
|
|
204
|
+
|
|
205
|
+
// Calculate breakdown by type
|
|
206
|
+
const breakdown = this._calculateBreakdown(allFiles);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
period: {
|
|
210
|
+
start: startDateStr,
|
|
211
|
+
end: endDateStr,
|
|
212
|
+
days: period
|
|
213
|
+
},
|
|
214
|
+
completion,
|
|
215
|
+
velocity,
|
|
216
|
+
duration,
|
|
217
|
+
breakdown
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Calculate velocity for an epic
|
|
223
|
+
*
|
|
224
|
+
* @param {string} epicId - Epic ID
|
|
225
|
+
* @param {number} periodDays - Period in days (default: 7)
|
|
226
|
+
* @returns {Promise<Object>} - Velocity metrics
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* const velocity = await engine.calculateVelocity('epic-001', 7);
|
|
230
|
+
* // Returns: { tasksPerWeek: 3.5, completedInPeriod: 7, periodDays: 7 }
|
|
231
|
+
*/
|
|
232
|
+
async calculateVelocity(epicId, periodDays = 7) {
|
|
233
|
+
const epicDir = path.join(this.basePath, 'epics', epicId);
|
|
234
|
+
const tasks = await this.filterEngine.loadFiles(epicDir);
|
|
235
|
+
const taskFiles = tasks.filter(t => t.frontmatter.id && t.frontmatter.id !== epicId);
|
|
236
|
+
|
|
237
|
+
return this._calculateVelocityForPeriod(taskFiles, periodDays);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get completion rate for a type
|
|
242
|
+
*
|
|
243
|
+
* @param {string} type - Type (task/epic/prd)
|
|
244
|
+
* @param {number} periodDays - Period in days (default: 30)
|
|
245
|
+
* @returns {Promise<Object>} - Completion rate
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* const rate = await engine.getCompletionRate('task', 30);
|
|
249
|
+
* // Returns: { total: 150, completed: 120, rate: 0.80 }
|
|
250
|
+
*/
|
|
251
|
+
async getCompletionRate(type, periodDays = 30) {
|
|
252
|
+
const endDate = new Date();
|
|
253
|
+
const startDate = new Date(endDate);
|
|
254
|
+
startDate.setDate(startDate.getDate() - periodDays);
|
|
255
|
+
const startDateStr = this._formatDate(startDate);
|
|
256
|
+
|
|
257
|
+
let files;
|
|
258
|
+
if (type === 'task') {
|
|
259
|
+
files = await this._loadAllTasks();
|
|
260
|
+
} else if (type === 'epic') {
|
|
261
|
+
files = await this._loadAllEpics();
|
|
262
|
+
} else {
|
|
263
|
+
const dir = path.join(this.basePath, `${type}s`);
|
|
264
|
+
files = await this.filterEngine.loadFiles(dir);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Filter by date range
|
|
268
|
+
const filteredFiles = files.filter(f => {
|
|
269
|
+
const created = f.frontmatter.created;
|
|
270
|
+
if (!created) return false;
|
|
271
|
+
return created >= startDateStr;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const total = filteredFiles.length;
|
|
275
|
+
const completed = filteredFiles.filter(f => f.frontmatter.status === 'completed').length;
|
|
276
|
+
const rate = total > 0 ? completed / total : 0;
|
|
277
|
+
|
|
278
|
+
return { total, completed, rate };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Find blockers for an epic
|
|
283
|
+
*
|
|
284
|
+
* @param {string} epicId - Epic ID
|
|
285
|
+
* @returns {Promise<Array>} - Array of blocked tasks
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* const blockers = await engine.findBlockers('epic-001');
|
|
289
|
+
* // Returns: [
|
|
290
|
+
* // { taskId: 'task-003', reason: 'Waiting for API access' },
|
|
291
|
+
* // { taskId: 'task-012', reason: 'Depends on task-003' }
|
|
292
|
+
* // ]
|
|
293
|
+
*/
|
|
294
|
+
async findBlockers(epicId) {
|
|
295
|
+
const epicDir = path.join(this.basePath, 'epics', epicId);
|
|
296
|
+
const tasks = await this.filterEngine.loadFiles(epicDir);
|
|
297
|
+
const taskFiles = tasks.filter(t => t.frontmatter.id && t.frontmatter.id !== epicId);
|
|
298
|
+
|
|
299
|
+
return this._findBlockersInTasks(taskFiles);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Export analytics to JSON or CSV
|
|
304
|
+
*
|
|
305
|
+
* @param {Object} analytics - Analytics object
|
|
306
|
+
* @param {string} format - Format (json/csv, default: json)
|
|
307
|
+
* @returns {Promise<string>} - Exported data
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* const analytics = await engine.analyzeEpic('epic-001');
|
|
311
|
+
* const json = await engine.export(analytics, 'json');
|
|
312
|
+
* const csv = await engine.export(analytics, 'csv');
|
|
313
|
+
*/
|
|
314
|
+
async export(analytics, format = 'json') {
|
|
315
|
+
if (format === 'csv') {
|
|
316
|
+
return this._exportToCSV(analytics);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return JSON.stringify(analytics, null, 2);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ============================================================================
|
|
323
|
+
// Private Helper Methods
|
|
324
|
+
// ============================================================================
|
|
325
|
+
|
|
326
|
+
_calculateStatusBreakdown(taskFiles) {
|
|
327
|
+
const status = {
|
|
328
|
+
total: taskFiles.length,
|
|
329
|
+
completed: 0,
|
|
330
|
+
in_progress: 0,
|
|
331
|
+
pending: 0,
|
|
332
|
+
blocked: 0
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
for (const task of taskFiles) {
|
|
336
|
+
const taskStatus = task.frontmatter.status;
|
|
337
|
+
if (taskStatus === 'completed') {
|
|
338
|
+
status.completed++;
|
|
339
|
+
} else if (taskStatus === 'in_progress') {
|
|
340
|
+
status.in_progress++;
|
|
341
|
+
} else if (taskStatus === 'blocked') {
|
|
342
|
+
status.blocked++;
|
|
343
|
+
} else if (taskStatus === 'pending') {
|
|
344
|
+
status.pending++;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return status;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_calculateProgress(taskFiles) {
|
|
352
|
+
const total = taskFiles.length;
|
|
353
|
+
const completed = taskFiles.filter(t => t.frontmatter.status === 'completed').length;
|
|
354
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
percentage,
|
|
358
|
+
completedTasks: completed,
|
|
359
|
+
remainingTasks: total - completed
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async _calculateVelocityForEpic(taskFiles) {
|
|
364
|
+
// Calculate current velocity (last 7 days)
|
|
365
|
+
const current = await this._calculateVelocityForPeriod(taskFiles, 7);
|
|
366
|
+
|
|
367
|
+
// Calculate average velocity (all time)
|
|
368
|
+
const completedTasks = taskFiles.filter(t =>
|
|
369
|
+
t.frontmatter.status === 'completed' && t.frontmatter.completed
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
if (completedTasks.length === 0) {
|
|
373
|
+
return {
|
|
374
|
+
current: 0,
|
|
375
|
+
average: 0,
|
|
376
|
+
trend: 'stable'
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Get date range
|
|
381
|
+
const dates = completedTasks
|
|
382
|
+
.map(t => new Date(t.frontmatter.completed))
|
|
383
|
+
.sort((a, b) => a - b);
|
|
384
|
+
|
|
385
|
+
const firstDate = dates[0];
|
|
386
|
+
const lastDate = dates[dates.length - 1];
|
|
387
|
+
const totalDays = Math.max(1, Math.ceil((lastDate - firstDate) / (1000 * 60 * 60 * 24)));
|
|
388
|
+
const totalWeeks = totalDays / 7;
|
|
389
|
+
|
|
390
|
+
const average = totalWeeks > 0 ? completedTasks.length / totalWeeks : 0;
|
|
391
|
+
|
|
392
|
+
// Determine trend
|
|
393
|
+
let trend = 'stable';
|
|
394
|
+
if (current.tasksPerWeek > average * 1.1) {
|
|
395
|
+
trend = 'increasing';
|
|
396
|
+
} else if (current.tasksPerWeek < average * 0.9) {
|
|
397
|
+
trend = 'decreasing';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
current: Math.round(current.tasksPerWeek * 10) / 10,
|
|
402
|
+
average: Math.round(average * 10) / 10,
|
|
403
|
+
trend
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
_calculateVelocityForPeriod(taskFiles, periodDays) {
|
|
408
|
+
const endDate = new Date();
|
|
409
|
+
const startDate = new Date(endDate);
|
|
410
|
+
startDate.setDate(startDate.getDate() - periodDays);
|
|
411
|
+
|
|
412
|
+
const completedInPeriod = taskFiles.filter(t => {
|
|
413
|
+
if (t.frontmatter.status !== 'completed' || !t.frontmatter.completed) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
const completedDate = new Date(t.frontmatter.completed);
|
|
417
|
+
return completedDate >= startDate && completedDate <= endDate;
|
|
418
|
+
}).length;
|
|
419
|
+
|
|
420
|
+
const weeks = periodDays / 7;
|
|
421
|
+
const tasksPerWeek = weeks > 0 ? completedInPeriod / weeks : 0;
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
tasksPerWeek: Math.round(tasksPerWeek * 10) / 10,
|
|
425
|
+
completedInPeriod,
|
|
426
|
+
periodDays
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
_calculateTimeline(epicMetadata, taskFiles, averageVelocity) {
|
|
431
|
+
const started = epicMetadata.created || null;
|
|
432
|
+
const lastUpdate = epicMetadata.updated || this._findLastUpdateDate(taskFiles);
|
|
433
|
+
|
|
434
|
+
let daysActive = 0;
|
|
435
|
+
if (started) {
|
|
436
|
+
const startDate = new Date(started);
|
|
437
|
+
const endDate = lastUpdate ? new Date(lastUpdate) : new Date();
|
|
438
|
+
daysActive = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Estimate completion
|
|
442
|
+
let estimatedCompletion = null;
|
|
443
|
+
if (averageVelocity > 0) {
|
|
444
|
+
const remainingTasks = taskFiles.filter(t => t.frontmatter.status !== 'completed').length;
|
|
445
|
+
const weeksRemaining = remainingTasks / averageVelocity;
|
|
446
|
+
const daysRemaining = Math.ceil(weeksRemaining * 7);
|
|
447
|
+
|
|
448
|
+
const completionDate = new Date();
|
|
449
|
+
completionDate.setDate(completionDate.getDate() + daysRemaining);
|
|
450
|
+
estimatedCompletion = this._formatDate(completionDate);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
started,
|
|
455
|
+
lastUpdate,
|
|
456
|
+
daysActive,
|
|
457
|
+
estimatedCompletion
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
_findLastUpdateDate(taskFiles) {
|
|
462
|
+
const dates = taskFiles
|
|
463
|
+
.map(t => t.frontmatter.updated || t.frontmatter.completed || t.frontmatter.created)
|
|
464
|
+
.filter(d => d)
|
|
465
|
+
.sort()
|
|
466
|
+
.reverse();
|
|
467
|
+
|
|
468
|
+
return dates.length > 0 ? dates[0] : null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
_findBlockersInTasks(taskFiles) {
|
|
472
|
+
const blockers = [];
|
|
473
|
+
|
|
474
|
+
for (const task of taskFiles) {
|
|
475
|
+
if (task.frontmatter.status === 'blocked') {
|
|
476
|
+
blockers.push({
|
|
477
|
+
taskId: task.frontmatter.id,
|
|
478
|
+
reason: task.frontmatter.blocker_reason || 'Unknown reason'
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return blockers;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
_calculateDependencies(taskFiles) {
|
|
487
|
+
let blocked = 0;
|
|
488
|
+
let blocking = 0;
|
|
489
|
+
|
|
490
|
+
for (const task of taskFiles) {
|
|
491
|
+
if (task.frontmatter.depends_on && task.frontmatter.depends_on.length > 0) {
|
|
492
|
+
blocked++;
|
|
493
|
+
}
|
|
494
|
+
if (task.frontmatter.blocks && task.frontmatter.blocks.length > 0) {
|
|
495
|
+
blocking += task.frontmatter.blocks.length;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return { blocked, blocking };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async _loadAllTasks() {
|
|
503
|
+
const epicsDir = path.join(this.basePath, 'epics');
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
await fs.access(epicsDir);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const epicDirs = await fs.readdir(epicsDir);
|
|
512
|
+
const allTasks = [];
|
|
513
|
+
|
|
514
|
+
for (const epicDir of epicDirs) {
|
|
515
|
+
const epicPath = path.join(epicsDir, epicDir);
|
|
516
|
+
const stat = await fs.stat(epicPath);
|
|
517
|
+
|
|
518
|
+
if (stat.isDirectory()) {
|
|
519
|
+
const tasks = await this.filterEngine.loadFiles(epicPath);
|
|
520
|
+
const taskFiles = tasks.filter(t => t.frontmatter.id && !t.path.endsWith('epic.md'));
|
|
521
|
+
allTasks.push(...taskFiles);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return allTasks;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async _loadAllEpics() {
|
|
529
|
+
const epicsDir = path.join(this.basePath, 'epics');
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
await fs.access(epicsDir);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const epicDirs = await fs.readdir(epicsDir);
|
|
538
|
+
const allEpics = [];
|
|
539
|
+
|
|
540
|
+
for (const epicDir of epicDirs) {
|
|
541
|
+
const epicPath = path.join(epicsDir, epicDir);
|
|
542
|
+
const stat = await fs.stat(epicPath);
|
|
543
|
+
|
|
544
|
+
if (stat.isDirectory()) {
|
|
545
|
+
const files = await this.filterEngine.loadFiles(epicPath);
|
|
546
|
+
const epicFile = files.find(f => f.path.endsWith('epic.md'));
|
|
547
|
+
if (epicFile) {
|
|
548
|
+
allEpics.push(epicFile);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return allEpics;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
_calculateCompletionMetrics(allFiles) {
|
|
557
|
+
const total = allFiles.length;
|
|
558
|
+
const completed = allFiles.filter(f => f.frontmatter.status === 'completed').length;
|
|
559
|
+
const rate = total > 0 ? completed / total : 0;
|
|
560
|
+
|
|
561
|
+
return { total, completed, rate: Math.round(rate * 100) / 100 };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
_calculateVelocityMetrics(allFiles, periodDays) {
|
|
565
|
+
const tasks = allFiles.filter(f => f.type === 'task');
|
|
566
|
+
const completedTasks = tasks.filter(t =>
|
|
567
|
+
t.frontmatter.status === 'completed' && t.frontmatter.completed
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const weeks = periodDays / 7;
|
|
571
|
+
const tasksPerWeek = weeks > 0 ? completedTasks.length / weeks : 0;
|
|
572
|
+
|
|
573
|
+
const epics = allFiles.filter(f => f.type === 'epic');
|
|
574
|
+
const completedEpics = epics.filter(e => e.frontmatter.status === 'completed');
|
|
575
|
+
const months = periodDays / 30;
|
|
576
|
+
const epicsPerMonth = months > 0 ? completedEpics.length / months : 0;
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
tasksPerWeek: Math.round(tasksPerWeek * 10) / 10,
|
|
580
|
+
epicsPerMonth: Math.round(epicsPerMonth * 10) / 10
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
_calculateDurationMetrics(allFiles) {
|
|
585
|
+
const tasks = allFiles.filter(f =>
|
|
586
|
+
f.type === 'task' &&
|
|
587
|
+
f.frontmatter.status === 'completed' &&
|
|
588
|
+
f.frontmatter.created &&
|
|
589
|
+
f.frontmatter.completed
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
let totalTaskDays = 0;
|
|
593
|
+
for (const task of tasks) {
|
|
594
|
+
const created = new Date(task.frontmatter.created);
|
|
595
|
+
const completed = new Date(task.frontmatter.completed);
|
|
596
|
+
const days = Math.ceil((completed - created) / (1000 * 60 * 60 * 24));
|
|
597
|
+
totalTaskDays += days;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const averageTaskDays = tasks.length > 0 ? totalTaskDays / tasks.length : 0;
|
|
601
|
+
|
|
602
|
+
const epics = allFiles.filter(f =>
|
|
603
|
+
f.type === 'epic' &&
|
|
604
|
+
f.frontmatter.status === 'completed' &&
|
|
605
|
+
f.frontmatter.created &&
|
|
606
|
+
f.frontmatter.completed
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
let totalEpicDays = 0;
|
|
610
|
+
for (const epic of epics) {
|
|
611
|
+
const created = new Date(epic.frontmatter.created);
|
|
612
|
+
const completed = new Date(epic.frontmatter.completed);
|
|
613
|
+
const days = Math.ceil((completed - created) / (1000 * 60 * 60 * 24));
|
|
614
|
+
totalEpicDays += days;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const averageEpicDays = epics.length > 0 ? totalEpicDays / epics.length : 0;
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
averageTaskDays: Math.round(averageTaskDays * 10) / 10,
|
|
621
|
+
averageEpicDays: Math.round(averageEpicDays * 10) / 10
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
_calculateBreakdown(allFiles) {
|
|
626
|
+
const breakdown = {};
|
|
627
|
+
|
|
628
|
+
const types = ['prd', 'epic', 'task'];
|
|
629
|
+
for (const type of types) {
|
|
630
|
+
const typeFiles = allFiles.filter(f => f.type === type);
|
|
631
|
+
const total = typeFiles.length;
|
|
632
|
+
const completed = typeFiles.filter(f => f.frontmatter.status === 'completed').length;
|
|
633
|
+
|
|
634
|
+
breakdown[type] = { total, completed };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return breakdown;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
_formatDate(date) {
|
|
641
|
+
const year = date.getFullYear();
|
|
642
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
643
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
644
|
+
return `${year}-${month}-${day}`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
_exportToCSV(analytics) {
|
|
648
|
+
const lines = [];
|
|
649
|
+
|
|
650
|
+
// Header
|
|
651
|
+
lines.push('Metric,Value');
|
|
652
|
+
|
|
653
|
+
// Epic info
|
|
654
|
+
lines.push(`Epic ID,${analytics.epicId}`);
|
|
655
|
+
lines.push(`Title,${analytics.title}`);
|
|
656
|
+
|
|
657
|
+
// Status
|
|
658
|
+
lines.push(`Total Tasks,${analytics.status.total}`);
|
|
659
|
+
lines.push(`Completed,${analytics.status.completed}`);
|
|
660
|
+
lines.push(`In Progress,${analytics.status.in_progress}`);
|
|
661
|
+
lines.push(`Pending,${analytics.status.pending}`);
|
|
662
|
+
lines.push(`Blocked,${analytics.status.blocked}`);
|
|
663
|
+
|
|
664
|
+
// Progress
|
|
665
|
+
lines.push(`Progress %,${analytics.progress.percentage}`);
|
|
666
|
+
|
|
667
|
+
// Velocity
|
|
668
|
+
lines.push(`Current Velocity,${analytics.velocity.current}`);
|
|
669
|
+
lines.push(`Average Velocity,${analytics.velocity.average}`);
|
|
670
|
+
lines.push(`Trend,${analytics.velocity.trend}`);
|
|
671
|
+
|
|
672
|
+
// Timeline
|
|
673
|
+
lines.push(`Started,${analytics.timeline.started || 'N/A'}`);
|
|
674
|
+
lines.push(`Last Update,${analytics.timeline.lastUpdate || 'N/A'}`);
|
|
675
|
+
lines.push(`Days Active,${analytics.timeline.daysActive}`);
|
|
676
|
+
lines.push(`Est. Completion,${analytics.timeline.estimatedCompletion || 'N/A'}`);
|
|
677
|
+
|
|
678
|
+
// Dependencies
|
|
679
|
+
lines.push(`Blocked Tasks,${analytics.dependencies.blocked}`);
|
|
680
|
+
lines.push(`Blocking Tasks,${analytics.dependencies.blocking}`);
|
|
681
|
+
|
|
682
|
+
// Blockers
|
|
683
|
+
lines.push(`Total Blockers,${analytics.blockers.length}`);
|
|
684
|
+
|
|
685
|
+
return lines.join('\n');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
module.exports = AnalyticsEngine;
|