@viren/claude-code-dashboard 0.0.4 → 0.0.5
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 +2 -2
- package/generate-dashboard.mjs +1 -1
- package/package.json +4 -3
- package/src/assembler.mjs +150 -0
- package/src/constants.mjs +1 -1
- package/src/render.mjs +0 -4
- package/src/sections.mjs +413 -0
- package/template/dashboard.css +1251 -0
- package/template/dashboard.html +51 -0
- package/template/dashboard.js +152 -0
- package/src/html-template.mjs +0 -946
package/README.md
CHANGED
|
@@ -31,9 +31,9 @@ Scans your home directory for git repos, collects Claude Code configuration (com
|
|
|
31
31
|
|
|
32
32
|

|
|
33
33
|
|
|
34
|
-
###
|
|
34
|
+
### Light mode
|
|
35
35
|
|
|
36
|
-

|
|
37
37
|
|
|
38
38
|
> Screenshots generated with `claude-code-dashboard --demo`
|
|
39
39
|
|
package/generate-dashboard.mjs
CHANGED
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
import { aggregateSessionMeta } from "./src/usage.mjs";
|
|
61
61
|
import { handleInit } from "./src/templates.mjs";
|
|
62
62
|
import { generateCatalogHtml } from "./src/render.mjs";
|
|
63
|
-
import { generateDashboardHtml } from "./src/
|
|
63
|
+
import { generateDashboardHtml } from "./src/assembler.mjs";
|
|
64
64
|
import { startWatch } from "./src/watch.mjs";
|
|
65
65
|
|
|
66
66
|
// ── CLI ──────────────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viren/claude-code-dashboard",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "A visual dashboard for your Claude Code configuration across all repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"generate": "node generate-dashboard.mjs",
|
|
11
|
-
"test": "node --test test
|
|
11
|
+
"test": "node --test test/*.test.mjs",
|
|
12
12
|
"lint": "eslint .",
|
|
13
13
|
"lint:fix": "eslint . --fix",
|
|
14
14
|
"format": "prettier --write .",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"generate-dashboard.mjs",
|
|
36
|
-
"src/"
|
|
36
|
+
"src/",
|
|
37
|
+
"template/"
|
|
37
38
|
],
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"eslint": "^9.0.0",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
|
|
5
|
+
import { esc } from "./helpers.mjs";
|
|
6
|
+
import { VERSION, REPO_URL } from "./constants.mjs";
|
|
7
|
+
import { renderCmd, renderRule, renderRepoCard } from "./render.mjs";
|
|
8
|
+
import {
|
|
9
|
+
renderSkillsCard,
|
|
10
|
+
renderMcpCard,
|
|
11
|
+
renderToolsCard,
|
|
12
|
+
renderLangsCard,
|
|
13
|
+
renderErrorsCard,
|
|
14
|
+
renderActivityCard,
|
|
15
|
+
renderChainsCard,
|
|
16
|
+
renderConsolidationCard,
|
|
17
|
+
renderUnconfiguredCard,
|
|
18
|
+
renderReferenceCard,
|
|
19
|
+
renderInsightsCard,
|
|
20
|
+
renderInsightsReportCard,
|
|
21
|
+
renderStatsBar,
|
|
22
|
+
} from "./sections.mjs";
|
|
23
|
+
|
|
24
|
+
// Resolve template directory relative to this module (works when installed via npm too)
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const TEMPLATE_DIR = join(__dirname, "..", "template");
|
|
27
|
+
|
|
28
|
+
// Cache template files (read once per process).
|
|
29
|
+
// Assumes one-shot CLI usage; watch mode spawns fresh processes.
|
|
30
|
+
let _css, _js, _html;
|
|
31
|
+
function loadTemplates() {
|
|
32
|
+
if (!_css) _css = readFileSync(join(TEMPLATE_DIR, "dashboard.css"), "utf8");
|
|
33
|
+
if (!_js) _js = readFileSync(join(TEMPLATE_DIR, "dashboard.js"), "utf8");
|
|
34
|
+
if (!_html) _html = readFileSync(join(TEMPLATE_DIR, "dashboard.html"), "utf8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function generateDashboardHtml(data) {
|
|
38
|
+
loadTemplates();
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
configured,
|
|
42
|
+
unconfigured,
|
|
43
|
+
globalCmds,
|
|
44
|
+
globalRules,
|
|
45
|
+
globalSkills,
|
|
46
|
+
chains,
|
|
47
|
+
mcpSummary,
|
|
48
|
+
mcpPromotions,
|
|
49
|
+
formerMcpServers,
|
|
50
|
+
consolidationGroups,
|
|
51
|
+
usageAnalytics,
|
|
52
|
+
ccusageData,
|
|
53
|
+
statsCache,
|
|
54
|
+
timestamp,
|
|
55
|
+
coveragePct,
|
|
56
|
+
totalRepos,
|
|
57
|
+
configuredCount,
|
|
58
|
+
unconfiguredCount,
|
|
59
|
+
scanScope,
|
|
60
|
+
insights,
|
|
61
|
+
insightsReport,
|
|
62
|
+
} = data;
|
|
63
|
+
|
|
64
|
+
// ── Build section HTML fragments ──────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const header = `<h1>claude code dashboard</h1>
|
|
67
|
+
<button id="theme-toggle" class="theme-toggle" title="Toggle light/dark mode" aria-label="Toggle theme"><span class="theme-icon"></span></button>
|
|
68
|
+
<p class="sub">generated ${timestamp} · run <code>claude-code-dashboard</code> to refresh · <a href="${esc(REPO_URL)}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none">v${esc(VERSION)}</a></p>`;
|
|
69
|
+
|
|
70
|
+
const statsBar = renderStatsBar(data);
|
|
71
|
+
|
|
72
|
+
// Overview tab
|
|
73
|
+
const overviewCommands = `<div class="top-grid">
|
|
74
|
+
<div class="card" id="section-commands" style="margin-bottom:0">
|
|
75
|
+
<h2>Global Commands <span class="n">${globalCmds.length}</span></h2>
|
|
76
|
+
${globalCmds.map((c) => renderCmd(c)).join("\n ")}
|
|
77
|
+
</div>
|
|
78
|
+
<div class="card" style="margin-bottom:0">
|
|
79
|
+
<h2>Global Rules <span class="n">${globalRules.length}</span></h2>
|
|
80
|
+
${globalRules.map((r) => renderRule(r)).join("\n ")}
|
|
81
|
+
</div>
|
|
82
|
+
</div>`;
|
|
83
|
+
const insightsHtml = renderInsightsCard(insights);
|
|
84
|
+
const chainsHtml = renderChainsCard(chains);
|
|
85
|
+
const consolidationHtml = renderConsolidationCard(consolidationGroups);
|
|
86
|
+
const tabOverview = `${overviewCommands}\n ${insightsHtml}\n ${chainsHtml}\n ${consolidationHtml}`;
|
|
87
|
+
|
|
88
|
+
// Skills & MCP tab
|
|
89
|
+
const tabSkillsMcp = `${renderSkillsCard(globalSkills)}\n ${renderMcpCard(mcpSummary, mcpPromotions, formerMcpServers)}`;
|
|
90
|
+
|
|
91
|
+
// Analytics tab
|
|
92
|
+
const insightsReportHtml = renderInsightsReportCard(insightsReport);
|
|
93
|
+
const toolsHtml = renderToolsCard(usageAnalytics.topTools);
|
|
94
|
+
const langsHtml = renderLangsCard(usageAnalytics.topLanguages);
|
|
95
|
+
const errorsHtml = renderErrorsCard(usageAnalytics.errorCategories);
|
|
96
|
+
const activityHtml = renderActivityCard(statsCache, ccusageData);
|
|
97
|
+
const tabAnalytics = `${insightsReportHtml}
|
|
98
|
+
<div class="top-grid">
|
|
99
|
+
${toolsHtml || ""}
|
|
100
|
+
${langsHtml || ""}
|
|
101
|
+
</div>
|
|
102
|
+
${errorsHtml}
|
|
103
|
+
${activityHtml}`;
|
|
104
|
+
|
|
105
|
+
// Repos tab
|
|
106
|
+
const repoCards = configured.map((r) => renderRepoCard(r)).join("\n");
|
|
107
|
+
const unconfiguredHtml = renderUnconfiguredCard(unconfigured);
|
|
108
|
+
const tabRepos = `<div class="search-bar">
|
|
109
|
+
<input type="text" id="search" placeholder="search repos..." autocomplete="off">
|
|
110
|
+
<span class="search-hint"><kbd>/</kbd></span>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="group-controls">
|
|
113
|
+
<label class="group-label">Group by:</label>
|
|
114
|
+
<select id="group-by" class="group-select">
|
|
115
|
+
<option value="none">None</option>
|
|
116
|
+
<option value="stack">Tech Stack</option>
|
|
117
|
+
<option value="parent">Parent Directory</option>
|
|
118
|
+
</select>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="repo-grid" id="repo-grid">
|
|
121
|
+
${repoCards}
|
|
122
|
+
</div>
|
|
123
|
+
${unconfiguredHtml}`;
|
|
124
|
+
|
|
125
|
+
// Reference tab
|
|
126
|
+
const tabReference = renderReferenceCard();
|
|
127
|
+
|
|
128
|
+
// Footer
|
|
129
|
+
const footer = `<div class="ts">found ${totalRepos} repos · ${configuredCount} configured · ${unconfiguredCount} unconfigured · scanned ${scanScope} · ${timestamp}</div>`;
|
|
130
|
+
|
|
131
|
+
// ── Inject dynamic coverage color via CSS custom property ─────────────────
|
|
132
|
+
const coverageColor =
|
|
133
|
+
coveragePct >= 70 ? "var(--green)" : coveragePct >= 40 ? "var(--yellow)" : "var(--red)";
|
|
134
|
+
const css = `:root { --coverage-color: ${coverageColor}; }\n${_css}`;
|
|
135
|
+
|
|
136
|
+
// ── Assemble final HTML via placeholder replacement ───────────────────────
|
|
137
|
+
let html = _html;
|
|
138
|
+
html = html.replace("<!-- {{CSS}} -->", css);
|
|
139
|
+
html = html.replace("/* {{JS}} */", _js);
|
|
140
|
+
html = html.replace("<!-- {{HEADER}} -->", header);
|
|
141
|
+
html = html.replace("<!-- {{STATS_BAR}} -->", statsBar);
|
|
142
|
+
html = html.replace("<!-- {{TAB_OVERVIEW}} -->", tabOverview);
|
|
143
|
+
html = html.replace("<!-- {{TAB_SKILLS_MCP}} -->", tabSkillsMcp);
|
|
144
|
+
html = html.replace("<!-- {{TAB_ANALYTICS}} -->", tabAnalytics);
|
|
145
|
+
html = html.replace("<!-- {{TAB_REPOS}} -->", tabRepos);
|
|
146
|
+
html = html.replace("<!-- {{TAB_REFERENCE}} -->", tabReference);
|
|
147
|
+
html = html.replace("<!-- {{FOOTER}} -->", footer);
|
|
148
|
+
|
|
149
|
+
return html;
|
|
150
|
+
}
|
package/src/constants.mjs
CHANGED
package/src/render.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { esc } from "./helpers.mjs";
|
|
2
2
|
import { extractSteps, extractSections } from "./markdown.mjs";
|
|
3
|
-
import { groupSkillsByCategory } from "./skills.mjs";
|
|
4
3
|
|
|
5
4
|
export function renderSections(sections) {
|
|
6
5
|
return sections
|
|
@@ -58,9 +57,6 @@ export function renderSkill(skill) {
|
|
|
58
57
|
return `<div class="cmd-row"><span class="cmd-name skill-name">${esc(skill.name)}</span>${badge}<span class="cmd-desc">${d}</span></div>`;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
// Re-export from skills.mjs (single source of truth)
|
|
62
|
-
export { groupSkillsByCategory };
|
|
63
|
-
|
|
64
60
|
export function renderBadges(repo) {
|
|
65
61
|
const b = [];
|
|
66
62
|
if (repo.commands.length) b.push(`<span class="badge cmds">${repo.commands.length} cmd</span>`);
|
package/src/sections.mjs
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { esc, formatTokens } from "./helpers.mjs";
|
|
2
|
+
import { renderSkill, healthScoreColor } from "./render.mjs";
|
|
3
|
+
import { groupSkillsByCategory } from "./skills.mjs";
|
|
4
|
+
import { QUICK_REFERENCE } from "./constants.mjs";
|
|
5
|
+
|
|
6
|
+
export function renderSkillsCard(globalSkills) {
|
|
7
|
+
if (!globalSkills.length) return "";
|
|
8
|
+
const groups = groupSkillsByCategory(globalSkills);
|
|
9
|
+
const categoryHtml = Object.entries(groups)
|
|
10
|
+
.map(
|
|
11
|
+
([cat, skills], idx) =>
|
|
12
|
+
`<details class="skill-category"${idx === 0 ? " open" : ""}>` +
|
|
13
|
+
`<summary class="skill-category-label">${esc(cat)} <span class="cat-n">${skills.length}</span></summary>` +
|
|
14
|
+
skills.map((s) => renderSkill(s)).join("\n ") +
|
|
15
|
+
`</details>`,
|
|
16
|
+
)
|
|
17
|
+
.join("\n ");
|
|
18
|
+
return `<div class="card" id="section-skills">
|
|
19
|
+
<h2>Skills <span class="n">${globalSkills.length}</span></h2>
|
|
20
|
+
${categoryHtml}
|
|
21
|
+
</div>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderMcpCard(mcpSummary, mcpPromotions, formerMcpServers) {
|
|
25
|
+
if (!mcpSummary.length) return "";
|
|
26
|
+
const rows = mcpSummary
|
|
27
|
+
.map((s) => {
|
|
28
|
+
const disabledClass = s.disabledIn > 0 ? " mcp-disabled" : "";
|
|
29
|
+
const disabledHint =
|
|
30
|
+
s.disabledIn > 0
|
|
31
|
+
? `<span class="mcp-disabled-hint">disabled in ${s.disabledIn} project${s.disabledIn > 1 ? "s" : ""}</span>`
|
|
32
|
+
: "";
|
|
33
|
+
const scopeBadge = s.userLevel
|
|
34
|
+
? `<span class="badge mcp-global">global</span>`
|
|
35
|
+
: s.recentlyActive
|
|
36
|
+
? `<span class="badge mcp-recent">recent</span>`
|
|
37
|
+
: `<span class="badge mcp-project">project</span>`;
|
|
38
|
+
const typeBadge = `<span class="badge mcp-type">${esc(s.type)}</span>`;
|
|
39
|
+
const projects =
|
|
40
|
+
!s.userLevel && s.projects.length
|
|
41
|
+
? `<span class="mcp-projects">${s.projects.map((p) => esc(p)).join(", ")}</span>`
|
|
42
|
+
: "";
|
|
43
|
+
return `<div class="mcp-row${disabledClass}"><span class="mcp-name">${esc(s.name)}</span>${scopeBadge}${typeBadge}${disabledHint}${projects}</div>`;
|
|
44
|
+
})
|
|
45
|
+
.join("\n ");
|
|
46
|
+
const promoteHtml = mcpPromotions.length
|
|
47
|
+
? mcpPromotions
|
|
48
|
+
.map(
|
|
49
|
+
(p) =>
|
|
50
|
+
`<div class="mcp-promote"><span class="mcp-name">${esc(p.name)}</span> installed in ${p.projects.length} projects → add to <code>~/.claude/mcp_config.json</code></div>`,
|
|
51
|
+
)
|
|
52
|
+
.join("\n ")
|
|
53
|
+
: "";
|
|
54
|
+
const formerHtml = formerMcpServers.length
|
|
55
|
+
? `<div class="label" style="margin-top:.75rem">Formerly Installed</div>
|
|
56
|
+
${formerMcpServers
|
|
57
|
+
.map((s) => {
|
|
58
|
+
const hint = s.projects.length
|
|
59
|
+
? `<span class="mcp-projects">${s.projects.map((p) => esc(p)).join(", ")}</span>`
|
|
60
|
+
: "";
|
|
61
|
+
return `<div class="mcp-row mcp-former"><span class="mcp-name">${esc(s.name)}</span><span class="badge mcp-former-badge">removed</span>${hint}</div>`;
|
|
62
|
+
})
|
|
63
|
+
.join("\n ")}`
|
|
64
|
+
: "";
|
|
65
|
+
return `<div class="card" id="section-mcp">
|
|
66
|
+
<h2>MCP Servers <span class="n">${mcpSummary.length}</span></h2>
|
|
67
|
+
${rows}
|
|
68
|
+
${promoteHtml}
|
|
69
|
+
${formerHtml}
|
|
70
|
+
</div>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderToolsCard(topTools) {
|
|
74
|
+
if (!topTools.length) return "";
|
|
75
|
+
const maxCount = topTools[0].count;
|
|
76
|
+
const rows = topTools
|
|
77
|
+
.map((t) => {
|
|
78
|
+
const pct = maxCount > 0 ? Math.round((t.count / maxCount) * 100) : 0;
|
|
79
|
+
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(t.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-tool" style="width:${pct}%"></div></div><span class="usage-bar-count">${t.count.toLocaleString()}</span></div>`;
|
|
80
|
+
})
|
|
81
|
+
.join("\n ");
|
|
82
|
+
return `<div class="card">
|
|
83
|
+
<h2>Top Tools Used <span class="n">${topTools.length}</span></h2>
|
|
84
|
+
${rows}
|
|
85
|
+
</div>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderLangsCard(topLanguages) {
|
|
89
|
+
if (!topLanguages.length) return "";
|
|
90
|
+
const maxCount = topLanguages[0].count;
|
|
91
|
+
const rows = topLanguages
|
|
92
|
+
.map((l) => {
|
|
93
|
+
const pct = maxCount > 0 ? Math.round((l.count / maxCount) * 100) : 0;
|
|
94
|
+
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(l.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-lang" style="width:${pct}%"></div></div><span class="usage-bar-count">${l.count.toLocaleString()}</span></div>`;
|
|
95
|
+
})
|
|
96
|
+
.join("\n ");
|
|
97
|
+
return `<div class="card">
|
|
98
|
+
<h2>Languages <span class="n">${topLanguages.length}</span></h2>
|
|
99
|
+
${rows}
|
|
100
|
+
</div>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function renderErrorsCard(errorCategories) {
|
|
104
|
+
if (!errorCategories.length) return "";
|
|
105
|
+
const maxCount = errorCategories[0].count;
|
|
106
|
+
const rows = errorCategories
|
|
107
|
+
.map((e) => {
|
|
108
|
+
const pct = maxCount > 0 ? Math.round((e.count / maxCount) * 100) : 0;
|
|
109
|
+
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(e.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-error" style="width:${pct}%"></div></div><span class="usage-bar-count">${e.count.toLocaleString()}</span></div>`;
|
|
110
|
+
})
|
|
111
|
+
.join("\n ");
|
|
112
|
+
return `<div class="card">
|
|
113
|
+
<h2>Top Errors <span class="n">${errorCategories.length}</span></h2>
|
|
114
|
+
${rows}
|
|
115
|
+
</div>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function renderActivityCard(statsCache, ccusageData) {
|
|
119
|
+
const dailyActivity = statsCache.dailyActivity || [];
|
|
120
|
+
const hourCounts = statsCache.hourCounts || {};
|
|
121
|
+
const modelUsage = statsCache.modelUsage || {};
|
|
122
|
+
const hasActivity = dailyActivity.length > 0;
|
|
123
|
+
const hasHours = Object.keys(hourCounts).length > 0;
|
|
124
|
+
const hasModels = Object.keys(modelUsage).length > 0;
|
|
125
|
+
|
|
126
|
+
if (!hasActivity && !hasHours && !hasModels && !ccusageData) return "";
|
|
127
|
+
|
|
128
|
+
let content = "";
|
|
129
|
+
|
|
130
|
+
if (hasActivity) {
|
|
131
|
+
const dateMap = new Map(dailyActivity.map((d) => [d.date, d.messageCount || 0]));
|
|
132
|
+
const dates = dailyActivity.map((d) => d.date).sort();
|
|
133
|
+
const lastDate = new Date(dates[dates.length - 1]);
|
|
134
|
+
const firstDate = new Date(lastDate);
|
|
135
|
+
firstDate.setDate(firstDate.getDate() - 364);
|
|
136
|
+
|
|
137
|
+
const nonZero = dailyActivity
|
|
138
|
+
.map((d) => d.messageCount || 0)
|
|
139
|
+
.filter((n) => n > 0)
|
|
140
|
+
.sort((a, b) => a - b);
|
|
141
|
+
const q1 = nonZero[Math.floor(nonZero.length * 0.25)] || 1;
|
|
142
|
+
const q2 = nonZero[Math.floor(nonZero.length * 0.5)] || 2;
|
|
143
|
+
const q3 = nonZero[Math.floor(nonZero.length * 0.75)] || 3;
|
|
144
|
+
|
|
145
|
+
function level(count) {
|
|
146
|
+
if (count === 0) return "";
|
|
147
|
+
if (count <= q1) return " l1";
|
|
148
|
+
if (count <= q2) return " l2";
|
|
149
|
+
if (count <= q3) return " l3";
|
|
150
|
+
return " l4";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const start = new Date(firstDate);
|
|
154
|
+
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
|
155
|
+
|
|
156
|
+
const months = [];
|
|
157
|
+
let lastMonth = -1;
|
|
158
|
+
const cursor1 = new Date(start);
|
|
159
|
+
let weekIdx = 0;
|
|
160
|
+
while (cursor1 <= lastDate) {
|
|
161
|
+
if (cursor1.getUTCDay() === 0) {
|
|
162
|
+
const m = cursor1.getUTCMonth();
|
|
163
|
+
if (m !== lastMonth) {
|
|
164
|
+
months.push({
|
|
165
|
+
name: cursor1.toLocaleString("en", { month: "short", timeZone: "UTC" }),
|
|
166
|
+
week: weekIdx,
|
|
167
|
+
});
|
|
168
|
+
lastMonth = m;
|
|
169
|
+
}
|
|
170
|
+
weekIdx++;
|
|
171
|
+
}
|
|
172
|
+
cursor1.setUTCDate(cursor1.getUTCDate() + 1);
|
|
173
|
+
}
|
|
174
|
+
const totalWeeks = weekIdx;
|
|
175
|
+
const monthLabels = months
|
|
176
|
+
.map((m) => {
|
|
177
|
+
const left = totalWeeks > 0 ? Math.round((m.week / totalWeeks) * 100) : 0;
|
|
178
|
+
return `<span class="heatmap-month" style="position:absolute;left:${left}%">${m.name}</span>`;
|
|
179
|
+
})
|
|
180
|
+
.join("");
|
|
181
|
+
|
|
182
|
+
let cells = "";
|
|
183
|
+
const cursor2 = new Date(start);
|
|
184
|
+
while (cursor2 <= lastDate) {
|
|
185
|
+
const key = cursor2.toISOString().slice(0, 10);
|
|
186
|
+
const count = dateMap.get(key) || 0;
|
|
187
|
+
const fmtDate = cursor2.toLocaleDateString("en-US", {
|
|
188
|
+
month: "short",
|
|
189
|
+
day: "numeric",
|
|
190
|
+
year: "numeric",
|
|
191
|
+
});
|
|
192
|
+
cells += `<div class="heatmap-cell${level(count)}" title="${esc(fmtDate)}: ${count} messages"></div>`;
|
|
193
|
+
cursor2.setUTCDate(cursor2.getUTCDate() + 1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
content += `<div class="label">Activity</div>
|
|
197
|
+
<div style="overflow-x:auto;margin-bottom:.5rem">
|
|
198
|
+
<div style="width:fit-content;position:relative">
|
|
199
|
+
<div class="heatmap-months" style="position:relative;height:.8rem">${monthLabels}</div>
|
|
200
|
+
<div class="heatmap">${cells}</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (hasHours) {
|
|
206
|
+
const maxHour = Math.max(...Object.values(hourCounts), 1);
|
|
207
|
+
let bars = "";
|
|
208
|
+
let labels = "";
|
|
209
|
+
for (let h = 0; h < 24; h++) {
|
|
210
|
+
const count = hourCounts[String(h)] || 0;
|
|
211
|
+
const pct = Math.round((count / maxHour) * 100);
|
|
212
|
+
bars += `<div class="peak-bar" style="height:${Math.max(pct, 2)}%" title="${esc(String(h))}:00 — ${count} messages"></div>`;
|
|
213
|
+
labels += `<div class="peak-label">${h % 6 === 0 ? h : ""}</div>`;
|
|
214
|
+
}
|
|
215
|
+
content += `<div class="label" style="margin-top:.75rem">Peak Hours</div>
|
|
216
|
+
<div class="peak-hours">${bars}</div>
|
|
217
|
+
<div class="peak-labels">${labels}</div>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (ccusageData) {
|
|
221
|
+
const modelCosts = {};
|
|
222
|
+
for (const day of ccusageData.daily) {
|
|
223
|
+
for (const mb of day.modelBreakdowns || []) {
|
|
224
|
+
if (!modelCosts[mb.modelName]) modelCosts[mb.modelName] = { cost: 0, tokens: 0 };
|
|
225
|
+
modelCosts[mb.modelName].cost += mb.cost || 0;
|
|
226
|
+
modelCosts[mb.modelName].tokens +=
|
|
227
|
+
(mb.inputTokens || 0) +
|
|
228
|
+
(mb.outputTokens || 0) +
|
|
229
|
+
(mb.cacheCreationTokens || 0) +
|
|
230
|
+
(mb.cacheReadTokens || 0);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const modelRows = Object.entries(modelCosts)
|
|
234
|
+
.sort((a, b) => b[1].cost - a[1].cost)
|
|
235
|
+
.map(
|
|
236
|
+
([name, data]) =>
|
|
237
|
+
`<div class="model-row"><span class="model-name">${esc(name)}</span><span class="model-tokens">$${Math.round(data.cost).toLocaleString()} · ${formatTokens(data.tokens)}</span></div>`,
|
|
238
|
+
)
|
|
239
|
+
.join("\n ");
|
|
240
|
+
|
|
241
|
+
const t = ccusageData.totals;
|
|
242
|
+
const breakdownHtml = `<div class="token-breakdown">
|
|
243
|
+
<div class="tb-row"><span class="tb-label">Cache Read</span><span class="tb-val">${formatTokens(t.cacheReadTokens)}</span></div>
|
|
244
|
+
<div class="tb-row"><span class="tb-label">Cache Creation</span><span class="tb-val">${formatTokens(t.cacheCreationTokens)}</span></div>
|
|
245
|
+
<div class="tb-row"><span class="tb-label">Output</span><span class="tb-val">${formatTokens(t.outputTokens)}</span></div>
|
|
246
|
+
<div class="tb-row"><span class="tb-label">Input</span><span class="tb-val">${formatTokens(t.inputTokens)}</span></div>
|
|
247
|
+
</div>`;
|
|
248
|
+
|
|
249
|
+
content += `<div class="label" style="margin-top:.75rem">Model Usage (via ccusage)</div>
|
|
250
|
+
${modelRows}
|
|
251
|
+
<div class="label" style="margin-top:.75rem">Token Breakdown</div>
|
|
252
|
+
${breakdownHtml}`;
|
|
253
|
+
} else if (hasModels) {
|
|
254
|
+
const modelRows = Object.entries(modelUsage)
|
|
255
|
+
.map(([name, usage]) => {
|
|
256
|
+
const total = (usage.inputTokens || 0) + (usage.outputTokens || 0);
|
|
257
|
+
return { name, total };
|
|
258
|
+
})
|
|
259
|
+
.sort((a, b) => b.total - a.total)
|
|
260
|
+
.map(
|
|
261
|
+
(m) =>
|
|
262
|
+
`<div class="model-row"><span class="model-name">${esc(m.name)}</span><span class="model-tokens">${formatTokens(m.total)}</span></div>`,
|
|
263
|
+
)
|
|
264
|
+
.join("\n ");
|
|
265
|
+
content += `<div class="label" style="margin-top:.75rem">Model Usage (partial — install ccusage for full data)</div>
|
|
266
|
+
${modelRows}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return `<div class="card" id="section-activity">
|
|
270
|
+
<h2>Activity</h2>
|
|
271
|
+
${content}
|
|
272
|
+
</div>`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function renderChainsCard(chains) {
|
|
276
|
+
if (!chains.length) return "";
|
|
277
|
+
return `<div class="card">
|
|
278
|
+
<h2>Dependency Chains</h2>
|
|
279
|
+
${chains.map((c) => `<div class="chain">${c.nodes.map((n, i) => `<span class="chain-node">${esc(n.trim())}</span>${i < c.nodes.length - 1 ? `<span class="chain-arrow">${c.arrow}</span>` : ""}`).join("")}</div>`).join("\n ")}
|
|
280
|
+
</div>`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function renderConsolidationCard(consolidationGroups) {
|
|
284
|
+
if (!consolidationGroups.length) return "";
|
|
285
|
+
return `<div class="card">
|
|
286
|
+
<h2>Consolidation Opportunities <span class="n">${consolidationGroups.length}</span></h2>
|
|
287
|
+
${consolidationGroups.map((g) => `<div class="consolidation-hint"><span class="consolidation-stack">${esc(g.stack)}</span> <span class="consolidation-text">${esc(g.suggestion)}</span></div>`).join("\n ")}
|
|
288
|
+
</div>`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function renderUnconfiguredCard(unconfigured) {
|
|
292
|
+
if (!unconfigured.length) return "";
|
|
293
|
+
return `<details class="card">
|
|
294
|
+
<summary style="cursor:pointer;list-style:none"><h2 style="margin:0">Unconfigured Repos <span class="n">${unconfigured.length}</span></h2></summary>
|
|
295
|
+
<div style="margin-top:.75rem">
|
|
296
|
+
<div class="unconfigured-grid">
|
|
297
|
+
${unconfigured
|
|
298
|
+
.map((r) => {
|
|
299
|
+
const stackTag =
|
|
300
|
+
r.techStack && r.techStack.length
|
|
301
|
+
? `<span class="stack-tag">${esc(r.techStack.join(", "))}</span>`
|
|
302
|
+
: "";
|
|
303
|
+
const suggestionsHtml =
|
|
304
|
+
r.suggestions && r.suggestions.length
|
|
305
|
+
? `<div class="suggestion-hints">${r.suggestions.map((s) => `<span class="suggestion-hint">${esc(s)}</span>`).join("")}</div>`
|
|
306
|
+
: "";
|
|
307
|
+
return `<div class="unconfigured-item">${esc(r.name)}${stackTag}<span class="upath">${esc(r.shortPath)}</span>${suggestionsHtml}</div>`;
|
|
308
|
+
})
|
|
309
|
+
.join("\n ")}
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
</details>`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function renderReferenceCard() {
|
|
316
|
+
return `<div class="card">
|
|
317
|
+
<h2>Quick Reference</h2>
|
|
318
|
+
<div class="ref-grid">
|
|
319
|
+
<div class="ref-col">
|
|
320
|
+
<div class="label">Essential Commands</div>
|
|
321
|
+
${QUICK_REFERENCE.essentialCommands.map((c) => `<div class="ref-row"><code class="ref-cmd">${esc(c.cmd)}</code><span class="ref-desc">${esc(c.desc)}</span></div>`).join("\n ")}
|
|
322
|
+
</div>
|
|
323
|
+
<div class="ref-col">
|
|
324
|
+
<div class="label">Built-in Tools</div>
|
|
325
|
+
${QUICK_REFERENCE.tools.map((t) => `<div class="ref-row"><code class="ref-cmd">${esc(t.name)}</code><span class="ref-desc">${esc(t.desc)}</span></div>`).join("\n ")}
|
|
326
|
+
<div class="label" style="margin-top:.75rem">Keyboard Shortcuts</div>
|
|
327
|
+
${QUICK_REFERENCE.shortcuts.map((s) => `<div class="ref-row"><kbd class="ref-key">${esc(s.keys)}</kbd><span class="ref-desc">${esc(s.desc)}</span></div>`).join("\n ")}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function renderInsightsCard(insights) {
|
|
334
|
+
if (!insights || !insights.length) return "";
|
|
335
|
+
return `<div class="card insight-card">
|
|
336
|
+
<h2>Insights <span class="n">${insights.length}</span></h2>
|
|
337
|
+
${insights
|
|
338
|
+
.map(
|
|
339
|
+
(i) =>
|
|
340
|
+
`<div class="insight-row ${esc(i.type)}">
|
|
341
|
+
<span class="insight-icon">${i.type === "warning" ? "⚠" : i.type === "tip" ? "✨" : i.type === "promote" ? "↑" : "ⓘ"}</span>
|
|
342
|
+
<div class="insight-body">
|
|
343
|
+
<div class="insight-title">${esc(i.title)}</div>
|
|
344
|
+
${i.detail ? `<div class="insight-detail">${esc(i.detail)}</div>` : ""}
|
|
345
|
+
${i.action ? `<div class="insight-action">${esc(i.action)}</div>` : ""}
|
|
346
|
+
</div>
|
|
347
|
+
</div>`,
|
|
348
|
+
)
|
|
349
|
+
.join("\n ")}
|
|
350
|
+
</div>`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function renderInsightsReportCard(insightsReport) {
|
|
354
|
+
if (!insightsReport) {
|
|
355
|
+
return `<div class="card report-card">
|
|
356
|
+
<h2>Claude Code Insights</h2>
|
|
357
|
+
<div class="report-glance"><div class="report-glance-item">No insights report found. Run <code>/insights</code> in Claude Code to generate a personalized report with usage patterns, friction points, and feature suggestions.</div></div>
|
|
358
|
+
</div>`;
|
|
359
|
+
}
|
|
360
|
+
return `<div class="card report-card" id="section-insights-report">
|
|
361
|
+
<h2>Claude Code Insights</h2>
|
|
362
|
+
${insightsReport.subtitle ? `<div class="report-subtitle">${esc(insightsReport.subtitle)}</div>` : ""}
|
|
363
|
+
${
|
|
364
|
+
insightsReport.stats.length > 0
|
|
365
|
+
? `<div class="report-stats">${insightsReport.stats
|
|
366
|
+
.map((s) => {
|
|
367
|
+
if (s.isDiff) {
|
|
368
|
+
const parts = s.value.match(/^([+-][^/]+)\/([-+].+)$/);
|
|
369
|
+
if (parts) {
|
|
370
|
+
return `<div class="report-stat"><b><span style="color:var(--green)">${esc(parts[1])}</span><span style="color:var(--text-dim)">/</span><span style="color:var(--red)">${esc(parts[2])}</span></b><span>${esc(s.label)}</span></div>`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return `<div class="report-stat"><b>${esc(s.value)}</b><span>${esc(s.label)}</span></div>`;
|
|
374
|
+
})
|
|
375
|
+
.join("")}</div>`
|
|
376
|
+
: ""
|
|
377
|
+
}
|
|
378
|
+
${
|
|
379
|
+
insightsReport.glance.length > 0
|
|
380
|
+
? `<div class="report-glance">${insightsReport.glance.map((g) => `<div class="report-glance-item"><strong>${esc(g.label)}:</strong> ${esc(g.text)}</div>`).join("")}</div>`
|
|
381
|
+
: ""
|
|
382
|
+
}
|
|
383
|
+
<a class="report-link" href="file://${encodeURI(insightsReport.filePath)}" target="_blank">View full insights report →</a>
|
|
384
|
+
</div>`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function renderStatsBar(data) {
|
|
388
|
+
const {
|
|
389
|
+
coveragePct,
|
|
390
|
+
configuredCount,
|
|
391
|
+
totalRepos,
|
|
392
|
+
avgHealth,
|
|
393
|
+
globalCmds,
|
|
394
|
+
globalSkills,
|
|
395
|
+
totalRepoCmds,
|
|
396
|
+
mcpCount,
|
|
397
|
+
driftCount,
|
|
398
|
+
ccusageData,
|
|
399
|
+
usageAnalytics,
|
|
400
|
+
} = data;
|
|
401
|
+
return `<div class="stats">
|
|
402
|
+
<div class="stat coverage" data-nav="repos" data-section="repo-grid" title="View repos"><b>${coveragePct}%</b><span>Coverage (${configuredCount}/${totalRepos})</span></div>
|
|
403
|
+
<div class="stat" data-nav="repos" data-section="repo-grid" title="View repos" style="${avgHealth >= 70 ? "border-color:#4ade8033" : avgHealth >= 40 ? "border-color:#fbbf2433" : "border-color:#f8717133"}"><b style="color:${healthScoreColor(avgHealth)}">${avgHealth}</b><span>Avg Health</span></div>
|
|
404
|
+
<div class="stat" data-nav="overview" data-section="section-commands" title="View commands"><b>${globalCmds.length}</b><span>Global Commands</span></div>
|
|
405
|
+
<div class="stat" data-nav="skills-mcp" data-section="section-skills" title="View skills"><b>${globalSkills.length}</b><span>Skills</span></div>
|
|
406
|
+
<div class="stat" data-nav="repos" data-section="repo-grid" title="View repos"><b>${totalRepoCmds}</b><span>Repo Commands</span></div>
|
|
407
|
+
${mcpCount > 0 ? `<div class="stat" data-nav="skills-mcp" data-section="section-mcp" title="View MCP servers"><b>${mcpCount}</b><span>MCP Servers</span></div>` : ""}
|
|
408
|
+
${driftCount > 0 ? `<div class="stat" data-nav="repos" data-section="repo-grid" title="View drifting repos" style="border-color:#f8717133"><b style="color:var(--red)">${driftCount}</b><span>Drifting Repos</span></div>` : ""}
|
|
409
|
+
${ccusageData ? `<div class="stat" data-nav="analytics" data-section="section-activity" title="View analytics" style="border-color:#4ade8033"><b style="color:var(--green)">$${Math.round(Number(ccusageData.totals.totalCost) || 0).toLocaleString()}</b><span>Total Spent</span></div>` : ""}
|
|
410
|
+
${ccusageData ? `<div class="stat" data-nav="analytics" data-section="section-activity" title="View analytics"><b>${formatTokens(ccusageData.totals.totalTokens).replace(" tokens", "")}</b><span>Total Tokens</span></div>` : ""}
|
|
411
|
+
${usageAnalytics.heavySessions > 0 ? `<div class="stat" data-nav="analytics" data-section="section-activity" title="View analytics"><b>${usageAnalytics.heavySessions}</b><span>Heavy Sessions</span></div>` : ""}
|
|
412
|
+
</div>`;
|
|
413
|
+
}
|