ai-heatmap 1.3.0 → 1.4.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.
- package/README.md +4 -4
- package/api/heatmap.ts +200 -0
- package/bin/cli.mjs +5 -5
- package/bin/init.mjs +15 -1
- package/bin/push.mjs +2 -2
- package/package.json +4 -2
- package/vercel.json +5 -0
package/README.md
CHANGED
|
@@ -34,15 +34,15 @@ npx ai-heatmap generate
|
|
|
34
34
|
|
|
35
35
|
# Init a new GitHub Pages repo
|
|
36
36
|
npx ai-heatmap init
|
|
37
|
-
npx ai-heatmap init
|
|
37
|
+
npx ai-heatmap init {user}-ai-heatmap
|
|
38
38
|
|
|
39
39
|
# Push data to repo
|
|
40
40
|
npx ai-heatmap push
|
|
41
|
-
npx ai-heatmap push --repo
|
|
41
|
+
npx ai-heatmap push --repo {user}-ai-heatmap
|
|
42
42
|
|
|
43
43
|
# Generate + push in one step
|
|
44
44
|
npx ai-heatmap update
|
|
45
|
-
npx ai-heatmap update --repo
|
|
45
|
+
npx ai-heatmap update --repo {user}-ai-heatmap
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
## SVG API (Vercel)
|
|
@@ -99,7 +99,7 @@ The interactive version with tooltips is deployed via GitHub Pages. Tooltips sho
|
|
|
99
99
|
All options are controlled via query string:
|
|
100
100
|
|
|
101
101
|
```
|
|
102
|
-
https://owner.github.io/
|
|
102
|
+
https://owner.github.io/{user}-ai-heatmap/?colorScheme=dark&blockSize=14
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
| Parameter | Default | Description |
|
package/api/heatmap.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
interface Activity {
|
|
6
|
+
date: string;
|
|
7
|
+
count: number;
|
|
8
|
+
level: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const THEMES: Record<string, string[]> = {
|
|
12
|
+
light: ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"],
|
|
13
|
+
dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
|
|
14
|
+
blue: ["#ebedf0", "#c0ddf9", "#73b3f3", "#3886e1", "#1b4f91"],
|
|
15
|
+
orange: ["#ebedf0", "#ffdf80", "#ffa742", "#e87d2f", "#ac5219"],
|
|
16
|
+
pink: ["#ebedf0", "#ffc0cb", "#ff69b4", "#ff1493", "#c71585"],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
20
|
+
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
21
|
+
|
|
22
|
+
function num(v: string | string[] | undefined, def: number): number {
|
|
23
|
+
if (v == null || v === "") return def;
|
|
24
|
+
const n = Number(v);
|
|
25
|
+
return isNaN(n) ? def : n;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function usd(n: number): string {
|
|
29
|
+
return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function handler(req: VercelRequest, res: VercelResponse) {
|
|
33
|
+
const {
|
|
34
|
+
colorScheme = "light",
|
|
35
|
+
blockSize: bs,
|
|
36
|
+
blockMargin: bm,
|
|
37
|
+
blockRadius: br,
|
|
38
|
+
start,
|
|
39
|
+
end,
|
|
40
|
+
theme: themeName,
|
|
41
|
+
bg,
|
|
42
|
+
textColor,
|
|
43
|
+
stats: statsParam,
|
|
44
|
+
weekday: weekdayParam,
|
|
45
|
+
} = req.query;
|
|
46
|
+
|
|
47
|
+
const BLOCK = num(bs as string, 16);
|
|
48
|
+
const GAP = num(bm as string, 4);
|
|
49
|
+
const RADIUS = num(br as string, 3);
|
|
50
|
+
const PAD = 16;
|
|
51
|
+
const LABEL_W = 36;
|
|
52
|
+
const HEADER_H = 24;
|
|
53
|
+
|
|
54
|
+
const scheme = (colorScheme as string) || "light";
|
|
55
|
+
const colors = THEMES[(themeName as string) || scheme] || THEMES.light;
|
|
56
|
+
const bgColor = (bg as string) || (scheme === "dark" ? "#0d1117" : "transparent");
|
|
57
|
+
const txtColor = (textColor as string) || (scheme === "dark" ? "#c9d1d9" : "#24292f");
|
|
58
|
+
const subColor = scheme === "dark" ? "#8b949e" : "#666";
|
|
59
|
+
|
|
60
|
+
// Load data
|
|
61
|
+
let data: Activity[];
|
|
62
|
+
try {
|
|
63
|
+
const raw = readFileSync(resolve(process.cwd(), "public/data.json"), "utf-8");
|
|
64
|
+
data = JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
res.setHeader("Content-Type", "image/svg+xml");
|
|
67
|
+
res.status(500).send(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="40"><text x="10" y="25" fill="red">data.json not found</text></svg>`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Filter by date range
|
|
72
|
+
if (start) data = data.filter((d) => d.date >= (start as string));
|
|
73
|
+
if (end) data = data.filter((d) => d.date <= (end as string));
|
|
74
|
+
|
|
75
|
+
// Group by weeks
|
|
76
|
+
const weeks: Activity[][] = [];
|
|
77
|
+
let currentWeek: Activity[] = [];
|
|
78
|
+
for (const d of data) {
|
|
79
|
+
const dow = new Date(d.date).getDay();
|
|
80
|
+
if (dow === 0 && currentWeek.length > 0) {
|
|
81
|
+
weeks.push(currentWeek);
|
|
82
|
+
currentWeek = [];
|
|
83
|
+
}
|
|
84
|
+
currentWeek.push(d);
|
|
85
|
+
}
|
|
86
|
+
if (currentWeek.length) weeks.push(currentWeek);
|
|
87
|
+
|
|
88
|
+
const cols = weeks.length;
|
|
89
|
+
const svgW = PAD * 2 + LABEL_W + cols * (BLOCK + GAP) + GAP;
|
|
90
|
+
const svgH = PAD * 2 + HEADER_H + 7 * (BLOCK + GAP) + GAP + 36;
|
|
91
|
+
|
|
92
|
+
// Month labels
|
|
93
|
+
const monthLabels: { x: number; label: string }[] = [];
|
|
94
|
+
let prevMonth = -1;
|
|
95
|
+
for (let w = 0; w < weeks.length; w++) {
|
|
96
|
+
const month = new Date(weeks[w][0].date).getMonth();
|
|
97
|
+
if (month !== prevMonth) {
|
|
98
|
+
monthLabels.push({ x: PAD + LABEL_W + w * (BLOCK + GAP), label: MONTHS[month] });
|
|
99
|
+
prevMonth = month;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Rects
|
|
104
|
+
const rects: string[] = [];
|
|
105
|
+
for (let w = 0; w < weeks.length; w++) {
|
|
106
|
+
for (const d of weeks[w]) {
|
|
107
|
+
const dow = new Date(d.date).getDay();
|
|
108
|
+
const x = PAD + LABEL_W + w * (BLOCK + GAP);
|
|
109
|
+
const y = PAD + HEADER_H + dow * (BLOCK + GAP);
|
|
110
|
+
const color = colors[d.level] || colors[0];
|
|
111
|
+
const cost = d.count > 0 ? `$${d.count.toFixed(2)}` : "No data";
|
|
112
|
+
rects.push(`<rect x="${x}" y="${y}" width="${BLOCK}" height="${BLOCK}" rx="${RADIUS}" fill="${color}"><title>${d.date}: ${cost}</title></rect>`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Legend
|
|
117
|
+
const legendX = svgW - PAD - 5 * (BLOCK + GAP) - 60;
|
|
118
|
+
const legendY = PAD + HEADER_H + 7 * (BLOCK + GAP) + 10;
|
|
119
|
+
const legendRects = colors
|
|
120
|
+
.map((c, i) => `<rect x="${legendX + 40 + i * (BLOCK + GAP)}" y="${legendY}" width="${BLOCK}" height="${BLOCK}" rx="${RADIUS}" fill="${c}"/>`)
|
|
121
|
+
.join("\n");
|
|
122
|
+
|
|
123
|
+
// Total
|
|
124
|
+
const totalCost = data.reduce((s, d) => s + d.count, 0);
|
|
125
|
+
const totalFormatted = usd(totalCost);
|
|
126
|
+
const firstYear = data[0]?.date.slice(0, 4);
|
|
127
|
+
const lastYear = data[data.length - 1]?.date.slice(0, 4);
|
|
128
|
+
const yearLabel = firstYear === lastYear ? firstYear : `${firstYear}~${lastYear}`;
|
|
129
|
+
|
|
130
|
+
// Stats
|
|
131
|
+
const showStats = (statsParam as string) !== "false";
|
|
132
|
+
const showWeekday = (weekdayParam as string) !== "false";
|
|
133
|
+
const activeDays = data.filter((d) => d.count > 0);
|
|
134
|
+
const dailyAvg = activeDays.length ? totalCost / activeDays.length : 0;
|
|
135
|
+
const peak = activeDays.reduce((max, d) => (d.count > max.count ? d : max), { count: 0, date: "-" });
|
|
136
|
+
|
|
137
|
+
// Weekday averages
|
|
138
|
+
const weekdayTotals = Array(7).fill(0);
|
|
139
|
+
const weekdayCounts = Array(7).fill(0);
|
|
140
|
+
for (const d of data) {
|
|
141
|
+
if (d.count > 0) {
|
|
142
|
+
const dow = new Date(d.date).getDay();
|
|
143
|
+
weekdayTotals[dow] += d.count;
|
|
144
|
+
weekdayCounts[dow]++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const weekdayAvgs = weekdayTotals.map((t: number, i: number) => (weekdayCounts[i] ? t / weekdayCounts[i] : 0));
|
|
148
|
+
const maxWeekdayAvg = Math.max(...weekdayAvgs);
|
|
149
|
+
|
|
150
|
+
// Weekly averages
|
|
151
|
+
const weeklyTotals = weeks.map((w) => w.reduce((s, d) => s + d.count, 0));
|
|
152
|
+
const activeWeeks = weeklyTotals.filter((t) => t > 0);
|
|
153
|
+
const weeklyAvg = activeWeeks.length ? activeWeeks.reduce((s, t) => s + t, 0) / activeWeeks.length : 0;
|
|
154
|
+
|
|
155
|
+
// Extra height
|
|
156
|
+
const STATS_H = showStats ? 50 : 0;
|
|
157
|
+
const WEEKDAY_H = showWeekday ? 180 : 0;
|
|
158
|
+
const totalH = svgH + STATS_H + WEEKDAY_H;
|
|
159
|
+
|
|
160
|
+
// Stats section Y
|
|
161
|
+
const statsY = legendY + BLOCK + 20;
|
|
162
|
+
const weekdayY = statsY + (showStats ? 50 : 10);
|
|
163
|
+
const BAR_W = Math.min(300, svgW - PAD * 2 - 100);
|
|
164
|
+
|
|
165
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${totalH}" viewBox="0 0 ${svgW} ${totalH}">
|
|
166
|
+
<rect width="100%" height="100%" fill="${bgColor}" rx="6"/>
|
|
167
|
+
<style>text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;fill:${txtColor}}.month{font-size:10px}.day{font-size:10px}.legend-label{font-size:10px;fill:${subColor}}.total{font-size:11px;font-weight:600}.stat{font-size:11px;fill:${subColor}}.stat-val{font-size:11px;font-weight:600;fill:${txtColor}}.bar-label{font-size:11px;fill:${subColor}}.bar-val{font-size:10px;fill:${subColor}}.section-title{font-size:12px;font-weight:600}</style>
|
|
168
|
+
${monthLabels.map((m) => `<text x="${m.x}" y="${PAD + 14}" class="month">${m.label}</text>`).join("\n")}
|
|
169
|
+
<text x="${PAD}" y="${PAD + HEADER_H + 1 * (BLOCK + GAP) + BLOCK - 2}" class="day">Mon</text>
|
|
170
|
+
<text x="${PAD}" y="${PAD + HEADER_H + 3 * (BLOCK + GAP) + BLOCK - 2}" class="day">Wed</text>
|
|
171
|
+
<text x="${PAD}" y="${PAD + HEADER_H + 5 * (BLOCK + GAP) + BLOCK - 2}" class="day">Fri</text>
|
|
172
|
+
${rects.join("\n")}
|
|
173
|
+
<text x="${legendX}" y="${legendY + BLOCK - 1}" class="legend-label">Less</text>
|
|
174
|
+
${legendRects}
|
|
175
|
+
<text x="${legendX + 40 + 5 * (BLOCK + GAP)}" y="${legendY + BLOCK - 1}" class="legend-label">More</text>
|
|
176
|
+
<text x="${PAD + LABEL_W}" y="${legendY + BLOCK - 1}" class="total">${totalFormatted} total (${yearLabel})</text>
|
|
177
|
+
${showStats ? `
|
|
178
|
+
<line x1="${PAD}" y1="${statsY - 6}" x2="${svgW - PAD}" y2="${statsY - 6}" stroke="${scheme === "dark" ? "#30363d" : "#d0d7de"}" stroke-width="1"/>
|
|
179
|
+
<text x="${PAD}" y="${statsY + 12}" class="stat">Daily avg: <tspan class="stat-val">${usd(dailyAvg)}</tspan></text>
|
|
180
|
+
<text x="${PAD + 200}" y="${statsY + 12}" class="stat">Weekly avg: <tspan class="stat-val">${usd(weeklyAvg)}</tspan></text>
|
|
181
|
+
<text x="${PAD}" y="${statsY + 30}" class="stat">Peak: <tspan class="stat-val">${usd(peak.count)}</tspan> (${peak.date})</text>
|
|
182
|
+
<text x="${PAD + 200}" y="${statsY + 30}" class="stat">Active: <tspan class="stat-val">${activeDays.length}</tspan> / ${data.length} days</text>
|
|
183
|
+
` : ""}
|
|
184
|
+
${showWeekday ? `
|
|
185
|
+
<text x="${PAD}" y="${weekdayY}" class="section-title">Avg by weekday</text>
|
|
186
|
+
${DAY_NAMES.map((name, i) => {
|
|
187
|
+
const barY = weekdayY + 14 + i * 22;
|
|
188
|
+
const barLen = maxWeekdayAvg > 0 ? (weekdayAvgs[i] / maxWeekdayAvg) * BAR_W : 0;
|
|
189
|
+
const barColor = colors[Math.min(4, Math.ceil((weekdayAvgs[i] / (maxWeekdayAvg || 1)) * 4))];
|
|
190
|
+
return `<text x="${PAD}" y="${barY + 12}" class="bar-label">${name}</text>` +
|
|
191
|
+
`<rect x="${PAD + 36}" y="${barY + 2}" width="${barLen}" height="14" rx="3" fill="${barColor}" opacity="0.85"/>` +
|
|
192
|
+
`<text x="${PAD + 42 + barLen}" y="${barY + 13}" class="bar-val">${usd(weekdayAvgs[i])}</text>`;
|
|
193
|
+
}).join("\n")}
|
|
194
|
+
` : ""}
|
|
195
|
+
</svg>`;
|
|
196
|
+
|
|
197
|
+
res.setHeader("Content-Type", "image/svg+xml");
|
|
198
|
+
res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600");
|
|
199
|
+
res.status(200).send(svg);
|
|
200
|
+
}
|
package/bin/cli.mjs
CHANGED
|
@@ -14,8 +14,8 @@ ai-heatmap - AI usage cost heatmap
|
|
|
14
14
|
Commands:
|
|
15
15
|
init [repo-name] Create a new heatmap GitHub Pages repo
|
|
16
16
|
generate [options] Generate data.json from ccusage
|
|
17
|
-
push [--repo <owner/repo>] Push data.json to target repo (default: {user}/
|
|
18
|
-
update [--repo <owner/repo>] generate + push combined (default: {user}/
|
|
17
|
+
push [--repo <owner/repo>] Push data.json to target repo (default: {user}/{user}-ai-heatmap)
|
|
18
|
+
update [--repo <owner/repo>] generate + push combined (default: {user}/{user}-ai-heatmap)
|
|
19
19
|
deploy Deploy to Vercel (SVG API endpoint)
|
|
20
20
|
|
|
21
21
|
Generate options:
|
|
@@ -23,10 +23,10 @@ Generate options:
|
|
|
23
23
|
--until YYYYMMDD End date
|
|
24
24
|
|
|
25
25
|
Examples:
|
|
26
|
-
npx ai-heatmap init
|
|
26
|
+
npx ai-heatmap init {user}-ai-heatmap
|
|
27
27
|
npx ai-heatmap generate --since 20260101
|
|
28
|
-
npx ai-heatmap push --repo
|
|
29
|
-
npx ai-heatmap update --repo
|
|
28
|
+
npx ai-heatmap push --repo {user}-ai-heatmap
|
|
29
|
+
npx ai-heatmap update --repo {user}-ai-heatmap
|
|
30
30
|
npx ai-heatmap deploy
|
|
31
31
|
`;
|
|
32
32
|
|
package/bin/init.mjs
CHANGED
|
@@ -6,7 +6,12 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
|
|
7
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const templateRoot = resolve(__dirname, "..");
|
|
9
|
-
|
|
9
|
+
let defaultName = "ai-heatmap";
|
|
10
|
+
try {
|
|
11
|
+
const owner = execSync("gh api user --jq .login", { encoding: "utf-8" }).trim();
|
|
12
|
+
defaultName = `${owner}-ai-heatmap`;
|
|
13
|
+
} catch {}
|
|
14
|
+
const repoName = process.argv[2] || defaultName;
|
|
10
15
|
const targetDir = resolve(process.cwd(), repoName);
|
|
11
16
|
|
|
12
17
|
if (existsSync(targetDir)) {
|
|
@@ -38,6 +43,7 @@ const pkg = {
|
|
|
38
43
|
devDependencies: {
|
|
39
44
|
"@types/react": "^19.2.14",
|
|
40
45
|
"@types/react-dom": "^19.2.3",
|
|
46
|
+
"@vercel/node": "^5.6.4",
|
|
41
47
|
"@vitejs/plugin-react": "^5.1.4",
|
|
42
48
|
typescript: "^5.9.3",
|
|
43
49
|
vite: "^7.3.1",
|
|
@@ -53,6 +59,7 @@ const filesToCopy = [
|
|
|
53
59
|
"index.html",
|
|
54
60
|
"vite.config.ts",
|
|
55
61
|
"tsconfig.json",
|
|
62
|
+
"vercel.json",
|
|
56
63
|
".gitignore",
|
|
57
64
|
];
|
|
58
65
|
for (const f of filesToCopy) {
|
|
@@ -69,6 +76,13 @@ for (const f of srcFiles) {
|
|
|
69
76
|
);
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
// Copy api/
|
|
80
|
+
mkdirSync(resolve(targetDir, "api"), { recursive: true });
|
|
81
|
+
copyFileSync(
|
|
82
|
+
resolve(templateRoot, "api/heatmap.ts"),
|
|
83
|
+
resolve(targetDir, "api/heatmap.ts"),
|
|
84
|
+
);
|
|
85
|
+
|
|
72
86
|
// Create public/
|
|
73
87
|
mkdirSync(resolve(targetDir, "public"), { recursive: true });
|
|
74
88
|
|
package/bin/push.mjs
CHANGED
|
@@ -14,7 +14,7 @@ let repo = repoIdx !== -1 ? args[repoIdx + 1] : null;
|
|
|
14
14
|
try {
|
|
15
15
|
const owner = execSync("gh api user --jq .login", { encoding: "utf-8" }).trim();
|
|
16
16
|
if (!repo) {
|
|
17
|
-
repo = `${owner}
|
|
17
|
+
repo = `${owner}/${owner}-ai-heatmap`;
|
|
18
18
|
console.log(`No --repo specified, using default: ${repo}`);
|
|
19
19
|
} else if (!repo.includes("/")) {
|
|
20
20
|
repo = `${owner}/${repo}`;
|
|
@@ -22,7 +22,7 @@ try {
|
|
|
22
22
|
} catch {
|
|
23
23
|
if (!repo || !repo.includes("/")) {
|
|
24
24
|
console.error("Usage: ai-heatmap push --repo <owner/repo>");
|
|
25
|
-
console.error("Example: ai-heatmap push --repo <owner
|
|
25
|
+
console.error("Example: ai-heatmap push --repo <owner>/<owner>-ai-heatmap");
|
|
26
26
|
process.exit(1);
|
|
27
27
|
}
|
|
28
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-heatmap",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "AI usage cost heatmap powered by ccusage + react-activity-calendar",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"vite.config.ts",
|
|
16
16
|
"tsconfig.json",
|
|
17
17
|
".gitignore",
|
|
18
|
-
".github/"
|
|
18
|
+
".github/",
|
|
19
|
+
"api/",
|
|
20
|
+
"vercel.json"
|
|
19
21
|
],
|
|
20
22
|
"scripts": {
|
|
21
23
|
"generate": "node scripts/generate.mjs",
|