claude-autopm 1.29.0 → 1.30.1

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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * BurndownChart - ASCII burndown chart generator
3
+ *
4
+ * Generates visual burndown charts comparing ideal vs actual task completion.
5
+ *
6
+ * @example Basic Usage
7
+ * ```javascript
8
+ * const BurndownChart = require('./lib/burndown-chart');
9
+ * const chart = new BurndownChart();
10
+ *
11
+ * const rendered = await chart.generate('epic-001', {
12
+ * basePath: '.claude'
13
+ * });
14
+ * console.log(rendered);
15
+ * ```
16
+ *
17
+ * @example Custom Rendering
18
+ * ```javascript
19
+ * const chart = new BurndownChart({ width: 80, height: 20 });
20
+ *
21
+ * const ideal = [10, 8, 6, 4, 2, 0];
22
+ * const actual = [10, 9, 7, 5, 2, 0];
23
+ *
24
+ * const rendered = chart.renderChart(ideal, actual, {
25
+ * epicId: 'epic-001',
26
+ * epicTitle: 'User Authentication',
27
+ * startDate: '2025-10-01',
28
+ * endDate: '2025-10-05'
29
+ * });
30
+ * ```
31
+ *
32
+ * @module BurndownChart
33
+ * @version 1.0.0
34
+ * @since v1.29.0
35
+ */
36
+
37
+ const FilterEngine = require('./filter-engine');
38
+ const path = require('path');
39
+
40
+ class BurndownChart {
41
+ /**
42
+ * Create BurndownChart instance
43
+ *
44
+ * @param {Object} options - Configuration options
45
+ * @param {number} options.width - Chart width in characters (default: 60)
46
+ * @param {number} options.height - Chart height in lines (default: 15)
47
+ */
48
+ constructor(options = {}) {
49
+ this.width = options.width || 60;
50
+ this.height = options.height || 15;
51
+ }
52
+
53
+ /**
54
+ * Generate burndown chart for an epic
55
+ *
56
+ * @param {string} epicId - Epic ID
57
+ * @param {Object} options - Options
58
+ * @param {string} options.basePath - Base path (default: '.claude')
59
+ * @param {string} options.startDate - Start date (YYYY-MM-DD, default: epic created date)
60
+ * @param {number} options.days - Number of days (default: 30)
61
+ * @returns {Promise<string>} - Rendered ASCII chart
62
+ *
63
+ * @example
64
+ * const chart = await generator.generate('epic-001');
65
+ * console.log(chart);
66
+ */
67
+ async generate(epicId, options = {}) {
68
+ const basePath = options.basePath || '.claude';
69
+ const filterEngine = new FilterEngine({ basePath });
70
+
71
+ // Load epic and tasks
72
+ const epicDir = path.join(basePath, 'epics', epicId);
73
+ const tasks = await filterEngine.loadFiles(epicDir);
74
+
75
+ const epicFile = tasks.find(t => t.path.endsWith('epic.md'));
76
+ const taskFiles = tasks.filter(t => t.frontmatter.id && t.frontmatter.id !== epicId);
77
+
78
+ if (taskFiles.length === 0) {
79
+ return this._renderEmptyChart(epicId, epicFile);
80
+ }
81
+
82
+ // Determine date range
83
+ let startDate = options.startDate;
84
+ if (!startDate && epicFile) {
85
+ startDate = epicFile.frontmatter.created || this._findEarliestDate(taskFiles);
86
+ }
87
+ if (!startDate) {
88
+ startDate = this._formatDate(new Date());
89
+ }
90
+
91
+ const days = options.days || 30;
92
+
93
+ // Calculate burndown data
94
+ const ideal = this.calculateIdealBurndown(taskFiles.length, days);
95
+ const actual = this.calculateActualBurndown(taskFiles, startDate, days);
96
+
97
+ // Calculate velocity
98
+ const velocity = this._calculateVelocity(taskFiles, startDate, days);
99
+
100
+ // Calculate estimated completion
101
+ const estimatedCompletion = this._estimateCompletion(taskFiles, velocity, startDate);
102
+
103
+ // Render chart
104
+ return this.renderChart(ideal, actual, {
105
+ epicId,
106
+ epicTitle: epicFile ? epicFile.frontmatter.title : epicId,
107
+ startDate,
108
+ endDate: this._addDays(startDate, days),
109
+ velocity,
110
+ estimatedCompletion
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Calculate ideal burndown line
116
+ *
117
+ * @param {number} total - Total number of tasks
118
+ * @param {number} days - Number of days
119
+ * @returns {Array<number>} - Ideal burndown values
120
+ *
121
+ * @example
122
+ * const ideal = chart.calculateIdealBurndown(25, 30);
123
+ * // Returns: [25, 24.17, 23.33, ..., 0]
124
+ */
125
+ calculateIdealBurndown(total, days) {
126
+ const ideal = [];
127
+ const rate = total / days;
128
+
129
+ for (let i = 0; i <= days; i++) {
130
+ const remaining = Math.max(0, total - (rate * i));
131
+ ideal.push(Math.round(remaining * 100) / 100);
132
+ }
133
+
134
+ return ideal;
135
+ }
136
+
137
+ /**
138
+ * Calculate actual burndown from task completion
139
+ *
140
+ * @param {Array} tasks - Task files with frontmatter
141
+ * @param {string} startDate - Start date (YYYY-MM-DD)
142
+ * @param {number} days - Number of days
143
+ * @returns {Array<number>} - Actual burndown values
144
+ *
145
+ * @example
146
+ * const actual = chart.calculateActualBurndown(tasks, '2025-10-01', 30);
147
+ * // Returns: [25, 24, 22, ..., 3]
148
+ */
149
+ calculateActualBurndown(tasks, startDate, days) {
150
+ const actual = [];
151
+ const total = tasks.length;
152
+
153
+ const startDateObj = new Date(startDate);
154
+
155
+ for (let i = 0; i <= days; i++) {
156
+ const currentDate = new Date(startDateObj);
157
+ currentDate.setDate(currentDate.getDate() + i);
158
+ const currentDateStr = this._formatDate(currentDate);
159
+
160
+ // Count tasks completed BEFORE this date (not including current day)
161
+ const completedCount = tasks.filter(t => {
162
+ // Support both frontmatter and direct object formats
163
+ const status = t.frontmatter ? t.frontmatter.status : t.status;
164
+ const completed = t.frontmatter ? t.frontmatter.completed : t.completed;
165
+
166
+ if (status !== 'completed' || !completed) {
167
+ return false;
168
+ }
169
+ return completed < currentDateStr;
170
+ }).length;
171
+
172
+ const remaining = total - completedCount;
173
+ actual.push(remaining);
174
+ }
175
+
176
+ return actual;
177
+ }
178
+
179
+ /**
180
+ * Render ASCII chart
181
+ *
182
+ * @param {Array<number>} ideal - Ideal burndown values
183
+ * @param {Array<number>} actual - Actual burndown values
184
+ * @param {Object} metadata - Chart metadata
185
+ * @returns {string} - Rendered ASCII chart
186
+ *
187
+ * @example
188
+ * const chart = generator.renderChart(ideal, actual, {
189
+ * epicId: 'epic-001',
190
+ * epicTitle: 'Authentication',
191
+ * startDate: '2025-10-01',
192
+ * endDate: '2025-10-30'
193
+ * });
194
+ */
195
+ renderChart(ideal, actual, metadata) {
196
+ const lines = [];
197
+
198
+ // Title
199
+ lines.push(`Epic: ${metadata.epicTitle} (${metadata.epicId})`);
200
+ lines.push(`Burndown Chart - ${this._formatDateForDisplay(metadata.startDate)} to ${this._formatDateForDisplay(metadata.endDate)}`);
201
+ lines.push('');
202
+
203
+ // Find max value for scaling
204
+ const maxValue = Math.max(...ideal, ...actual, 1);
205
+
206
+ // Generate chart lines
207
+ const chartHeight = this.height;
208
+ const chartWidth = this.width;
209
+
210
+ for (let row = 0; row < chartHeight; row++) {
211
+ const value = maxValue - (row * maxValue / (chartHeight - 1));
212
+ const valueLabel = String(Math.round(value)).padStart(4, ' ');
213
+
214
+ let line = `${valueLabel} `;
215
+
216
+ if (row === 0) {
217
+ line += '┤';
218
+ } else if (row === chartHeight - 1) {
219
+ line += '└';
220
+ } else {
221
+ line += '│';
222
+ }
223
+
224
+ // Plot points
225
+ const pointsPerCol = Math.max(1, ideal.length / chartWidth);
226
+
227
+ for (let col = 0; col < chartWidth; col++) {
228
+ const dataIndex = Math.floor(col * pointsPerCol);
229
+
230
+ if (dataIndex >= ideal.length) {
231
+ line += ' ';
232
+ continue;
233
+ }
234
+
235
+ const idealValue = ideal[dataIndex];
236
+ const actualValue = actual[dataIndex] !== undefined ? actual[dataIndex] : idealValue;
237
+
238
+ const idealY = Math.round((chartHeight - 1) * (1 - idealValue / maxValue));
239
+ const actualY = Math.round((chartHeight - 1) * (1 - actualValue / maxValue));
240
+
241
+ if (row === idealY && row === actualY) {
242
+ line += '●'; // Both lines at same point
243
+ } else if (row === idealY) {
244
+ line += '━'; // Ideal line
245
+ } else if (row === actualY) {
246
+ line += '╲'; // Actual line
247
+ } else if (row > idealY && row <= actualY && actualValue > idealValue) {
248
+ line += '╲'; // Filling actual line when behind
249
+ } else if (row <= idealY && row > actualY && actualValue < idealValue) {
250
+ line += '╲'; // Filling actual line when ahead
251
+ } else {
252
+ line += ' ';
253
+ }
254
+ }
255
+
256
+ lines.push(line);
257
+ }
258
+
259
+ // X-axis
260
+ const xAxis = ' ' + '─'.repeat(chartWidth);
261
+ lines.push(xAxis);
262
+
263
+ // Date labels
264
+ const startDateLabel = this._formatDateForDisplay(metadata.startDate);
265
+ const endDateLabel = this._formatDateForDisplay(metadata.endDate);
266
+ const midLabel = '';
267
+
268
+ const dateLabels = ` ${startDateLabel}${' '.repeat(chartWidth - startDateLabel.length - endDateLabel.length)}${endDateLabel}`;
269
+ lines.push(dateLabels);
270
+ lines.push('');
271
+
272
+ // Legend
273
+ lines.push('Legend: ━━━ Ideal ╲╲╲ Actual');
274
+ lines.push('');
275
+
276
+ // Status
277
+ const status = this._calculateStatus(ideal, actual);
278
+ lines.push(`Status: ${status.text}`);
279
+
280
+ if (metadata.velocity) {
281
+ lines.push(`Velocity: ${metadata.velocity} tasks/week`);
282
+ }
283
+
284
+ if (metadata.estimatedCompletion) {
285
+ lines.push(`Estimated Completion: ${metadata.estimatedCompletion}`);
286
+ }
287
+
288
+ return lines.join('\n');
289
+ }
290
+
291
+ // ============================================================================
292
+ // Private Helper Methods
293
+ // ============================================================================
294
+
295
+ _renderEmptyChart(epicId, epicFile) {
296
+ const lines = [];
297
+ const title = epicFile ? epicFile.frontmatter.title : epicId;
298
+
299
+ lines.push(`Epic: ${title} (${epicId})`);
300
+ lines.push('Burndown Chart');
301
+ lines.push('');
302
+ lines.push('No tasks found for this epic.');
303
+ lines.push('');
304
+
305
+ return lines.join('\n');
306
+ }
307
+
308
+ _findEarliestDate(taskFiles) {
309
+ const dates = taskFiles
310
+ .map(t => t.frontmatter.created)
311
+ .filter(d => d)
312
+ .sort();
313
+
314
+ return dates.length > 0 ? dates[0] : this._formatDate(new Date());
315
+ }
316
+
317
+ _calculateVelocity(taskFiles, startDate, days) {
318
+ const completedTasks = taskFiles.filter(t => {
319
+ const status = t.frontmatter ? t.frontmatter.status : t.status;
320
+ const completed = t.frontmatter ? t.frontmatter.completed : t.completed;
321
+ return status === 'completed' && completed;
322
+ });
323
+
324
+ const weeks = days / 7;
325
+ return weeks > 0 ? Math.round((completedTasks.length / weeks) * 10) / 10 : 0;
326
+ }
327
+
328
+ _estimateCompletion(taskFiles, velocity, startDate) {
329
+ if (velocity === 0) return null;
330
+
331
+ const remainingTasks = taskFiles.filter(t => {
332
+ const status = t.frontmatter ? t.frontmatter.status : t.status;
333
+ return status !== 'completed';
334
+ }).length;
335
+
336
+ const weeksRemaining = remainingTasks / velocity;
337
+ const daysRemaining = Math.ceil(weeksRemaining * 7);
338
+
339
+ const completionDate = new Date(startDate);
340
+ completionDate.setDate(completionDate.getDate() + daysRemaining);
341
+
342
+ return this._formatDate(completionDate);
343
+ }
344
+
345
+ _calculateStatus(ideal, actual) {
346
+ if (ideal.length === 0 || actual.length === 0) {
347
+ return { text: 'ON TRACK', ahead: false, behind: false };
348
+ }
349
+
350
+ // Compare at the last non-zero point, or at 75% through the period
351
+ // This gives a better sense of whether we're ahead/behind during execution
352
+ const compareIndex = Math.floor(Math.min(ideal.length, actual.length) * 0.75);
353
+ const idealValue = ideal[compareIndex];
354
+ const actualValue = actual[compareIndex];
355
+
356
+ const difference = idealValue - actualValue;
357
+
358
+ // If there's minimal difference, we're on track
359
+ if (Math.abs(difference) < 0.5) {
360
+ return { text: 'ON TRACK', ahead: false, behind: false };
361
+ }
362
+
363
+ // Calculate percentage difference using initial value as base
364
+ const initialValue = ideal[0] > 0 ? ideal[0] : 10;
365
+ const percentDiff = Math.abs(difference / initialValue) * 100;
366
+
367
+ if (percentDiff < 5) {
368
+ return { text: 'ON TRACK', ahead: false, behind: false };
369
+ } else if (difference > 0) {
370
+ // Actual is lower than ideal = ahead of schedule (burned down more tasks)
371
+ return {
372
+ text: `AHEAD OF SCHEDULE (${Math.round(percentDiff)}% ahead)`,
373
+ ahead: true,
374
+ behind: false
375
+ };
376
+ } else {
377
+ // Actual is higher than ideal = behind schedule (more tasks remaining)
378
+ return {
379
+ text: `BEHIND SCHEDULE (${Math.round(percentDiff)}% behind)`,
380
+ ahead: false,
381
+ behind: true
382
+ };
383
+ }
384
+ }
385
+
386
+ _formatDate(date) {
387
+ if (typeof date === 'string') {
388
+ date = new Date(date);
389
+ }
390
+
391
+ const year = date.getFullYear();
392
+ const month = String(date.getMonth() + 1).padStart(2, '0');
393
+ const day = String(date.getDate()).padStart(2, '0');
394
+ return `${year}-${month}-${day}`;
395
+ }
396
+
397
+ _formatDateForDisplay(dateStr) {
398
+ if (!dateStr) return '';
399
+
400
+ const date = new Date(dateStr);
401
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
402
+ const month = months[date.getMonth()];
403
+ const day = date.getDate();
404
+
405
+ return `${month} ${day}`;
406
+ }
407
+
408
+ _addDays(dateStr, days) {
409
+ const date = new Date(dateStr);
410
+ date.setDate(date.getDate() + days);
411
+ return this._formatDate(date);
412
+ }
413
+ }
414
+
415
+ module.exports = BurndownChart;