ai-heatmap 1.13.1 → 1.13.2
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/api/heatmap.ts +158 -10
- package/bin/init.mjs +10 -1
- package/package.json +1 -1
package/api/heatmap.ts
CHANGED
|
@@ -1,11 +1,158 @@
|
|
|
1
1
|
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import type { Activity } from "../src/lib/constants";
|
|
5
|
-
import { parseNum } from "../src/lib/utils";
|
|
6
|
-
import { buildHeatmapSVG } from "../src/lib/svg-builder";
|
|
7
2
|
|
|
8
|
-
|
|
3
|
+
// --- Inlined types & constants (Vercel serverless can't resolve ../src/lib imports) ---
|
|
4
|
+
|
|
5
|
+
interface Activity {
|
|
6
|
+
date: string;
|
|
7
|
+
count: number;
|
|
8
|
+
level: number;
|
|
9
|
+
inputTokens?: number;
|
|
10
|
+
outputTokens?: number;
|
|
11
|
+
totalTokens?: number;
|
|
12
|
+
cacheHitRate?: number;
|
|
13
|
+
modelBreakdowns?: { model: string; cost: number }[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const THEMES: Record<string, string[]> = {
|
|
17
|
+
light: ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"],
|
|
18
|
+
dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
|
|
19
|
+
blue: ["#ebedf0", "#c0ddf9", "#73b3f3", "#3886e1", "#1b4f91"],
|
|
20
|
+
orange: ["#ebedf0", "#ffdf80", "#ffa742", "#e87d2f", "#ac5219"],
|
|
21
|
+
pink: ["#ebedf0", "#ffc0cb", "#ff69b4", "#ff1493", "#c71585"],
|
|
22
|
+
};
|
|
23
|
+
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
|
24
|
+
const DAY_NAMES = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
|
|
25
|
+
|
|
26
|
+
function parseNum(v: string | undefined, def: number): number {
|
|
27
|
+
if (v == null) return def;
|
|
28
|
+
const n = Number(v);
|
|
29
|
+
return Number.isNaN(n) ? def : n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function usd(n: number) {
|
|
33
|
+
return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildHeatmapSVG(data: Activity[], options: {
|
|
37
|
+
colorScheme?: string; theme?: string; blockSize?: number; blockMargin?: number;
|
|
38
|
+
blockRadius?: number; bg?: string; textColor?: string; stats?: boolean; weekday?: boolean;
|
|
39
|
+
} = {}): string {
|
|
40
|
+
const PAD = 16, LABEL_W = 36, HEADER_H = 24;
|
|
41
|
+
const BLOCK = options.blockSize ?? 16;
|
|
42
|
+
const GAP = options.blockMargin ?? 4;
|
|
43
|
+
const RADIUS = options.blockRadius ?? 3;
|
|
44
|
+
const scheme = options.colorScheme ?? "light";
|
|
45
|
+
const colors = THEMES[options.theme ?? scheme] ?? THEMES.light;
|
|
46
|
+
const bgColor = options.bg || (scheme === "dark" ? "#0d1117" : "transparent");
|
|
47
|
+
const txtColor = options.textColor || (scheme === "dark" ? "#c9d1d9" : "#24292f");
|
|
48
|
+
const subColor = scheme === "dark" ? "#8b949e" : "#666";
|
|
49
|
+
const showStats = options.stats !== false;
|
|
50
|
+
const showWeekday = options.weekday !== false;
|
|
51
|
+
|
|
52
|
+
const weeks: Activity[][] = []; let cur: Activity[] = [];
|
|
53
|
+
for (const d of data) {
|
|
54
|
+
if (new Date(d.date).getDay() === 0 && cur.length) { weeks.push(cur); cur = []; }
|
|
55
|
+
cur.push(d);
|
|
56
|
+
}
|
|
57
|
+
if (cur.length) weeks.push(cur);
|
|
58
|
+
|
|
59
|
+
const cols = weeks.length;
|
|
60
|
+
const svgW = PAD * 2 + LABEL_W + cols * (BLOCK + GAP) + GAP;
|
|
61
|
+
const svgH = PAD * 2 + HEADER_H + 7 * (BLOCK + GAP) + GAP + 36;
|
|
62
|
+
|
|
63
|
+
const monthLabels: { x: number; label: string }[] = []; let pm = -1;
|
|
64
|
+
for (let w = 0; w < weeks.length; w++) {
|
|
65
|
+
const m = new Date(weeks[w][0].date).getMonth();
|
|
66
|
+
if (m !== pm) { monthLabels.push({ x: PAD + LABEL_W + w * (BLOCK + GAP), label: MONTHS[m] }); pm = m; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const rects: string[] = [];
|
|
70
|
+
for (let w = 0; w < weeks.length; w++) {
|
|
71
|
+
for (const d of weeks[w]) {
|
|
72
|
+
const dow = new Date(d.date).getDay();
|
|
73
|
+
const x = PAD + LABEL_W + w * (BLOCK + GAP);
|
|
74
|
+
const y = PAD + HEADER_H + dow * (BLOCK + GAP);
|
|
75
|
+
const lines = [d.date + ` (${DAY_NAMES[dow]})`];
|
|
76
|
+
if (d.count > 0) {
|
|
77
|
+
lines.push(`Cost: ${usd(d.count)}`);
|
|
78
|
+
if (d.inputTokens != null) lines.push(`In: ${d.inputTokens.toLocaleString()} / Out: ${(d.outputTokens ?? 0).toLocaleString()}`);
|
|
79
|
+
if (d.totalTokens) lines.push(`Total: ${d.totalTokens.toLocaleString()}`);
|
|
80
|
+
if (d.cacheHitRate != null) lines.push(`Cache hit: ${d.cacheHitRate}%`);
|
|
81
|
+
if (d.modelBreakdowns?.length) {
|
|
82
|
+
for (const mb of d.modelBreakdowns) lines.push(`${mb.model}: ${usd(mb.cost)}`);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
lines.push("No data");
|
|
86
|
+
}
|
|
87
|
+
rects.push(`<rect x="${x}" y="${y}" width="${BLOCK}" height="${BLOCK}" rx="${RADIUS}" fill="${colors[d.level] || colors[0]}"><title>${lines.join(" ")}</title></rect>`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lx = svgW - PAD - 5 * (BLOCK + GAP) - 60;
|
|
92
|
+
const ly = PAD + HEADER_H + 7 * (BLOCK + GAP) + 10;
|
|
93
|
+
const lr = colors.map((c, i) => `<rect x="${lx + 40 + i * (BLOCK + GAP)}" y="${ly}" width="${BLOCK}" height="${BLOCK}" rx="${RADIUS}" fill="${c}"/>`).join("\n");
|
|
94
|
+
|
|
95
|
+
const total = data.reduce((s, d) => s + d.count, 0);
|
|
96
|
+
const fy = data[0]?.date.slice(0, 4), ly2 = data[data.length - 1]?.date.slice(0, 4);
|
|
97
|
+
const yl = fy === ly2 ? fy : `${fy}~${ly2}`;
|
|
98
|
+
const activeDays = data.filter(d => d.count > 0);
|
|
99
|
+
const dailyAvg = activeDays.length ? total / activeDays.length : 0;
|
|
100
|
+
const peak = activeDays.reduce((max, d) => d.count > max.count ? d : max, { count: 0, date: "-" });
|
|
101
|
+
const weeklyTotals = weeks.map(w => w.reduce((s, d) => s + d.count, 0));
|
|
102
|
+
const activeWeeks = weeklyTotals.filter(t => t > 0);
|
|
103
|
+
const weeklyAvg = activeWeeks.length ? activeWeeks.reduce((s, t) => s + t, 0) / activeWeeks.length : 0;
|
|
104
|
+
|
|
105
|
+
const weekdayTotals = Array(7).fill(0);
|
|
106
|
+
const weekdayCounts = Array(7).fill(0);
|
|
107
|
+
for (const d of data) {
|
|
108
|
+
if (d.count > 0) { const dow = new Date(d.date).getDay(); weekdayTotals[dow] += d.count; weekdayCounts[dow]++; }
|
|
109
|
+
}
|
|
110
|
+
const weekdayAvgs = weekdayTotals.map((t: number, i: number) => weekdayCounts[i] ? t / weekdayCounts[i] : 0);
|
|
111
|
+
const maxWeekdayAvg = Math.max(...weekdayAvgs);
|
|
112
|
+
|
|
113
|
+
const STATS_H = showStats ? 50 : 0;
|
|
114
|
+
const WEEKDAY_H = showWeekday ? 180 : 0;
|
|
115
|
+
const totalH = svgH + STATS_H + WEEKDAY_H;
|
|
116
|
+
const statsY = ly + BLOCK + 20;
|
|
117
|
+
const weekdayY = statsY + (showStats ? 50 : 10);
|
|
118
|
+
const BAR_W = svgW - PAD * 2 - 100;
|
|
119
|
+
|
|
120
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${totalH}" viewBox="0 0 ${svgW} ${totalH}">
|
|
121
|
+
<rect width="100%" height="100%" fill="${bgColor}" rx="6"/>
|
|
122
|
+
<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>
|
|
123
|
+
${monthLabels.map(m => `<text x="${m.x}" y="${PAD + 14}" class="month">${m.label}</text>`).join("\n")}
|
|
124
|
+
<text x="${PAD}" y="${PAD + HEADER_H + 1 * (BLOCK + GAP) + BLOCK - 2}" class="day">Mon</text>
|
|
125
|
+
<text x="${PAD}" y="${PAD + HEADER_H + 3 * (BLOCK + GAP) + BLOCK - 2}" class="day">Wed</text>
|
|
126
|
+
<text x="${PAD}" y="${PAD + HEADER_H + 5 * (BLOCK + GAP) + BLOCK - 2}" class="day">Fri</text>
|
|
127
|
+
${rects.join("\n")}
|
|
128
|
+
<text x="${lx}" y="${ly + BLOCK - 1}" class="legend-label">Less</text>
|
|
129
|
+
${lr}
|
|
130
|
+
<text x="${lx + 40 + 5 * (BLOCK + GAP)}" y="${ly + BLOCK - 1}" class="legend-label">More</text>
|
|
131
|
+
<text x="${PAD + LABEL_W}" y="${ly + BLOCK - 1}" class="total">\u{1F4B0} Total: ${usd(total)} across ${data.length} days (${yl})</text>
|
|
132
|
+
${showStats ? `
|
|
133
|
+
<line x1="${PAD}" y1="${statsY - 6}" x2="${svgW - PAD}" y2="${statsY - 6}" stroke="${scheme === "dark" ? "#30363d" : "#d0d7de"}" stroke-width="1"/>
|
|
134
|
+
<text x="${PAD}" y="${statsY + 12}" class="stat">Daily avg: <tspan class="stat-val">${usd(dailyAvg)}</tspan></text>
|
|
135
|
+
<text x="${PAD + 200}" y="${statsY + 12}" class="stat">Weekly avg: <tspan class="stat-val">${usd(weeklyAvg)}</tspan></text>
|
|
136
|
+
<text x="${PAD}" y="${statsY + 30}" class="stat">Peak: <tspan class="stat-val">${usd(peak.count)}</tspan> (${peak.date})</text>
|
|
137
|
+
<text x="${PAD + 200}" y="${statsY + 30}" class="stat">Active: <tspan class="stat-val">${activeDays.length}</tspan> / ${data.length} days</text>
|
|
138
|
+
` : ""}
|
|
139
|
+
${showWeekday ? `
|
|
140
|
+
<text x="${PAD}" y="${weekdayY}" class="section-title">Avg by weekday</text>
|
|
141
|
+
${DAY_NAMES.map((name, i) => {
|
|
142
|
+
const barY = weekdayY + 14 + i * 22;
|
|
143
|
+
const barLen = maxWeekdayAvg > 0 ? (weekdayAvgs[i] / maxWeekdayAvg) * BAR_W : 0;
|
|
144
|
+
const barColor = colors[Math.min(4, Math.ceil((weekdayAvgs[i] / (maxWeekdayAvg || 1)) * 4))];
|
|
145
|
+
return `<text x="${PAD}" y="${barY + 12}" class="bar-label">${name}</text>` +
|
|
146
|
+
`<rect x="${PAD + 36}" y="${barY + 2}" width="${barLen}" height="14" rx="3" fill="${barColor}" opacity="0.85"/>` +
|
|
147
|
+
`<text x="${PAD + 42 + barLen}" y="${barY + 13}" class="bar-val">${usd(weekdayAvgs[i])}</text>`;
|
|
148
|
+
}).join("\n")}
|
|
149
|
+
` : ""}
|
|
150
|
+
</svg>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Handler ---
|
|
154
|
+
|
|
155
|
+
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
9
156
|
const {
|
|
10
157
|
colorScheme,
|
|
11
158
|
blockSize: bs,
|
|
@@ -20,18 +167,19 @@ export default function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
20
167
|
weekday: weekdayParam,
|
|
21
168
|
} = req.query;
|
|
22
169
|
|
|
23
|
-
// Load data
|
|
24
170
|
let data: Activity[];
|
|
25
171
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
172
|
+
const proto = req.headers["x-forwarded-proto"] || "https";
|
|
173
|
+
const host = req.headers.host;
|
|
174
|
+
const resp = await fetch(`${proto}://${host}/data.json`);
|
|
175
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
176
|
+
data = await resp.json();
|
|
28
177
|
} catch {
|
|
29
178
|
res.setHeader("Content-Type", "image/svg+xml");
|
|
30
179
|
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>`);
|
|
31
180
|
return;
|
|
32
181
|
}
|
|
33
182
|
|
|
34
|
-
// Filter by date range
|
|
35
183
|
if (start) data = data.filter((d) => d.date >= (start as string));
|
|
36
184
|
if (end) data = data.filter((d) => d.date <= (end as string));
|
|
37
185
|
|
package/bin/init.mjs
CHANGED
|
@@ -41,6 +41,7 @@ const pkg = {
|
|
|
41
41
|
"react-tooltip": "^5.30.0",
|
|
42
42
|
},
|
|
43
43
|
devDependencies: {
|
|
44
|
+
"@types/node": "^22.15.0",
|
|
44
45
|
"@types/react": "^19.2.14",
|
|
45
46
|
"@types/react-dom": "^19.2.3",
|
|
46
47
|
"@vercel/node": "^5.6.4",
|
|
@@ -134,7 +135,15 @@ readmeLines.push(
|
|
|
134
135
|
"## Usage",
|
|
135
136
|
"",
|
|
136
137
|
"```bash",
|
|
137
|
-
"npx ai-heatmap update",
|
|
138
|
+
"npx ai-heatmap@latest update",
|
|
139
|
+
"```",
|
|
140
|
+
"",
|
|
141
|
+
"## Dynamic SVG (by Vercel)",
|
|
142
|
+
"",
|
|
143
|
+
``,
|
|
144
|
+
"",
|
|
145
|
+
"```bash",
|
|
146
|
+
"npx ai-heatmap@latest deploy",
|
|
138
147
|
"```",
|
|
139
148
|
"",
|
|
140
149
|
);
|