ai-heatmap 1.0.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.
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3
+ import { resolve, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const root = resolve(__dirname, "..");
8
+
9
+ // Load config
10
+ const configPath = resolve(root, "heatmap.config.json");
11
+ const defaults = {
12
+ colorScheme: "light",
13
+ theme: "",
14
+ blockSize: 16,
15
+ blockMargin: 4,
16
+ blockRadius: 3,
17
+ bg: "",
18
+ textColor: "",
19
+ start: "",
20
+ end: "",
21
+ stats: true,
22
+ weekday: true,
23
+ };
24
+ const config = existsSync(configPath)
25
+ ? { ...defaults, ...JSON.parse(readFileSync(configPath, "utf-8")) }
26
+ : defaults;
27
+
28
+ const THEMES = {
29
+ light: ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"],
30
+ dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
31
+ blue: ["#ebedf0", "#c0ddf9", "#73b3f3", "#3886e1", "#1b4f91"],
32
+ orange: ["#ebedf0", "#ffdf80", "#ffa742", "#e87d2f", "#ac5219"],
33
+ pink: ["#ebedf0", "#ffc0cb", "#ff69b4", "#ff1493", "#c71585"],
34
+ };
35
+ const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
36
+ const DAY_NAMES = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
37
+
38
+ const BLOCK = config.blockSize;
39
+ const GAP = config.blockMargin;
40
+ const RADIUS = config.blockRadius;
41
+ const PAD = 16;
42
+ const LABEL_W = 36;
43
+ const HEADER_H = 24;
44
+
45
+ const scheme = config.colorScheme || "light";
46
+ const colors = THEMES[config.theme || scheme] || THEMES.light;
47
+ const bgColor = config.bg || (scheme === "dark" ? "#0d1117" : "transparent");
48
+ const txtColor = config.textColor || (scheme === "dark" ? "#c9d1d9" : "#24292f");
49
+ const subColor = scheme === "dark" ? "#8b949e" : "#666";
50
+ const showStats = config.stats !== false;
51
+ const showWeekday = config.weekday !== false;
52
+
53
+ function usd(n) {
54
+ return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
55
+ }
56
+
57
+ // Load data
58
+ let data = JSON.parse(readFileSync(resolve(root, "public/data.json"), "utf-8"));
59
+ if (config.start) data = data.filter(d => d.date >= config.start);
60
+ if (config.end) data = data.filter(d => d.date <= config.end);
61
+
62
+ // Group by weeks
63
+ const weeks = [];
64
+ let cur = [];
65
+ for (const d of data) {
66
+ if (new Date(d.date).getDay() === 0 && cur.length) { weeks.push(cur); cur = []; }
67
+ cur.push(d);
68
+ }
69
+ if (cur.length) weeks.push(cur);
70
+
71
+ const cols = weeks.length;
72
+ const svgW = PAD * 2 + LABEL_W + cols * (BLOCK + GAP) + GAP;
73
+ const svgH = PAD * 2 + HEADER_H + 7 * (BLOCK + GAP) + GAP + 36;
74
+
75
+ // Month labels
76
+ const monthLabels = [];
77
+ let pm = -1;
78
+ for (let w = 0; w < weeks.length; w++) {
79
+ const m = new Date(weeks[w][0].date).getMonth();
80
+ if (m !== pm) { monthLabels.push({ x: PAD + LABEL_W + w * (BLOCK + GAP), label: MONTHS[m] }); pm = m; }
81
+ }
82
+
83
+ // Rects
84
+ const rects = [];
85
+ for (let w = 0; w < weeks.length; w++) {
86
+ for (const d of weeks[w]) {
87
+ const dow = new Date(d.date).getDay();
88
+ const x = PAD + LABEL_W + w * (BLOCK + GAP);
89
+ const y = PAD + HEADER_H + dow * (BLOCK + GAP);
90
+ const cost = d.count > 0 ? `$${d.count.toFixed(2)}` : "No data";
91
+ rects.push(`<rect x="${x}" y="${y}" width="${BLOCK}" height="${BLOCK}" rx="${RADIUS}" fill="${colors[d.level] || colors[0]}"><title>${d.date}: ${cost}</title></rect>`);
92
+ }
93
+ }
94
+
95
+ // Legend
96
+ const lx = svgW - PAD - 5 * (BLOCK + GAP) - 60;
97
+ const ly = PAD + HEADER_H + 7 * (BLOCK + GAP) + 10;
98
+ 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");
99
+
100
+ // Total
101
+ const total = data.reduce((s, d) => s + d.count, 0);
102
+ const fy = data[0]?.date.slice(0, 4), ly2 = data[data.length - 1]?.date.slice(0, 4);
103
+ const yl = fy === ly2 ? fy : `${fy}~${ly2}`;
104
+
105
+ // Stats
106
+ const activeDays = data.filter(d => d.count > 0);
107
+ const dailyAvg = activeDays.length ? total / activeDays.length : 0;
108
+ const peak = activeDays.reduce((max, d) => d.count > max.count ? d : max, { count: 0, date: "-" });
109
+ const weeklyTotals = weeks.map(w => w.reduce((s, d) => s + d.count, 0));
110
+ const activeWeeks = weeklyTotals.filter(t => t > 0);
111
+ const weeklyAvg = activeWeeks.length ? activeWeeks.reduce((s, t) => s + t, 0) / activeWeeks.length : 0;
112
+
113
+ // Weekday averages
114
+ const weekdayTotals = Array(7).fill(0);
115
+ const weekdayCounts = Array(7).fill(0);
116
+ for (const d of data) {
117
+ if (d.count > 0) {
118
+ const dow = new Date(d.date).getDay();
119
+ weekdayTotals[dow] += d.count;
120
+ weekdayCounts[dow]++;
121
+ }
122
+ }
123
+ const weekdayAvgs = weekdayTotals.map((t, i) => weekdayCounts[i] ? t / weekdayCounts[i] : 0);
124
+ const maxWeekdayAvg = Math.max(...weekdayAvgs);
125
+
126
+ // Extra height
127
+ const STATS_H = showStats ? 50 : 0;
128
+ const WEEKDAY_H = showWeekday ? 180 : 0;
129
+ const totalH = svgH + STATS_H + WEEKDAY_H;
130
+
131
+ const statsY = ly + BLOCK + 20;
132
+ const weekdayY = statsY + (showStats ? 50 : 10);
133
+ const BAR_W = Math.min(300, svgW - PAD * 2 - 100);
134
+
135
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${totalH}" viewBox="0 0 ${svgW} ${totalH}">
136
+ <rect width="100%" height="100%" fill="${bgColor}" rx="6"/>
137
+ <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>
138
+ ${monthLabels.map(m => `<text x="${m.x}" y="${PAD + 14}" class="month">${m.label}</text>`).join("\n")}
139
+ <text x="${PAD}" y="${PAD + HEADER_H + 1 * (BLOCK + GAP) + BLOCK - 2}" class="day">Mon</text>
140
+ <text x="${PAD}" y="${PAD + HEADER_H + 3 * (BLOCK + GAP) + BLOCK - 2}" class="day">Wed</text>
141
+ <text x="${PAD}" y="${PAD + HEADER_H + 5 * (BLOCK + GAP) + BLOCK - 2}" class="day">Fri</text>
142
+ ${rects.join("\n")}
143
+ <text x="${lx}" y="${ly + BLOCK - 1}" class="legend-label">Less</text>
144
+ ${lr}
145
+ <text x="${lx + 40 + 5 * (BLOCK + GAP)}" y="${ly + BLOCK - 1}" class="legend-label">More</text>
146
+ <text x="${PAD + LABEL_W}" y="${ly + BLOCK - 1}" class="total">${usd(total)} total (${yl})</text>
147
+ ${showStats ? `
148
+ <line x1="${PAD}" y1="${statsY - 6}" x2="${svgW - PAD}" y2="${statsY - 6}" stroke="${scheme === "dark" ? "#30363d" : "#d0d7de"}" stroke-width="1"/>
149
+ <text x="${PAD}" y="${statsY + 12}" class="stat">Daily avg: <tspan class="stat-val">${usd(dailyAvg)}</tspan></text>
150
+ <text x="${PAD + 200}" y="${statsY + 12}" class="stat">Weekly avg: <tspan class="stat-val">${usd(weeklyAvg)}</tspan></text>
151
+ <text x="${PAD}" y="${statsY + 30}" class="stat">Peak: <tspan class="stat-val">${usd(peak.count)}</tspan> (${peak.date})</text>
152
+ <text x="${PAD + 200}" y="${statsY + 30}" class="stat">Active: <tspan class="stat-val">${activeDays.length}</tspan> / ${data.length} days</text>
153
+ ` : ""}
154
+ ${showWeekday ? `
155
+ <text x="${PAD}" y="${weekdayY}" class="section-title">Avg by weekday</text>
156
+ ${DAY_NAMES.map((name, i) => {
157
+ const barY = weekdayY + 14 + i * 22;
158
+ const barLen = maxWeekdayAvg > 0 ? (weekdayAvgs[i] / maxWeekdayAvg) * BAR_W : 0;
159
+ const barColor = colors[Math.min(4, Math.ceil((weekdayAvgs[i] / (maxWeekdayAvg || 1)) * 4))];
160
+ return `<text x="${PAD}" y="${barY + 12}" class="bar-label">${name}</text>` +
161
+ `<rect x="${PAD + 36}" y="${barY + 2}" width="${barLen}" height="14" rx="3" fill="${barColor}" opacity="0.85"/>` +
162
+ `<text x="${PAD + 42 + barLen}" y="${barY + 13}" class="bar-val">${usd(weekdayAvgs[i])}</text>`;
163
+ }).join("\n")}
164
+ ` : ""}
165
+ </svg>`;
166
+
167
+ const outPath = resolve(root, "public/heatmap.svg");
168
+ writeFileSync(outPath, svg);
169
+ console.log(`Generated ${outPath} (config: ${configPath})`);
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import { writeFileSync, mkdirSync } from "node:fs";
4
+ import { resolve, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const root = resolve(__dirname, "..");
9
+
10
+ const args = process.argv.slice(2);
11
+ const sinceFlag = args.find((a) => a.startsWith("--since"));
12
+ const untilFlag = args.find((a) => a.startsWith("--until"));
13
+
14
+ let cmd = "npx ccusage@latest daily --json";
15
+ if (sinceFlag) cmd += ` ${sinceFlag}`;
16
+ if (untilFlag) cmd += ` ${untilFlag}`;
17
+
18
+ console.log(`Running: ${cmd}`);
19
+ const raw = execSync(cmd, { encoding: "utf-8", timeout: 30000 });
20
+ const { daily } = JSON.parse(raw);
21
+
22
+ const costs = daily.map((d) => d.totalCost);
23
+ const maxCost = Math.max(...costs);
24
+
25
+ function toLevel(cost) {
26
+ if (cost === 0 || maxCost === 0) return 0;
27
+ const ratio = cost / maxCost;
28
+ if (ratio <= 0.25) return 1;
29
+ if (ratio <= 0.5) return 2;
30
+ if (ratio <= 0.75) return 3;
31
+ return 4;
32
+ }
33
+
34
+ // Build a map of existing data
35
+ const dataMap = new Map(daily.map((d) => [d.date, d]));
36
+
37
+ // Fill 365 days (from today back 364 days)
38
+ const today = new Date();
39
+ const activities = [];
40
+ for (let i = 364; i >= 0; i--) {
41
+ const d = new Date(today);
42
+ d.setDate(d.getDate() - i);
43
+ const date = d.toISOString().slice(0, 10);
44
+ const entry = dataMap.get(date);
45
+ if (entry) {
46
+ const cacheTotal = entry.cacheCreationTokens + entry.cacheReadTokens;
47
+ const cacheHitRate = cacheTotal > 0
48
+ ? Math.round((entry.cacheReadTokens / cacheTotal) * 100)
49
+ : 0;
50
+ activities.push({
51
+ date,
52
+ count: Math.round(entry.totalCost * 100) / 100,
53
+ level: toLevel(entry.totalCost),
54
+ inputTokens: entry.inputTokens,
55
+ outputTokens: entry.outputTokens,
56
+ totalTokens: entry.totalTokens,
57
+ cacheHitRate,
58
+ modelsUsed: entry.modelsUsed,
59
+ modelBreakdowns: entry.modelBreakdowns.map((m) => ({
60
+ model: m.modelName,
61
+ cost: Math.round(m.cost * 100) / 100,
62
+ })),
63
+ });
64
+ } else {
65
+ activities.push({ date, count: 0, level: 0 });
66
+ }
67
+ }
68
+
69
+ const outDir = resolve(root, "public");
70
+ mkdirSync(outDir, { recursive: true });
71
+ const outPath = resolve(outDir, "data.json");
72
+ writeFileSync(outPath, JSON.stringify(activities, null, 2));
73
+ console.log(`Generated ${outPath} (${activities.length} days)`);
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:http";
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { URL } from "node:url";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const root = resolve(__dirname, "..");
10
+
11
+ const THEMES = {
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
+ const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
19
+
20
+ function num(v, def) { if (v == null || v === "") return def; const n = Number(v); return isNaN(n) ? def : n; }
21
+
22
+ function generateSvg(query) {
23
+ const BLOCK = num(query.get("blockSize"), 16);
24
+ const GAP = num(query.get("blockMargin"), 4);
25
+ const RADIUS = num(query.get("blockRadius"), 3);
26
+ const PAD = 16;
27
+ const LABEL_W = 36;
28
+ const HEADER_H = 24;
29
+ const scheme = query.get("colorScheme") || "light";
30
+ const colors = THEMES[query.get("theme") || scheme] || THEMES.light;
31
+ const bgColor = query.get("bg") || (scheme === "dark" ? "#0d1117" : "transparent");
32
+ const txtColor = query.get("textColor") || (scheme === "dark" ? "#c9d1d9" : "#24292f");
33
+ const subColor = scheme === "dark" ? "#8b949e" : "#666";
34
+
35
+ const dataPath = resolve(root, "public/data.json");
36
+ let data = JSON.parse(readFileSync(dataPath, "utf-8"));
37
+ const start = query.get("start"), end = query.get("end");
38
+ if (start) data = data.filter(d => d.date >= start);
39
+ if (end) data = data.filter(d => d.date <= end);
40
+
41
+ const weeks = []; let cur = [];
42
+ for (const d of data) {
43
+ if (new Date(d.date).getDay() === 0 && cur.length) { weeks.push(cur); cur = []; }
44
+ cur.push(d);
45
+ }
46
+ if (cur.length) weeks.push(cur);
47
+
48
+ const cols = weeks.length;
49
+ const svgW = PAD * 2 + LABEL_W + cols * (BLOCK + GAP) + GAP;
50
+ const svgH = PAD * 2 + HEADER_H + 7 * (BLOCK + GAP) + GAP + 36;
51
+
52
+
53
+ const monthLabels = []; let pm = -1;
54
+ for (let w = 0; w < weeks.length; w++) {
55
+ const m = new Date(weeks[w][0].date).getMonth();
56
+ if (m !== pm) { monthLabels.push({ x: PAD + LABEL_W + w * (BLOCK + GAP), label: MONTHS[m] }); pm = m; }
57
+ }
58
+
59
+ const rects = [];
60
+ for (let w = 0; w < weeks.length; w++) {
61
+ for (const d of weeks[w]) {
62
+ const dow = new Date(d.date).getDay();
63
+ const x = PAD + LABEL_W + w * (BLOCK + GAP);
64
+ const y = PAD + HEADER_H + dow * (BLOCK + GAP);
65
+ const cost = d.count > 0 ? `$${d.count.toFixed(2)}` : "No data";
66
+ rects.push(`<rect x="${x}" y="${y}" width="${BLOCK}" height="${BLOCK}" rx="${RADIUS}" fill="${colors[d.level] || colors[0]}"><title>${d.date}: ${cost}</title></rect>`);
67
+ }
68
+ }
69
+
70
+ const lx = svgW - PAD - 5 * (BLOCK + GAP) - 60;
71
+ const ly = PAD + HEADER_H + 7 * (BLOCK + GAP) + 10;
72
+ 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");
73
+ const total = data.reduce((s, d) => s + d.count, 0);
74
+ const usd = (n) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
75
+ const tf = usd(total);
76
+ const fy = data[0]?.date.slice(0, 4), ly2 = data[data.length - 1]?.date.slice(0, 4);
77
+ const yl = fy === ly2 ? fy : `${fy}~${ly2}`;
78
+
79
+ // Stats
80
+ const showStats = query.get("stats") !== "false";
81
+ const showWeekday = query.get("weekday") !== "false";
82
+ const activeDays = data.filter(d => d.count > 0);
83
+ const dailyAvg = activeDays.length ? total / activeDays.length : 0;
84
+ const peak = activeDays.reduce((max, d) => d.count > max.count ? d : max, { count: 0, date: "-" });
85
+
86
+ // Weekday averages
87
+ const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
88
+ const weekdayTotals = Array(7).fill(0);
89
+ const weekdayCounts = Array(7).fill(0);
90
+ for (const d of data) {
91
+ if (d.count > 0) {
92
+ const dow = new Date(d.date).getDay();
93
+ weekdayTotals[dow] += d.count;
94
+ weekdayCounts[dow]++;
95
+ }
96
+ }
97
+ const weekdayAvgs = weekdayTotals.map((t, i) => weekdayCounts[i] ? t / weekdayCounts[i] : 0);
98
+ const maxWeekdayAvg = Math.max(...weekdayAvgs);
99
+
100
+ // Weekly averages
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
+ // Extra height
106
+ const STATS_H = showStats ? 50 : 0;
107
+ const WEEKDAY_H = showWeekday ? 180 : 0;
108
+ const totalH = svgH + STATS_H + WEEKDAY_H;
109
+
110
+ // Stats section Y
111
+ const statsY = ly + BLOCK + 20;
112
+ const weekdayY = statsY + (showStats ? 50 : 10);
113
+ const BAR_W = Math.min(300, svgW - PAD * 2 - 100);
114
+
115
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${totalH}" viewBox="0 0 ${svgW} ${totalH}">
116
+ <rect width="100%" height="100%" fill="${bgColor}" rx="6"/>
117
+ <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>
118
+ ${monthLabels.map(m => `<text x="${m.x}" y="${PAD + 14}" class="month">${m.label}</text>`).join("\n")}
119
+ <text x="${PAD}" y="${PAD + HEADER_H + 1 * (BLOCK + GAP) + BLOCK - 2}" class="day">Mon</text>
120
+ <text x="${PAD}" y="${PAD + HEADER_H + 3 * (BLOCK + GAP) + BLOCK - 2}" class="day">Wed</text>
121
+ <text x="${PAD}" y="${PAD + HEADER_H + 5 * (BLOCK + GAP) + BLOCK - 2}" class="day">Fri</text>
122
+ ${rects.join("\n")}
123
+ <text x="${lx}" y="${ly + BLOCK - 1}" class="legend-label">Less</text>
124
+ ${lr}
125
+ <text x="${lx + 40 + 5 * (BLOCK + GAP)}" y="${ly + BLOCK - 1}" class="legend-label">More</text>
126
+ <text x="${PAD + LABEL_W}" y="${ly + BLOCK - 1}" class="total">${tf} total (${yl})</text>
127
+ ${showStats ? `
128
+ <line x1="${PAD}" y1="${statsY - 6}" x2="${svgW - PAD}" y2="${statsY - 6}" stroke="${scheme === "dark" ? "#30363d" : "#d0d7de"}" stroke-width="1"/>
129
+ <text x="${PAD}" y="${statsY + 12}" class="stat">Daily avg: <tspan class="stat-val">${usd(dailyAvg)}</tspan></text>
130
+ <text x="${PAD + 200}" y="${statsY + 12}" class="stat">Weekly avg: <tspan class="stat-val">${usd(weeklyAvg)}</tspan></text>
131
+ <text x="${PAD}" y="${statsY + 30}" class="stat">Peak: <tspan class="stat-val">${usd(peak.count)}</tspan> (${peak.date})</text>
132
+ <text x="${PAD + 200}" y="${statsY + 30}" class="stat">Active: <tspan class="stat-val">${activeDays.length}</tspan> / ${data.length} days</text>
133
+ ` : ""}
134
+ ${showWeekday ? `
135
+ <text x="${PAD}" y="${weekdayY}" class="section-title">Avg by weekday</text>
136
+ ${DAY_NAMES.map((name, i) => {
137
+ const barY = weekdayY + 14 + i * 22;
138
+ const barLen = maxWeekdayAvg > 0 ? (weekdayAvgs[i] / maxWeekdayAvg) * BAR_W : 0;
139
+ const barColor = colors[Math.min(4, Math.ceil((weekdayAvgs[i] / (maxWeekdayAvg || 1)) * 4))];
140
+ return `<text x="${PAD}" y="${barY + 12}" class="bar-label">${name}</text>` +
141
+ `<rect x="${PAD + 36}" y="${barY + 2}" width="${barLen}" height="14" rx="3" fill="${barColor}" opacity="0.85"/>` +
142
+ `<text x="${PAD + 42 + barLen}" y="${barY + 13}" class="bar-val">${usd(weekdayAvgs[i])}</text>`;
143
+ }).join("\n")}
144
+ ` : ""}
145
+ </svg>`;
146
+ }
147
+
148
+ const server = createServer((req, res) => {
149
+ const url = new URL(req.url, "http://localhost");
150
+ if (url.pathname === "/api/heatmap" || url.pathname === "/api/heatmap.svg") {
151
+ res.writeHead(200, { "Content-Type": "image/svg+xml", "Access-Control-Allow-Origin": "*" });
152
+ res.end(generateSvg(url.searchParams));
153
+ } else {
154
+ res.writeHead(200, { "Content-Type": "text/html" });
155
+ res.end(`<html><body style="padding:2rem;font-family:sans-serif">
156
+ <h2>AI Heatmap SVG API</h2>
157
+ <p><a href="/api/heatmap">Default (light)</a></p>
158
+ <p><a href="/api/heatmap?colorScheme=dark">Dark</a></p>
159
+ <p><a href="/api/heatmap?theme=blue">Blue</a></p>
160
+ <p><a href="/api/heatmap?theme=orange">Orange</a></p>
161
+ <p><a href="/api/heatmap?theme=pink">Pink</a></p>
162
+ <p><a href="/api/heatmap?colorScheme=dark&blockSize=16&blockMargin=4">Dark + Large</a></p>
163
+ <p><a href="/api/heatmap?start=2026-02-01&end=2026-02-18">Feb only</a></p>
164
+ <p><a href="/api/heatmap?stats=false&weekday=false">Heatmap only</a></p>
165
+ <hr/><h3>Preview:</h3>
166
+ <img src="/api/heatmap" style="max-width:100%" /><br/><br/>
167
+ <img src="/api/heatmap?colorScheme=dark" style="max-width:100%" />
168
+ </body></html>`);
169
+ }
170
+ });
171
+
172
+ server.listen(3333, () => console.log("SVG API: http://localhost:3333"));
package/src/App.css ADDED
@@ -0,0 +1,94 @@
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ html, body {
8
+ height: 100%;
9
+ overflow: hidden;
10
+ }
11
+
12
+ body {
13
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
14
+ padding: 1rem;
15
+ }
16
+
17
+ [data-color-scheme="dark"] {
18
+ background: #0d1117;
19
+ color: #c9d1d9;
20
+ }
21
+
22
+ .container {
23
+ max-width: 960px;
24
+ margin: 0 auto;
25
+ overflow: hidden;
26
+ }
27
+
28
+ h1 {
29
+ font-size: 1.5rem;
30
+ margin-bottom: 0.5rem;
31
+ }
32
+
33
+ .summary {
34
+ color: #666;
35
+ margin-bottom: 1.5rem;
36
+ font-size: 0.9rem;
37
+ }
38
+
39
+ [data-color-scheme="dark"] .summary {
40
+ color: #8b949e;
41
+ }
42
+
43
+ .error {
44
+ color: #d73a49;
45
+ font-weight: 600;
46
+ }
47
+
48
+ code {
49
+ background: #f0f0f0;
50
+ padding: 2px 6px;
51
+ border-radius: 3px;
52
+ font-size: 0.85em;
53
+ }
54
+
55
+ .params-help {
56
+ margin-top: 2rem;
57
+ font-size: 0.85rem;
58
+ color: #666;
59
+ }
60
+
61
+ [data-color-scheme="dark"] .params-help {
62
+ color: #8b949e;
63
+ }
64
+
65
+ .params-help summary {
66
+ cursor: pointer;
67
+ font-weight: 600;
68
+ margin-bottom: 0.5rem;
69
+ }
70
+
71
+ .params-help table {
72
+ border-collapse: collapse;
73
+ width: 100%;
74
+ }
75
+
76
+ .params-help th,
77
+ .params-help td {
78
+ border: 1px solid #d0d7de;
79
+ padding: 6px 12px;
80
+ text-align: left;
81
+ }
82
+
83
+ [data-color-scheme="dark"] .params-help th,
84
+ [data-color-scheme="dark"] .params-help td {
85
+ border-color: #30363d;
86
+ }
87
+
88
+ .params-help th {
89
+ background: #f6f8fa;
90
+ }
91
+
92
+ [data-color-scheme="dark"] .params-help th {
93
+ background: #161b22;
94
+ }