tlc-claude-code 1.2.29 → 1.3.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 (80) hide show
  1. package/dashboard/dist/components/UsagePane.d.ts +13 -0
  2. package/dashboard/dist/components/UsagePane.js +51 -0
  3. package/dashboard/dist/components/UsagePane.test.d.ts +1 -0
  4. package/dashboard/dist/components/UsagePane.test.js +142 -0
  5. package/dashboard/dist/components/WorkspaceDocsPane.d.ts +19 -0
  6. package/dashboard/dist/components/WorkspaceDocsPane.js +146 -0
  7. package/dashboard/dist/components/WorkspaceDocsPane.test.d.ts +1 -0
  8. package/dashboard/dist/components/WorkspaceDocsPane.test.js +242 -0
  9. package/dashboard/dist/components/WorkspacePane.d.ts +18 -0
  10. package/dashboard/dist/components/WorkspacePane.js +17 -0
  11. package/dashboard/dist/components/WorkspacePane.test.d.ts +1 -0
  12. package/dashboard/dist/components/WorkspacePane.test.js +84 -0
  13. package/package.json +1 -1
  14. package/server/lib/architecture-command.js +450 -0
  15. package/server/lib/architecture-command.test.js +754 -0
  16. package/server/lib/ast-analyzer.js +324 -0
  17. package/server/lib/ast-analyzer.test.js +437 -0
  18. package/server/lib/auth-system.test.js +4 -1
  19. package/server/lib/boundary-detector.js +427 -0
  20. package/server/lib/boundary-detector.test.js +320 -0
  21. package/server/lib/budget-alerts.js +138 -0
  22. package/server/lib/budget-alerts.test.js +235 -0
  23. package/server/lib/candidates-tracker.js +210 -0
  24. package/server/lib/candidates-tracker.test.js +300 -0
  25. package/server/lib/checkpoint-manager.js +251 -0
  26. package/server/lib/checkpoint-manager.test.js +474 -0
  27. package/server/lib/circular-detector.js +337 -0
  28. package/server/lib/circular-detector.test.js +353 -0
  29. package/server/lib/cohesion-analyzer.js +310 -0
  30. package/server/lib/cohesion-analyzer.test.js +447 -0
  31. package/server/lib/contract-testing.js +625 -0
  32. package/server/lib/contract-testing.test.js +342 -0
  33. package/server/lib/conversion-planner.js +469 -0
  34. package/server/lib/conversion-planner.test.js +361 -0
  35. package/server/lib/convert-command.js +351 -0
  36. package/server/lib/convert-command.test.js +608 -0
  37. package/server/lib/coupling-calculator.js +189 -0
  38. package/server/lib/coupling-calculator.test.js +509 -0
  39. package/server/lib/dependency-graph.js +367 -0
  40. package/server/lib/dependency-graph.test.js +516 -0
  41. package/server/lib/duplication-detector.js +349 -0
  42. package/server/lib/duplication-detector.test.js +401 -0
  43. package/server/lib/example-service.js +616 -0
  44. package/server/lib/example-service.test.js +397 -0
  45. package/server/lib/impact-scorer.js +184 -0
  46. package/server/lib/impact-scorer.test.js +211 -0
  47. package/server/lib/mermaid-generator.js +358 -0
  48. package/server/lib/mermaid-generator.test.js +301 -0
  49. package/server/lib/messaging-patterns.js +750 -0
  50. package/server/lib/messaging-patterns.test.js +213 -0
  51. package/server/lib/microservice-template.js +386 -0
  52. package/server/lib/microservice-template.test.js +325 -0
  53. package/server/lib/new-project-microservice.js +450 -0
  54. package/server/lib/new-project-microservice.test.js +600 -0
  55. package/server/lib/refactor-command.js +326 -0
  56. package/server/lib/refactor-command.test.js +528 -0
  57. package/server/lib/refactor-executor.js +254 -0
  58. package/server/lib/refactor-executor.test.js +305 -0
  59. package/server/lib/refactor-observer.js +292 -0
  60. package/server/lib/refactor-observer.test.js +422 -0
  61. package/server/lib/refactor-progress.js +193 -0
  62. package/server/lib/refactor-progress.test.js +251 -0
  63. package/server/lib/refactor-reporter.js +237 -0
  64. package/server/lib/refactor-reporter.test.js +247 -0
  65. package/server/lib/semantic-analyzer.js +198 -0
  66. package/server/lib/semantic-analyzer.test.js +474 -0
  67. package/server/lib/service-scaffold.js +486 -0
  68. package/server/lib/service-scaffold.test.js +373 -0
  69. package/server/lib/shared-kernel.js +578 -0
  70. package/server/lib/shared-kernel.test.js +255 -0
  71. package/server/lib/traefik-config.js +282 -0
  72. package/server/lib/traefik-config.test.js +312 -0
  73. package/server/lib/usage-command.js +218 -0
  74. package/server/lib/usage-command.test.js +391 -0
  75. package/server/lib/usage-formatter.js +192 -0
  76. package/server/lib/usage-formatter.test.js +267 -0
  77. package/server/lib/usage-history.js +122 -0
  78. package/server/lib/usage-history.test.js +206 -0
  79. package/server/package-lock.json +14 -0
  80. package/server/package.json +1 -0
@@ -0,0 +1,391 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const { UsageCommand, parseArgs } = await import('./usage-command.js');
7
+
8
+ describe('usage-command', () => {
9
+ let usageCommand;
10
+ let tempDir;
11
+
12
+ beforeEach(() => {
13
+ // Create temp directory for tests
14
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'usage-cmd-test-'));
15
+ });
16
+
17
+ afterEach(() => {
18
+ // Clean up temp directory
19
+ fs.rmSync(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe('parseArgs', () => {
23
+ it('parses empty args', () => {
24
+ const result = parseArgs([]);
25
+ expect(result).toEqual({
26
+ reset: false,
27
+ model: null,
28
+ json: false,
29
+ });
30
+ });
31
+
32
+ it('parses --reset flag', () => {
33
+ const result = parseArgs(['--reset']);
34
+ expect(result).toEqual({
35
+ reset: true,
36
+ model: null,
37
+ json: false,
38
+ });
39
+ });
40
+
41
+ it('parses --model flag with value', () => {
42
+ const result = parseArgs(['--model', 'openai']);
43
+ expect(result).toEqual({
44
+ reset: false,
45
+ model: 'openai',
46
+ json: false,
47
+ });
48
+ });
49
+
50
+ it('parses --json flag', () => {
51
+ const result = parseArgs(['--json']);
52
+ expect(result).toEqual({
53
+ reset: false,
54
+ model: null,
55
+ json: true,
56
+ });
57
+ });
58
+
59
+ it('parses multiple flags together', () => {
60
+ const result = parseArgs(['--model', 'deepseek', '--json']);
61
+ expect(result).toEqual({
62
+ reset: false,
63
+ model: 'deepseek',
64
+ json: true,
65
+ });
66
+ });
67
+
68
+ it('handles --model= syntax', () => {
69
+ const result = parseArgs(['--model=openai']);
70
+ expect(result).toEqual({
71
+ reset: false,
72
+ model: 'openai',
73
+ json: false,
74
+ });
75
+ });
76
+ });
77
+
78
+ describe('execute with mocked dependencies', () => {
79
+ let mockBudgetTracker;
80
+ let mockUsageHistory;
81
+ let mockBudgetAlerts;
82
+
83
+ beforeEach(() => {
84
+ mockBudgetTracker = {
85
+ getUsage: vi.fn().mockReturnValue({ daily: 5.00, monthly: 50.00, requests: 100 }),
86
+ reset: vi.fn(),
87
+ };
88
+
89
+ mockUsageHistory = {
90
+ getHistory: vi.fn().mockReturnValue([
91
+ { date: '2026-01-25', daily: 4.50, monthly: 45.00 },
92
+ { date: '2026-01-26', daily: 5.00, monthly: 50.00 },
93
+ ]),
94
+ getAllModels: vi.fn().mockReturnValue(['openai', 'deepseek']),
95
+ };
96
+
97
+ mockBudgetAlerts = {
98
+ checkThresholds: vi.fn().mockReturnValue([]),
99
+ resetAlerts: vi.fn(),
100
+ };
101
+
102
+ usageCommand = new UsageCommand({
103
+ budgetTracker: mockBudgetTracker,
104
+ usageHistory: mockUsageHistory,
105
+ budgetAlerts: mockBudgetAlerts,
106
+ });
107
+ });
108
+
109
+ const defaultConfig = {
110
+ openai: { budgetDaily: 10, budgetMonthly: 100 },
111
+ deepseek: { budgetDaily: 5, budgetMonthly: 50 },
112
+ };
113
+
114
+ it('shows usage summary', () => {
115
+ const result = usageCommand.execute([], defaultConfig);
116
+
117
+ expect(result.success).toBe(true);
118
+ expect(result.output).toContain('Usage Summary');
119
+ expect(result.output).toContain('openai');
120
+ expect(result.output).toContain('deepseek');
121
+ });
122
+
123
+ it('shows history chart in summary', () => {
124
+ const result = usageCommand.execute([], defaultConfig);
125
+
126
+ expect(result.output).toContain('7-Day Usage History');
127
+ });
128
+
129
+ it('filters by model when --model flag provided', () => {
130
+ const result = usageCommand.execute(['--model', 'openai'], defaultConfig);
131
+
132
+ expect(result.success).toBe(true);
133
+ expect(mockBudgetTracker.getUsage).toHaveBeenCalledWith('openai');
134
+ expect(mockBudgetTracker.getUsage).not.toHaveBeenCalledWith('deepseek');
135
+ });
136
+
137
+ it('outputs JSON format when --json flag provided', () => {
138
+ const result = usageCommand.execute(['--json'], defaultConfig);
139
+
140
+ expect(result.success).toBe(true);
141
+ expect(result.json).toBeDefined();
142
+ expect(result.json.models).toBeDefined();
143
+ });
144
+
145
+ it('includes usage data in JSON output', () => {
146
+ const result = usageCommand.execute(['--json'], defaultConfig);
147
+
148
+ expect(result.json.models.openai).toBeDefined();
149
+ expect(result.json.models.openai.daily).toBe(5.00);
150
+ expect(result.json.models.openai.monthly).toBe(50.00);
151
+ });
152
+
153
+ it('includes totals in JSON output', () => {
154
+ const result = usageCommand.execute(['--json'], defaultConfig);
155
+
156
+ expect(result.json.totals).toBeDefined();
157
+ expect(result.json.totals.daily).toBe(10); // 5 + 5
158
+ expect(result.json.totals.monthly).toBe(100); // 50 + 50
159
+ });
160
+
161
+ it('resets usage when --reset flag provided', () => {
162
+ const result = usageCommand.execute(['--reset'], defaultConfig);
163
+
164
+ expect(result.success).toBe(true);
165
+ expect(result.output).toContain('reset');
166
+ expect(mockBudgetTracker.reset).toHaveBeenCalled();
167
+ expect(mockBudgetAlerts.resetAlerts).toHaveBeenCalled();
168
+ });
169
+
170
+ it('resets only specified model with --reset --model', () => {
171
+ usageCommand.execute(['--reset', '--model', 'openai'], defaultConfig);
172
+
173
+ expect(mockBudgetTracker.reset).toHaveBeenCalledWith('openai');
174
+ expect(mockBudgetAlerts.resetAlerts).toHaveBeenCalledWith('openai');
175
+ expect(mockBudgetTracker.reset).toHaveBeenCalledTimes(1);
176
+ });
177
+
178
+ it('handles no usage data gracefully', () => {
179
+ mockBudgetTracker.getUsage.mockReturnValue({ daily: 0, monthly: 0, requests: 0 });
180
+ mockUsageHistory.getHistory.mockReturnValue([]);
181
+
182
+ const result = usageCommand.execute([], defaultConfig);
183
+
184
+ expect(result.success).toBe(true);
185
+ // Should still show the summary even with zero usage
186
+ expect(result.output).toContain('Usage Summary');
187
+ });
188
+
189
+ it('shows alerts if threshold crossed', () => {
190
+ const alert = {
191
+ model: 'openai',
192
+ threshold: 0.8,
193
+ currentSpend: 8.00,
194
+ budgetLimit: 10.00,
195
+ percentUsed: 80,
196
+ severity: 'caution',
197
+ };
198
+
199
+ mockBudgetAlerts.checkThresholds.mockReturnValue([alert]);
200
+
201
+ const result = usageCommand.execute([], defaultConfig);
202
+
203
+ expect(result.success).toBe(true);
204
+ expect(result.alerts).toBeDefined();
205
+ expect(result.alerts.length).toBeGreaterThan(0);
206
+ expect(result.alerts[0]).toContain('Budget Alert');
207
+ });
208
+
209
+ it('includes alerts in JSON output', () => {
210
+ const alert = {
211
+ model: 'openai',
212
+ threshold: 0.8,
213
+ currentSpend: 8.00,
214
+ budgetLimit: 10.00,
215
+ percentUsed: 80,
216
+ severity: 'caution',
217
+ };
218
+
219
+ mockBudgetAlerts.checkThresholds.mockReturnValue([alert]);
220
+
221
+ const result = usageCommand.execute(['--json'], defaultConfig);
222
+
223
+ expect(result.json.alerts).toBeDefined();
224
+ expect(result.json.alerts.length).toBeGreaterThan(0);
225
+ });
226
+
227
+ it('returns error for unknown model with --model flag', () => {
228
+ const result = usageCommand.execute(['--model', 'unknown'], defaultConfig);
229
+
230
+ expect(result.success).toBe(false);
231
+ expect(result.error).toContain('unknown');
232
+ });
233
+ });
234
+
235
+ describe('getUsageData', () => {
236
+ let mockBudgetTracker;
237
+
238
+ beforeEach(() => {
239
+ mockBudgetTracker = {
240
+ getUsage: vi.fn().mockReturnValue({ daily: 5.00, monthly: 50.00, requests: 100 }),
241
+ reset: vi.fn(),
242
+ };
243
+
244
+ usageCommand = new UsageCommand({
245
+ budgetTracker: mockBudgetTracker,
246
+ usageHistory: { getHistory: vi.fn(), getAllModels: vi.fn() },
247
+ budgetAlerts: { checkThresholds: vi.fn(), resetAlerts: vi.fn() },
248
+ });
249
+ });
250
+
251
+ it('collects usage data from all models', () => {
252
+ const data = usageCommand.getUsageData(['openai', 'deepseek']);
253
+
254
+ expect(data.openai).toBeDefined();
255
+ expect(data.deepseek).toBeDefined();
256
+ expect(mockBudgetTracker.getUsage).toHaveBeenCalledWith('openai');
257
+ expect(mockBudgetTracker.getUsage).toHaveBeenCalledWith('deepseek');
258
+ });
259
+
260
+ it('returns empty object for empty model list', () => {
261
+ const data = usageCommand.getUsageData([]);
262
+
263
+ expect(data).toEqual({});
264
+ });
265
+ });
266
+
267
+ describe('getHistoryData', () => {
268
+ let mockUsageHistory;
269
+
270
+ beforeEach(() => {
271
+ mockUsageHistory = {
272
+ getHistory: vi.fn().mockReturnValue([{ date: '2026-01-25', daily: 5.0 }]),
273
+ getAllModels: vi.fn(),
274
+ };
275
+
276
+ usageCommand = new UsageCommand({
277
+ budgetTracker: { getUsage: vi.fn(), reset: vi.fn() },
278
+ usageHistory: mockUsageHistory,
279
+ budgetAlerts: { checkThresholds: vi.fn(), resetAlerts: vi.fn() },
280
+ });
281
+ });
282
+
283
+ it('collects history from all models', () => {
284
+ usageCommand.getHistoryData(['openai', 'deepseek']);
285
+
286
+ expect(mockUsageHistory.getHistory).toHaveBeenCalledWith('openai');
287
+ expect(mockUsageHistory.getHistory).toHaveBeenCalledWith('deepseek');
288
+ });
289
+ });
290
+
291
+ describe('calculateTotals', () => {
292
+ beforeEach(() => {
293
+ usageCommand = new UsageCommand({
294
+ budgetTracker: { getUsage: vi.fn(), reset: vi.fn() },
295
+ usageHistory: { getHistory: vi.fn(), getAllModels: vi.fn() },
296
+ budgetAlerts: { checkThresholds: vi.fn(), resetAlerts: vi.fn() },
297
+ });
298
+ });
299
+
300
+ it('calculates totals across models', () => {
301
+ const usageData = {
302
+ openai: { daily: 5, monthly: 50, requests: 100 },
303
+ deepseek: { daily: 3, monthly: 30, requests: 60 },
304
+ };
305
+ const config = {
306
+ openai: { budgetDaily: 10, budgetMonthly: 100 },
307
+ deepseek: { budgetDaily: 5, budgetMonthly: 50 },
308
+ };
309
+
310
+ const totals = usageCommand.calculateTotals(usageData, config);
311
+
312
+ expect(totals.daily).toBe(8);
313
+ expect(totals.monthly).toBe(80);
314
+ expect(totals.requests).toBe(160);
315
+ expect(totals.budgetDaily).toBe(15);
316
+ expect(totals.budgetMonthly).toBe(150);
317
+ expect(totals.remainingDaily).toBe(7);
318
+ expect(totals.remainingMonthly).toBe(70);
319
+ });
320
+
321
+ it('handles missing config for model', () => {
322
+ const usageData = {
323
+ unknown: { daily: 1, monthly: 10, requests: 5 },
324
+ };
325
+ const config = {};
326
+
327
+ const totals = usageCommand.calculateTotals(usageData, config);
328
+
329
+ expect(totals.daily).toBe(1);
330
+ expect(totals.budgetDaily).toBe(0);
331
+ expect(totals.remainingDaily).toBe(0); // Can't be negative
332
+ });
333
+ });
334
+
335
+ describe('checkAlerts', () => {
336
+ let mockBudgetAlerts;
337
+
338
+ beforeEach(() => {
339
+ mockBudgetAlerts = {
340
+ checkThresholds: vi.fn().mockReturnValue([]),
341
+ resetAlerts: vi.fn(),
342
+ };
343
+
344
+ usageCommand = new UsageCommand({
345
+ budgetTracker: { getUsage: vi.fn(), reset: vi.fn() },
346
+ usageHistory: { getHistory: vi.fn(), getAllModels: vi.fn() },
347
+ budgetAlerts: mockBudgetAlerts,
348
+ });
349
+ });
350
+
351
+ it('checks thresholds for each model', () => {
352
+ const usageData = {
353
+ openai: { daily: 8, monthly: 80 },
354
+ deepseek: { daily: 4, monthly: 40 },
355
+ };
356
+ const config = {
357
+ openai: { budgetDaily: 10, budgetMonthly: 100 },
358
+ deepseek: { budgetDaily: 5, budgetMonthly: 50 },
359
+ };
360
+
361
+ usageCommand.checkAlerts(['openai', 'deepseek'], usageData, config);
362
+
363
+ expect(mockBudgetAlerts.checkThresholds).toHaveBeenCalledWith('openai', usageData.openai, config.openai);
364
+ expect(mockBudgetAlerts.checkThresholds).toHaveBeenCalledWith('deepseek', usageData.deepseek, config.deepseek);
365
+ });
366
+
367
+ it('aggregates alerts from all models', () => {
368
+ const alert1 = { model: 'openai', threshold: 0.8 };
369
+ const alert2 = { model: 'deepseek', threshold: 0.5 };
370
+
371
+ mockBudgetAlerts.checkThresholds
372
+ .mockReturnValueOnce([alert1])
373
+ .mockReturnValueOnce([alert2]);
374
+
375
+ const usageData = {
376
+ openai: { daily: 8, monthly: 80 },
377
+ deepseek: { daily: 2.5, monthly: 25 },
378
+ };
379
+ const config = {
380
+ openai: { budgetDaily: 10 },
381
+ deepseek: { budgetDaily: 5 },
382
+ };
383
+
384
+ const alerts = usageCommand.checkAlerts(['openai', 'deepseek'], usageData, config);
385
+
386
+ expect(alerts).toHaveLength(2);
387
+ expect(alerts).toContainEqual(alert1);
388
+ expect(alerts).toContainEqual(alert2);
389
+ });
390
+ });
391
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Usage Formatter - Format usage data for display
3
+ */
4
+
5
+ const BAR_WIDTH = 20;
6
+ const BAR_CHAR = '\u2588'; // Full block character
7
+
8
+ /**
9
+ * Format amount as currency with dollar sign and 2 decimals
10
+ * @param {number} amount - Amount in USD
11
+ * @returns {string} Formatted currency string
12
+ */
13
+ function formatCurrency(amount) {
14
+ return '$' + amount.toLocaleString('en-US', {
15
+ minimumFractionDigits: 2,
16
+ maximumFractionDigits: 2,
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Format budget percentage
22
+ * @param {number} used - Amount used
23
+ * @param {number} budget - Budget limit
24
+ * @returns {string} Percentage string
25
+ */
26
+ function formatBudgetPercentage(used, budget) {
27
+ if (budget === 0) {
28
+ return 'N/A';
29
+ }
30
+ const percentage = Math.round((used / budget) * 100);
31
+ return `${percentage}%`;
32
+ }
33
+
34
+ /**
35
+ * Format usage data as a table
36
+ * @param {Object} usageData - Usage data per model { model: { daily, monthly, requests } }
37
+ * @param {Object} budgets - Budget config per model { model: { budgetDaily, budgetMonthly } }
38
+ * @returns {string} Formatted table
39
+ */
40
+ function formatUsageTable(usageData, budgets) {
41
+ const models = Object.keys(usageData);
42
+ if (models.length === 0) {
43
+ return 'No usage data available.';
44
+ }
45
+
46
+ // Calculate column widths
47
+ const headers = ['Model', 'Daily', 'Budget %', 'Monthly', 'Budget %', 'Requests'];
48
+ const rows = [];
49
+
50
+ for (const model of models) {
51
+ const usage = usageData[model];
52
+ const budget = budgets[model] || { budgetDaily: 0, budgetMonthly: 0 };
53
+
54
+ rows.push([
55
+ model,
56
+ formatCurrency(usage.daily),
57
+ formatBudgetPercentage(usage.daily, budget.budgetDaily),
58
+ formatCurrency(usage.monthly),
59
+ formatBudgetPercentage(usage.monthly, budget.budgetMonthly),
60
+ String(usage.requests),
61
+ ]);
62
+ }
63
+
64
+ // Calculate column widths
65
+ const colWidths = headers.map((h, i) => {
66
+ const dataWidths = rows.map(r => r[i].length);
67
+ return Math.max(h.length, ...dataWidths);
68
+ });
69
+
70
+ // Build table
71
+ const separator = '+' + colWidths.map(w => '-'.repeat(w + 2)).join('+') + '+';
72
+ const formatRow = (cols) => '| ' + cols.map((c, i) => c.padEnd(colWidths[i])).join(' | ') + ' |';
73
+
74
+ const lines = [
75
+ separator,
76
+ formatRow(headers),
77
+ separator,
78
+ ...rows.map(formatRow),
79
+ separator,
80
+ ];
81
+
82
+ return lines.join('\n');
83
+ }
84
+
85
+ /**
86
+ * Generate ASCII bar chart for usage history
87
+ * @param {Array} history - Array of { date, daily } snapshots
88
+ * @param {number} budgetDaily - Daily budget for scaling
89
+ * @returns {string} ASCII bar chart
90
+ */
91
+ function generateBarChart(history, budgetDaily) {
92
+ if (!history || history.length === 0) {
93
+ return 'No usage history available.';
94
+ }
95
+
96
+ const lines = [];
97
+ lines.push('7-Day Usage History:');
98
+ lines.push('');
99
+
100
+ // Find max for scaling (use budget or max daily, whichever is larger)
101
+ const maxDaily = Math.max(...history.map(h => h.daily), budgetDaily);
102
+
103
+ for (const entry of history) {
104
+ const dateLabel = entry.date.slice(5); // MM-DD format
105
+ const percentage = maxDaily > 0 ? entry.daily / maxDaily : 0;
106
+ const barLength = Math.round(percentage * BAR_WIDTH);
107
+ const bar = BAR_CHAR.repeat(barLength);
108
+ const amount = formatCurrency(entry.daily);
109
+
110
+ // Mark over budget
111
+ const overBudget = entry.daily > budgetDaily;
112
+ const indicator = overBudget ? ' OVER!' : '';
113
+
114
+ lines.push(`${dateLabel} ${bar.padEnd(BAR_WIDTH)} ${amount}${indicator}`);
115
+ }
116
+
117
+ return lines.join('\n');
118
+ }
119
+
120
+ /**
121
+ * Format complete usage summary
122
+ * @param {Object} usageData - Usage data per model
123
+ * @param {Object} budgets - Budget config per model
124
+ * @param {Object} history - History per model { model: [snapshots] }
125
+ * @returns {string} Complete formatted summary
126
+ */
127
+ function formatUsageSummary(usageData, budgets, history) {
128
+ const models = Object.keys(usageData);
129
+
130
+ if (models.length === 0) {
131
+ return 'No usage data available.';
132
+ }
133
+
134
+ const lines = [];
135
+ lines.push('='.repeat(50));
136
+ lines.push('Usage Summary');
137
+ lines.push('='.repeat(50));
138
+ lines.push('');
139
+
140
+ // Usage table
141
+ lines.push(formatUsageTable(usageData, budgets));
142
+ lines.push('');
143
+
144
+ // Calculate totals
145
+ let totalDaily = 0;
146
+ let totalMonthly = 0;
147
+ let totalBudgetDaily = 0;
148
+ let totalBudgetMonthly = 0;
149
+
150
+ for (const model of models) {
151
+ const usage = usageData[model];
152
+ const budget = budgets[model] || { budgetDaily: 0, budgetMonthly: 0 };
153
+
154
+ totalDaily += usage.daily;
155
+ totalMonthly += usage.monthly;
156
+ totalBudgetDaily += budget.budgetDaily;
157
+ totalBudgetMonthly += budget.budgetMonthly;
158
+ }
159
+
160
+ // Totals section
161
+ lines.push('Totals:');
162
+ lines.push(` Daily: ${formatCurrency(totalDaily)} / ${formatCurrency(totalBudgetDaily)} (${formatBudgetPercentage(totalDaily, totalBudgetDaily)})`);
163
+ lines.push(` Monthly: ${formatCurrency(totalMonthly)} / ${formatCurrency(totalBudgetMonthly)} (${formatBudgetPercentage(totalMonthly, totalBudgetMonthly)})`);
164
+ lines.push('');
165
+
166
+ // Remaining budget
167
+ lines.push('Remaining:');
168
+ lines.push(` Daily: ${formatCurrency(Math.max(0, totalBudgetDaily - totalDaily))}`);
169
+ lines.push(` Monthly: ${formatCurrency(Math.max(0, totalBudgetMonthly - totalMonthly))}`);
170
+ lines.push('');
171
+
172
+ // History charts per model
173
+ for (const model of models) {
174
+ const modelHistory = history[model] || [];
175
+ if (modelHistory.length > 0) {
176
+ const budget = budgets[model] || { budgetDaily: 10 };
177
+ lines.push(`${model}:`);
178
+ lines.push(generateBarChart(modelHistory, budget.budgetDaily));
179
+ lines.push('');
180
+ }
181
+ }
182
+
183
+ return lines.join('\n');
184
+ }
185
+
186
+ module.exports = {
187
+ formatCurrency,
188
+ formatUsageTable,
189
+ formatBudgetPercentage,
190
+ generateBarChart,
191
+ formatUsageSummary,
192
+ };