ai-heatmap 1.12.0 → 1.13.1

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.
@@ -31,14 +31,16 @@ jobs:
31
31
  - run: npm install
32
32
 
33
33
  - name: Generate static SVG from data.json
34
- run: npm run generate:svg --if-present
34
+ run: npx --yes ai-heatmap generate-svg
35
35
 
36
36
  - name: Commit heatmap.svg if changed
37
37
  run: |
38
38
  git config user.name "github-actions[bot]"
39
39
  git config user.email "github-actions[bot]@users.noreply.github.com"
40
- git add public/heatmap.svg
41
- git diff --staged --quiet || (git commit -m "chore: update heatmap.svg [skip ci]" && git push)
40
+ if [ -f public/heatmap.svg ]; then
41
+ git add public/heatmap.svg
42
+ git diff --staged --quiet || (git commit -m "chore: update heatmap.svg [skip ci]" && git push)
43
+ fi
42
44
 
43
45
  - run: npm run build
44
46
 
package/README.md CHANGED
@@ -10,6 +10,22 @@
10
10
  GitHub-style heatmap for your AI usage costs.
11
11
  Powered by [ccusage](https://github.com/ryoppippi/ccusage) + [react-activity-calendar](https://github.com/grubersjoe/react-activity-calendar).
12
12
 
13
+ ## Quick Start
14
+
15
+ ```shell
16
+ brew install gh
17
+ gh auth login
18
+ ```
19
+
20
+ ```shell
21
+ USER=$(gh api user -q .login)
22
+
23
+ npx --yes ai-heatmap@latest init
24
+
25
+ USER=$(gh api user -q .login)
26
+ open "https://${USER}.github.io/${USER}-ai-heatmap/heatmap.svg"
27
+ ```
28
+
13
29
  ## Preview
14
30
 
15
31
  <!-- Replace seunggabi-ai-heatmap.vercel.app with your actual Vercel deployment URL -->
@@ -34,11 +50,11 @@ Powered by [ccusage](https://github.com/ryoppippi/ccusage) + [react-activity-cal
34
50
 
35
51
  ```bash
36
52
  # Init a new heatmap repo (creates repo + generates data + pushes)
37
- npx ai-heatmap init
53
+ npx --yes ai-heatmap init
38
54
  # npx ai-heatmap init --repo {user}-ai-heatmap
39
55
 
40
56
  # Update data (generate + push)
41
- npx ai-heatmap update
57
+ npx --yes ai-heatmap update
42
58
  # npx ai-heatmap update --repo {user}-ai-heatmap
43
59
  ```
44
60
 
package/api/heatmap.ts CHANGED
@@ -1,62 +1,25 @@
1
1
  import type { VercelRequest, VercelResponse } from "@vercel/node";
2
2
  import { readFileSync } from "node:fs";
3
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
- }
4
+ import type { Activity } from "../src/lib/constants";
5
+ import { parseNum } from "../src/lib/utils";
6
+ import { buildHeatmapSVG } from "../src/lib/svg-builder";
31
7
 
32
8
  export default function handler(req: VercelRequest, res: VercelResponse) {
33
9
  const {
34
- colorScheme = "light",
10
+ colorScheme,
35
11
  blockSize: bs,
36
12
  blockMargin: bm,
37
13
  blockRadius: br,
38
14
  start,
39
15
  end,
40
- theme: themeName,
16
+ theme,
41
17
  bg,
42
18
  textColor,
43
19
  stats: statsParam,
44
20
  weekday: weekdayParam,
45
21
  } = req.query;
46
22
 
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
23
  // Load data
61
24
  let data: Activity[];
62
25
  try {
@@ -72,127 +35,17 @@ export default function handler(req: VercelRequest, res: VercelResponse) {
72
35
  if (start) data = data.filter((d) => d.date >= (start as string));
73
36
  if (end) data = data.filter((d) => d.date <= (end as string));
74
37
 
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>`;
38
+ const svg = buildHeatmapSVG(data, {
39
+ colorScheme: (colorScheme as string) || "light",
40
+ theme: theme as string,
41
+ blockSize: parseNum(bs as string, 16),
42
+ blockMargin: parseNum(bm as string, 4),
43
+ blockRadius: parseNum(br as string, 3),
44
+ bg: bg as string,
45
+ textColor: textColor as string,
46
+ stats: (statsParam as string) !== "false",
47
+ weekday: (weekdayParam as string) !== "false",
48
+ });
196
49
 
197
50
  res.setHeader("Content-Type", "image/svg+xml");
198
51
  res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600");
package/bin/cli.mjs CHANGED
@@ -13,10 +13,11 @@ const HELP = `
13
13
  ai-heatmap - AI usage cost heatmap
14
14
 
15
15
  Commands:
16
- init [--repo <name>] Create a new heatmap repo and generate initial data
17
- update [--repo <owner/repo>] Generate data + push to repo
18
- delete [--repo <name>] Delete the heatmap GitHub repo
19
- deploy [--repo <name>] Deploy to Vercel (SVG API endpoint)
16
+ init [--repo <name>] Create a new heatmap repo and generate initial data
17
+ update [--repo <owner/repo>] Generate data + push to repo
18
+ generate-svg Generate heatmap.svg from data.json
19
+ delete [--repo <name>] Delete the heatmap GitHub repo
20
+ deploy [--repo <name>] Deploy to Vercel (SVG API endpoint)
20
21
 
21
22
  Options:
22
23
  --repo <name> Target repo (default: {user}-ai-heatmap)
@@ -61,6 +62,11 @@ switch (command) {
61
62
  execSync(`node ${script} ${repoName}`, { stdio: "inherit" });
62
63
  break;
63
64
  }
65
+ case "generate-svg": {
66
+ const svgScript = resolve(root, "scripts/generate-svg.mjs");
67
+ execSync(`node ${svgScript}`, { stdio: "inherit", cwd: process.cwd() });
68
+ break;
69
+ }
64
70
  case "deploy": {
65
71
  try {
66
72
  execSync("vercel --version", { stdio: "ignore" });
package/bin/init.mjs CHANGED
@@ -78,6 +78,16 @@ for (const f of srcFiles) {
78
78
  );
79
79
  }
80
80
 
81
+ // Copy src/lib/
82
+ mkdirSync(resolve(targetDir, "src/lib"), { recursive: true });
83
+ const libFiles = ["constants.ts", "utils.ts", "svg-builder.ts"];
84
+ for (const f of libFiles) {
85
+ copyFileSync(
86
+ resolve(templateRoot, "src/lib", f),
87
+ resolve(targetDir, "src/lib", f),
88
+ );
89
+ }
90
+
81
91
  // Copy api/
82
92
  mkdirSync(resolve(targetDir, "api"), { recursive: true });
83
93
  copyFileSync(
@@ -105,6 +115,31 @@ writeFileSync(
105
115
  JSON.stringify(defaultConfig, null, 2),
106
116
  );
107
117
 
118
+ // README.md
119
+ let ghUser = "";
120
+ try {
121
+ ghUser = execSync("gh api user --jq .login", { encoding: "utf-8" }).trim();
122
+ } catch {}
123
+ const pagesUrl = ghUser ? `https://${ghUser}.github.io/${repoName}` : "";
124
+ const readmeLines = [
125
+ `# ${repoName}`,
126
+ "",
127
+ "AI usage cost heatmap powered by [ai-heatmap](https://github.com/seunggabi/ai-heatmap).",
128
+ "",
129
+ ];
130
+ if (pagesUrl) {
131
+ readmeLines.push(`![AI Heatmap](${pagesUrl}/heatmap.svg)`, "");
132
+ }
133
+ readmeLines.push(
134
+ "## Usage",
135
+ "",
136
+ "```bash",
137
+ "npx ai-heatmap update",
138
+ "```",
139
+ "",
140
+ );
141
+ writeFileSync(resolve(targetDir, "README.md"), readmeLines.join("\n"));
142
+
108
143
  // GitHub Actions workflow
109
144
  const workflowDir = resolve(targetDir, ".github/workflows");
110
145
  mkdirSync(workflowDir, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-heatmap",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "AI usage cost heatmap powered by ccusage + react-activity-calendar",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,8 @@
25
25
  "preview": "vite preview",
26
26
  "generate:svg": "node scripts/generate-svg.mjs",
27
27
  "update": "node bin/cli.mjs update",
28
- "deploy": "npm run generate && npm run generate:svg && npm run build"
28
+ "deploy": "npm run generate && npm run generate:svg && npm run build",
29
+ "test": "vitest run"
29
30
  },
30
31
  "keywords": [],
31
32
  "author": "",
@@ -41,7 +42,9 @@
41
42
  "@types/react-dom": "^19.2.3",
42
43
  "@vercel/node": "^5.6.4",
43
44
  "@vitejs/plugin-react": "^5.1.4",
45
+ "tsx": "^4.19.0",
44
46
  "typescript": "^5.9.3",
45
- "vite": "^7.3.1"
47
+ "vite": "^7.3.1",
48
+ "vitest": "^3.0.0"
46
49
  }
47
50
  }