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 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 my-ai-heatmap
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 my-ai-heatmap
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 my-ai-heatmap
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/my-ai-heatmap/?colorScheme=dark&blockSize=14
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}/my-ai-heatmap)
18
- update [--repo <owner/repo>] generate + push combined (default: {user}/my-ai-heatmap)
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 my-ai-heatmap
26
+ npx ai-heatmap init {user}-ai-heatmap
27
27
  npx ai-heatmap generate --since 20260101
28
- npx ai-heatmap push --repo my-ai-heatmap
29
- npx ai-heatmap update --repo my-ai-heatmap
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
- const repoName = process.argv[2] || "ai-heatmap";
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}/my-ai-heatmap`;
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>/my-ai-heatmap");
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.0",
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",
package/vercel.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "buildCommand": "npm run deploy",
3
+ "outputDirectory": "dist",
4
+ "framework": "vite"
5
+ }