| 67 | * Format a report string from a list of events. |
| 68 | */ |
| 69 | export function formatReport(events: AnalyticsEvent[], period: string = 'all'): string { |
| 70 | const skillEvents = events.filter(e => e.event !== 'hook_fire'); |
| 71 | const hookEvents = events.filter(e => e.event === 'hook_fire'); |
| 72 | |
| 73 | const lines: string[] = []; |
| 74 | lines.push('gstack skill usage analytics'); |
| 75 | lines.push('\u2550'.repeat(39)); |
| 76 | lines.push(''); |
| 77 | |
| 78 | const periodLabel = period === 'all' ? 'all time' : `last ${period.replace('d', ' days')}`; |
| 79 | lines.push(`Period: ${periodLabel}`); |
| 80 | |
| 81 | // Top Skills |
| 82 | const skillCounts = new Map<string, number>(); |
| 83 | for (const e of skillEvents) { |
| 84 | skillCounts.set(e.skill, (skillCounts.get(e.skill) || 0) + 1); |
| 85 | } |
| 86 | |
| 87 | if (skillCounts.size > 0) { |
| 88 | lines.push(''); |
| 89 | lines.push('Top Skills'); |
| 90 | |
| 91 | const sorted = [...skillCounts.entries()].sort((a, b) => b[1] - a[1]); |
| 92 | const maxName = Math.max(...sorted.map(([name]) => name.length + 1)); // +1 for / |
| 93 | const maxCount = Math.max(...sorted.map(([, count]) => String(count).length)); |
| 94 | |
| 95 | for (const [name, count] of sorted) { |
| 96 | const label = `/${name}`; |
| 97 | const suffix = `${count} invocation${count === 1 ? '' : 's'}`; |
| 98 | const dotLen = Math.max(2, 25 - label.length - suffix.length); |
| 99 | const dots = ' ' + '.'.repeat(dotLen) + ' '; |
| 100 | lines.push(` ${label}${dots}${suffix}`); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // By Repo |
| 105 | const repoSkills = new Map<string, Map<string, number>>(); |
| 106 | for (const e of skillEvents) { |
| 107 | if (!repoSkills.has(e.repo)) repoSkills.set(e.repo, new Map()); |
| 108 | const m = repoSkills.get(e.repo)!; |
| 109 | m.set(e.skill, (m.get(e.skill) || 0) + 1); |
| 110 | } |
| 111 | |
| 112 | if (repoSkills.size > 0) { |
| 113 | lines.push(''); |
| 114 | lines.push('By Repo'); |
| 115 | |
| 116 | const sortedRepos = [...repoSkills.entries()].sort((a, b) => a[0].localeCompare(b[0])); |
| 117 | for (const [repo, skills] of sortedRepos) { |
| 118 | const parts = [...skills.entries()] |
| 119 | .sort((a, b) => b[1] - a[1]) |
| 120 | .map(([s, c]) => `${s}(${c})`); |
| 121 | lines.push(` ${repo}: ${parts.join(' ')}`); |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // Safety Hook Events |
| 126 | const hookCounts = new Map<string, number>(); |