@viren/claude-code-dashboard 0.0.6 → 0.0.7

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,500 @@
1
+ /**
2
+ * Pure processing pipeline — transforms raw scan data into the shape
3
+ * that generateDashboardHtml() expects.
4
+ *
5
+ * NO filesystem I/O, NO git commands, NO process.exit.
6
+ * All data comes in via the `raw` parameter.
7
+ */
8
+
9
+ import { SIMILARITY_THRESHOLD } from "./constants.mjs";
10
+ import { relativeTime, freshnessClass } from "./freshness.mjs";
11
+ import {
12
+ computeHealthScore,
13
+ classifyDrift,
14
+ detectConfigPattern,
15
+ findExemplar,
16
+ generateSuggestions,
17
+ computeConfigSimilarity,
18
+ matchSkillsToRepo,
19
+ } from "./analysis.mjs";
20
+ import { findPromotionCandidates, classifyHistoricalServers } from "./mcp.mjs";
21
+ import { aggregateSessionMeta } from "./usage.mjs";
22
+
23
+ /**
24
+ * Build the complete dashboard data object from raw scan inputs.
25
+ *
26
+ * @param {object} raw - All I/O data collected before this function is called.
27
+ * @returns {object} Data object ready for generateDashboardHtml().
28
+ */
29
+ export function buildDashboardData(raw) {
30
+ // ── 1. Repo Classification ──────────────────────────────────────────────
31
+
32
+ const configured = [];
33
+ const unconfigured = [];
34
+ const seenNames = new Map();
35
+
36
+ for (const repo of raw.repos) {
37
+ // Collision-safe display key
38
+ const count = (seenNames.get(repo.name) || 0) + 1;
39
+ seenNames.set(repo.name, count);
40
+ const key = count > 1 ? `${repo.name}__${count}` : repo.name;
41
+
42
+ const entry = {
43
+ key,
44
+ name: repo.name,
45
+ path: repo.path,
46
+ shortPath: repo.shortPath,
47
+ commands: repo.commands || [],
48
+ rules: repo.rules || [],
49
+ desc: repo.desc || [],
50
+ sections: repo.sections || [],
51
+ freshness: repo.freshness || 0,
52
+ freshnessText: "",
53
+ freshnessClass: "stale",
54
+ techStack: repo.techStack || [],
55
+ };
56
+
57
+ const hasConfig = entry.commands.length > 0 || entry.rules.length > 0 || repo.agentsFile;
58
+
59
+ if (hasConfig) {
60
+ entry.freshnessText = relativeTime(entry.freshness);
61
+ entry.freshnessClass = freshnessClass(entry.freshness);
62
+
63
+ // Health score
64
+ const health = computeHealthScore({
65
+ hasAgentsFile: !!repo.agentsFile,
66
+ desc: entry.desc,
67
+ commandCount: entry.commands.length,
68
+ ruleCount: entry.rules.length,
69
+ sectionCount: entry.sections.length,
70
+ freshnessClass: entry.freshnessClass,
71
+ });
72
+ entry.healthScore = health.score;
73
+ entry.healthReasons = health.reasons;
74
+ entry.hasAgentsFile = !!repo.agentsFile;
75
+ entry.configPattern = detectConfigPattern(entry);
76
+
77
+ // Drift classification from pre-computed gitRevCount (no git I/O)
78
+ entry.drift = classifyDrift(repo.gitRevCount);
79
+
80
+ configured.push(entry);
81
+ } else {
82
+ unconfigured.push(entry);
83
+ }
84
+ }
85
+
86
+ // Sort configured by richness (most config first)
87
+ configured.sort((a, b) => {
88
+ const score = (r) =>
89
+ r.commands.length * 3 + r.rules.length * 2 + r.sections.length + (r.desc.length > 0 ? 1 : 0);
90
+ return score(b) - score(a);
91
+ });
92
+
93
+ unconfigured.sort((a, b) => a.name.localeCompare(b.name));
94
+
95
+ // ── 2. Cross-Repo Analysis ────────────────────────────────────────────
96
+
97
+ // Suggestions for unconfigured repos
98
+ for (const repo of unconfigured) {
99
+ const exemplar = findExemplar(repo.techStack, configured);
100
+ if (exemplar) {
101
+ repo.suggestions = generateSuggestions(exemplar);
102
+ repo.exemplarName = exemplar.name;
103
+ } else {
104
+ repo.suggestions = [];
105
+ repo.exemplarName = "";
106
+ }
107
+ }
108
+
109
+ // Similar repos + matched skills for configured repos
110
+ for (const repo of configured) {
111
+ const similar = configured
112
+ .filter((r) => r !== repo)
113
+ .map((r) => ({
114
+ name: r.name,
115
+ similarity: computeConfigSimilarity(repo, r),
116
+ }))
117
+ .filter((r) => r.similarity >= SIMILARITY_THRESHOLD)
118
+ .sort((a, b) => b.similarity - a.similarity)
119
+ .slice(0, 2);
120
+ repo.similarRepos = similar;
121
+ repo.matchedSkills = matchSkillsToRepo(repo, raw.globalSkills);
122
+ }
123
+
124
+ // Consolidation groups
125
+ const consolidationGroups = [];
126
+ const byStack = {};
127
+ for (const repo of configured) {
128
+ for (const s of repo.techStack || []) {
129
+ if (!byStack[s]) byStack[s] = [];
130
+ byStack[s].push(repo);
131
+ }
132
+ }
133
+ for (const [stack, repos] of Object.entries(byStack)) {
134
+ if (repos.length >= 3) {
135
+ let pairCount = 0;
136
+ let simSum = 0;
137
+ for (let i = 0; i < repos.length; i++) {
138
+ for (let j = i + 1; j < repos.length; j++) {
139
+ simSum += computeConfigSimilarity(repos[i], repos[j]);
140
+ pairCount++;
141
+ }
142
+ }
143
+ const avgSimilarity = pairCount > 0 ? Math.round(simSum / pairCount) : 0;
144
+ if (avgSimilarity >= 30) {
145
+ consolidationGroups.push({
146
+ stack,
147
+ repos: repos.map((r) => r.name),
148
+ avgSimilarity,
149
+ suggestion: `${repos.length} ${stack} repos with ${avgSimilarity}% avg similarity — consider shared global rules`,
150
+ });
151
+ }
152
+ }
153
+ }
154
+
155
+ // ── 3. MCP Aggregation ────────────────────────────────────────────────
156
+
157
+ const allMcpServers = [...(raw.userMcpServers || [])];
158
+
159
+ // Add project MCP servers and attach to matching repos
160
+ for (const [repoPath, servers] of Object.entries(raw.projectMcpByRepo || {})) {
161
+ allMcpServers.push(...servers);
162
+ const repo =
163
+ configured.find((r) => r.path === repoPath) || unconfigured.find((r) => r.path === repoPath);
164
+ if (repo) repo.mcpServers = servers;
165
+ }
166
+
167
+ const mcpPromotions = findPromotionCandidates(allMcpServers);
168
+
169
+ // Build disabled-by-server counts
170
+ const disabledByServer = {};
171
+ for (const [, names] of Object.entries(raw.disabledMcpByRepo || {})) {
172
+ for (const name of names) {
173
+ disabledByServer[name] = (disabledByServer[name] || 0) + 1;
174
+ }
175
+ }
176
+
177
+ // Build mcpByName map
178
+ const mcpByName = {};
179
+ for (const s of allMcpServers) {
180
+ if (!mcpByName[s.name])
181
+ mcpByName[s.name] = {
182
+ name: s.name,
183
+ type: s.type,
184
+ projects: [],
185
+ userLevel: false,
186
+ disabledIn: 0,
187
+ };
188
+ if (s.scope === "user") mcpByName[s.name].userLevel = true;
189
+ if (s.scope === "project") mcpByName[s.name].projects.push(s.source);
190
+ }
191
+ for (const entry of Object.values(mcpByName)) {
192
+ entry.disabledIn = disabledByServer[entry.name] || 0;
193
+ }
194
+
195
+ // Historical MCP servers
196
+ const currentMcpNames = new Set(allMcpServers.map((s) => s.name));
197
+ const { recent: recentMcpServers, former: formerMcpServers } = classifyHistoricalServers(
198
+ raw.historicalMcpMap || new Map(),
199
+ currentMcpNames,
200
+ );
201
+
202
+ // Historical project paths are already normalized by the caller (collectRawInputs
203
+ // applies shortPath on the I/O side, demo data uses short paths directly).
204
+
205
+ // Merge recently-seen servers into mcpByName
206
+ for (const server of recentMcpServers) {
207
+ if (!mcpByName[server.name]) {
208
+ mcpByName[server.name] = {
209
+ name: server.name,
210
+ type: "unknown",
211
+ projects: server.projects,
212
+ userLevel: false,
213
+ disabledIn: disabledByServer[server.name] || 0,
214
+ recentlyActive: true,
215
+ };
216
+ }
217
+ }
218
+
219
+ const mcpSummary = Object.values(mcpByName).sort((a, b) => {
220
+ if (a.userLevel !== b.userLevel) return a.userLevel ? -1 : 1;
221
+ return a.name.localeCompare(b.name);
222
+ });
223
+ const mcpCount = mcpSummary.length;
224
+
225
+ // ── 4. Usage Analytics ────────────────────────────────────────────────
226
+
227
+ const usageAnalytics = aggregateSessionMeta(raw.sessionMetaFiles || []);
228
+
229
+ // ── 5. Insights Report Parsing ────────────────────────────────────────
230
+
231
+ let insightsReport = null;
232
+ if (raw.insightsReportHtml) {
233
+ try {
234
+ const reportHtml = raw.insightsReportHtml;
235
+
236
+ // Extract subtitle — reformat ISO dates to readable format
237
+ const subtitleMatch = reportHtml.match(/<p class="subtitle">([^<]+)<\/p>/);
238
+ let subtitle = subtitleMatch ? subtitleMatch[1] : null;
239
+ if (subtitle) {
240
+ subtitle = subtitle.replace(/(\d{4})-(\d{2})-(\d{2})/g, (_, y, m2, d) => {
241
+ const dt = new Date(`${y}-${m2}-${d}T00:00:00Z`);
242
+ return dt.toLocaleDateString("en-US", {
243
+ month: "short",
244
+ day: "numeric",
245
+ year: "numeric",
246
+ timeZone: "UTC",
247
+ });
248
+ });
249
+ }
250
+
251
+ // Extract glance sections
252
+ const glanceSections = [];
253
+ const glanceRe =
254
+ /<div class="glance-section"><strong>([^<]+)<\/strong>\s*([\s\S]*?)<a[^>]*class="see-more"/g;
255
+ let m;
256
+ while ((m = glanceRe.exec(reportHtml)) !== null) {
257
+ const text = m[2].replace(/<[^>]+>/g, "").trim();
258
+ glanceSections.push({ label: m[1].replace(/:$/, ""), text });
259
+ }
260
+
261
+ // Extract stats
262
+ const statsRe =
263
+ /<div class="stat-value">([^<]+)<\/div><div class="stat-label">([^<]+)<\/div>/g;
264
+ const reportStats = [];
265
+ while ((m = statsRe.exec(reportHtml)) !== null) {
266
+ const value = m[1];
267
+ const label = m[2];
268
+ const isDiff = /^[+-]/.test(value) && value.includes("/");
269
+ reportStats.push({ value, label, isDiff });
270
+ }
271
+
272
+ // Extract friction categories
273
+ const frictionRe =
274
+ /<div class="friction-title">([^<]+)<\/div>\s*<div class="friction-desc">([^<]+)<\/div>/g;
275
+ const frictionPoints = [];
276
+ while ((m = frictionRe.exec(reportHtml)) !== null) {
277
+ frictionPoints.push({ title: m[1], desc: m[2] });
278
+ }
279
+
280
+ if (glanceSections.length > 0 || reportStats.length > 0) {
281
+ insightsReport = {
282
+ subtitle,
283
+ glance: glanceSections,
284
+ stats: reportStats,
285
+ friction: frictionPoints.slice(0, 3),
286
+ filePath: raw.insightsReportPath || null,
287
+ };
288
+ }
289
+ } catch {
290
+ // skip if parsing fails
291
+ }
292
+ }
293
+
294
+ // ── 6. Stats Supplementation ──────────────────────────────────────────
295
+
296
+ // Make a copy so we don't mutate raw.statsCache
297
+ const statsCache = structuredClone(raw.statsCache || {});
298
+
299
+ // Supplement dailyActivity with session-meta data
300
+ const sessionMetaFiles = raw.sessionMetaFiles || [];
301
+ if (sessionMetaFiles.length > 0) {
302
+ const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
303
+ const sessionDayCounts = {};
304
+ for (const s of sessionMetaFiles) {
305
+ const date = (s.start_time || "").slice(0, 10);
306
+ if (!date || existingDates.has(date)) continue;
307
+ sessionDayCounts[date] =
308
+ (sessionDayCounts[date] || 0) +
309
+ (s.user_message_count || 0) +
310
+ (s.assistant_message_count || 0);
311
+ }
312
+ const supplemental = Object.entries(sessionDayCounts).map(([date, messageCount]) => ({
313
+ date,
314
+ messageCount,
315
+ }));
316
+ if (supplemental.length > 0) {
317
+ statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...supplemental].sort(
318
+ (a, b) => a.date.localeCompare(b.date),
319
+ );
320
+ }
321
+ }
322
+
323
+ // Supplement dailyActivity with ccusage data
324
+ const ccusageData = raw.ccusageData;
325
+ if (ccusageData && ccusageData.daily) {
326
+ const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
327
+ const ccusageSupplemental = ccusageData.daily
328
+ .filter((d) => d.date && !existingDates.has(d.date) && d.totalTokens > 0)
329
+ .map((d) => ({
330
+ date: d.date,
331
+ messageCount: Math.max(1, Math.round(d.totalTokens / 10000)),
332
+ }));
333
+ if (ccusageSupplemental.length > 0) {
334
+ statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...ccusageSupplemental].sort(
335
+ (a, b) => a.date.localeCompare(b.date),
336
+ );
337
+ }
338
+ }
339
+
340
+ // ── 7. Summary Stats ──────────────────────────────────────────────────
341
+
342
+ const totalRepos = raw.repos.length;
343
+ const configuredCount = configured.length;
344
+ const unconfiguredCount = unconfigured.length;
345
+ const coveragePct = totalRepos > 0 ? Math.round((configuredCount / totalRepos) * 100) : 0;
346
+ const totalRepoCmds = configured.reduce((sum, r) => sum + r.commands.length, 0);
347
+ const avgHealth =
348
+ configured.length > 0
349
+ ? Math.round(configured.reduce((sum, r) => sum + (r.healthScore || 0), 0) / configured.length)
350
+ : 0;
351
+ const driftCount = configured.filter(
352
+ (r) => r.drift && (r.drift.level === "medium" || r.drift.level === "high"),
353
+ ).length;
354
+
355
+ // ── 8. Insight Generation ─────────────────────────────────────────────
356
+
357
+ const insights = [];
358
+
359
+ // Drift alerts
360
+ const highDriftRepos = configured.filter((r) => r.drift?.level === "high");
361
+ if (highDriftRepos.length > 0) {
362
+ insights.push({
363
+ type: "warning",
364
+ title: `${highDriftRepos.length} repo${highDriftRepos.length > 1 ? "s have" : " has"} high config drift`,
365
+ detail: highDriftRepos
366
+ .map((r) => `${r.name} (${r.drift.commitsSince} commits since config update)`)
367
+ .join(", "),
368
+ action: "Review and update CLAUDE.md in these repos",
369
+ });
370
+ }
371
+
372
+ // Coverage
373
+ if (unconfigured.length > 0 && totalRepos > 0) {
374
+ const pct = Math.round((unconfigured.length / totalRepos) * 100);
375
+ if (pct >= 40) {
376
+ const withStack = unconfigured.filter((r) => r.techStack?.length > 0).slice(0, 3);
377
+ insights.push({
378
+ type: "info",
379
+ title: `${unconfigured.length} repos unconfigured (${pct}%)`,
380
+ detail: withStack.length
381
+ ? `Top candidates: ${withStack.map((r) => `${r.name} (${r.techStack.join(", ")})`).join(", ")}`
382
+ : "",
383
+ action: "Run claude-code-dashboard init --template <stack> in these repos",
384
+ });
385
+ }
386
+ }
387
+
388
+ // MCP promotions
389
+ if (mcpPromotions.length > 0) {
390
+ insights.push({
391
+ type: "promote",
392
+ title: `${mcpPromotions.length} MCP server${mcpPromotions.length > 1 ? "s" : ""} could be promoted to global`,
393
+ detail: mcpPromotions.map((p) => `${p.name} (in ${p.projects.length} projects)`).join(", "),
394
+ action: "Add to ~/.claude/mcp_config.json for all projects",
395
+ });
396
+ }
397
+
398
+ // Redundant project-scope MCP configs
399
+ const redundantMcp = Object.values(mcpByName).filter((s) => s.userLevel && s.projects.length > 0);
400
+ if (redundantMcp.length > 0) {
401
+ insights.push({
402
+ type: "tip",
403
+ title: `${redundantMcp.length} MCP server${redundantMcp.length > 1 ? "s are" : " is"} global but also in project .mcp.json`,
404
+ detail: redundantMcp.map((s) => `${s.name} (${s.projects.join(", ")})`).join("; "),
405
+ action: "Remove from project .mcp.json — global config already covers all projects",
406
+ });
407
+ }
408
+
409
+ // Skill sharing opportunities
410
+ const skillMatchCounts = {};
411
+ for (const r of configured) {
412
+ for (const sk of r.matchedSkills || []) {
413
+ const skName = typeof sk === "string" ? sk : sk.name;
414
+ if (!skillMatchCounts[skName]) skillMatchCounts[skName] = [];
415
+ skillMatchCounts[skName].push(r.name);
416
+ }
417
+ }
418
+ const widelyRelevant = Object.entries(skillMatchCounts)
419
+ .filter(([, repos]) => repos.length >= 3)
420
+ .sort((a, b) => b[1].length - a[1].length);
421
+ if (widelyRelevant.length > 0) {
422
+ const top = widelyRelevant.slice(0, 3);
423
+ insights.push({
424
+ type: "info",
425
+ title: `${widelyRelevant.length} skill${widelyRelevant.length > 1 ? "s" : ""} relevant across 3+ repos`,
426
+ detail: top.map(([name, repos]) => `${name} (${repos.length} repos)`).join(", "),
427
+ action: "Consider adding these skills to your global config",
428
+ });
429
+ }
430
+
431
+ // Health quick wins
432
+ const quickWinRepos = configured
433
+ .filter((r) => r.healthScore > 0 && r.healthScore < 80 && r.healthReasons?.length > 0)
434
+ .sort((a, b) => b.healthScore - a.healthScore)
435
+ .slice(0, 3);
436
+ if (quickWinRepos.length > 0) {
437
+ insights.push({
438
+ type: "tip",
439
+ title: "Quick wins to improve config health",
440
+ detail: quickWinRepos
441
+ .map((r) => `${r.name} (${r.healthScore}/100): ${r.healthReasons[0]}`)
442
+ .join("; "),
443
+ action: "Small changes for measurable improvement",
444
+ });
445
+ }
446
+
447
+ // Insights report nudge
448
+ if (!insightsReport) {
449
+ insights.push({
450
+ type: "info",
451
+ title: "Generate your Claude Code Insights report",
452
+ detail: "Get personalized usage patterns, friction points, and feature suggestions",
453
+ action: "Run /insights in Claude Code",
454
+ });
455
+ }
456
+
457
+ // ── 9. Timestamp ──────────────────────────────────────────────────────
458
+
459
+ const now = new Date();
460
+ const timestamp =
461
+ now
462
+ .toLocaleDateString("en-US", {
463
+ month: "short",
464
+ day: "numeric",
465
+ year: "numeric",
466
+ })
467
+ .toLowerCase() +
468
+ " at " +
469
+ now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase();
470
+
471
+ // ── Return ────────────────────────────────────────────────────────────
472
+
473
+ return {
474
+ configured,
475
+ unconfigured,
476
+ globalCmds: raw.globalCmds,
477
+ globalRules: raw.globalRules,
478
+ globalSkills: raw.globalSkills,
479
+ chains: raw.chains,
480
+ mcpSummary,
481
+ mcpPromotions,
482
+ formerMcpServers,
483
+ consolidationGroups,
484
+ usageAnalytics,
485
+ ccusageData,
486
+ statsCache,
487
+ timestamp,
488
+ coveragePct,
489
+ totalRepos,
490
+ configuredCount,
491
+ unconfiguredCount,
492
+ totalRepoCmds,
493
+ avgHealth,
494
+ driftCount,
495
+ mcpCount,
496
+ scanScope: raw.scanScope,
497
+ insights,
498
+ insightsReport,
499
+ };
500
+ }