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.
- package/.github/workflows/deploy.yml +50 -0
- package/README.md +208 -0
- package/bin/cli.mjs +60 -0
- package/bin/init.mjs +120 -0
- package/bin/push.mjs +59 -0
- package/index.html +12 -0
- package/package.json +44 -0
- package/public/data.json +2236 -0
- package/public/heatmap.svg +410 -0
- package/scripts/generate-svg.mjs +169 -0
- package/scripts/generate.mjs +73 -0
- package/scripts/serve-svg.mjs +172 -0
- package/src/App.css +94 -0
- package/src/App.tsx +225 -0
- package/src/main.tsx +10 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +7 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { ActivityCalendar } from "react-activity-calendar";
|
|
3
|
+
import { Tooltip as ReactTooltip } from "react-tooltip";
|
|
4
|
+
import "react-tooltip/dist/react-tooltip.css";
|
|
5
|
+
|
|
6
|
+
interface ModelBreakdown {
|
|
7
|
+
model: string;
|
|
8
|
+
cost: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Activity {
|
|
12
|
+
date: string;
|
|
13
|
+
count: number;
|
|
14
|
+
level: number;
|
|
15
|
+
inputTokens?: number;
|
|
16
|
+
outputTokens?: number;
|
|
17
|
+
totalTokens?: number;
|
|
18
|
+
cacheHitRate?: number;
|
|
19
|
+
modelsUsed?: string[];
|
|
20
|
+
modelBreakdowns?: ModelBreakdown[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CalendarOptions {
|
|
24
|
+
blockSize: number;
|
|
25
|
+
blockMargin: number;
|
|
26
|
+
blockRadius: number;
|
|
27
|
+
fontSize: number;
|
|
28
|
+
hideColorLegend: boolean;
|
|
29
|
+
hideMonthLabels: boolean;
|
|
30
|
+
hideTotalCount: boolean;
|
|
31
|
+
showWeekdayLabels: boolean;
|
|
32
|
+
weekStart: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
33
|
+
colorScheme: "light" | "dark";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_OPTIONS: CalendarOptions = {
|
|
37
|
+
blockSize: 12,
|
|
38
|
+
blockMargin: 3,
|
|
39
|
+
blockRadius: 2,
|
|
40
|
+
fontSize: 12,
|
|
41
|
+
hideColorLegend: false,
|
|
42
|
+
hideMonthLabels: false,
|
|
43
|
+
hideTotalCount: false,
|
|
44
|
+
showWeekdayLabels: true,
|
|
45
|
+
weekStart: 0,
|
|
46
|
+
colorScheme: "light",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function parseOptions(): CalendarOptions {
|
|
50
|
+
const params = new URLSearchParams(window.location.search);
|
|
51
|
+
const opts = { ...DEFAULT_OPTIONS };
|
|
52
|
+
|
|
53
|
+
const bool = (key: keyof CalendarOptions) => {
|
|
54
|
+
const v = params.get(key);
|
|
55
|
+
if (v !== null) {
|
|
56
|
+
(opts as Record<string, unknown>)[key] = v === "true" || v === "1";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const num = (key: keyof CalendarOptions) => {
|
|
60
|
+
const v = params.get(key);
|
|
61
|
+
if (v !== null && !isNaN(Number(v))) {
|
|
62
|
+
(opts as Record<string, unknown>)[key] = Number(v);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
num("blockSize");
|
|
67
|
+
num("blockMargin");
|
|
68
|
+
num("blockRadius");
|
|
69
|
+
num("fontSize");
|
|
70
|
+
bool("hideColorLegend");
|
|
71
|
+
bool("hideMonthLabels");
|
|
72
|
+
bool("hideTotalCount");
|
|
73
|
+
bool("showWeekdayLabels");
|
|
74
|
+
num("weekStart");
|
|
75
|
+
|
|
76
|
+
const cs = params.get("colorScheme");
|
|
77
|
+
if (cs === "light" || cs === "dark") opts.colorScheme = cs;
|
|
78
|
+
|
|
79
|
+
return opts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function commas(n: number) {
|
|
83
|
+
return n.toLocaleString("en-US");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatUSD(n: number) {
|
|
87
|
+
return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatTokens(n: number) {
|
|
91
|
+
return commas(n);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shortModel(name: string) {
|
|
95
|
+
return name
|
|
96
|
+
.replace("claude-", "")
|
|
97
|
+
.replace(/-\d{8}$/, "")
|
|
98
|
+
.replace(/-preview$/, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default function App() {
|
|
102
|
+
const [data, setData] = useState<Activity[]>([]);
|
|
103
|
+
const [options] = useState(parseOptions);
|
|
104
|
+
const [error, setError] = useState<string | null>(null);
|
|
105
|
+
|
|
106
|
+
const params = new URLSearchParams(window.location.search);
|
|
107
|
+
const startDate = params.get("start");
|
|
108
|
+
const endDate = params.get("end");
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
fetch("./data.json")
|
|
112
|
+
.then((r) => {
|
|
113
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
114
|
+
return r.json();
|
|
115
|
+
})
|
|
116
|
+
.then(setData)
|
|
117
|
+
.catch((e) => setError(`Failed to load data.json: ${e.message}`));
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
if (error) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="container">
|
|
123
|
+
<p className="error">{error}</p>
|
|
124
|
+
<p>
|
|
125
|
+
Run <code>npm run generate</code> to create data.json
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!data.length) {
|
|
132
|
+
return (
|
|
133
|
+
<div className="container">
|
|
134
|
+
<p>Loading...</p>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const filtered = data.filter((d) => {
|
|
140
|
+
if (startDate && d.date < startDate) return false;
|
|
141
|
+
if (endDate && d.date > endDate) return false;
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const totalCost = filtered.reduce((s, d) => s + d.count, 0);
|
|
146
|
+
const firstYear = filtered[0]?.date.slice(0, 4);
|
|
147
|
+
const lastYear = filtered[filtered.length - 1]?.date.slice(0, 4);
|
|
148
|
+
const yearLabel = firstYear === lastYear ? firstYear : `${firstYear}~${lastYear}`;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
className="container"
|
|
153
|
+
data-color-scheme={options.colorScheme}
|
|
154
|
+
>
|
|
155
|
+
<h1>AI Usage Heatmap</h1>
|
|
156
|
+
<p className="summary">
|
|
157
|
+
Total: {formatUSD(totalCost)} across {filtered.length} days ({yearLabel})
|
|
158
|
+
</p>
|
|
159
|
+
|
|
160
|
+
<ActivityCalendar
|
|
161
|
+
data={filtered}
|
|
162
|
+
blockSize={options.blockSize}
|
|
163
|
+
blockMargin={options.blockMargin}
|
|
164
|
+
blockRadius={options.blockRadius}
|
|
165
|
+
fontSize={options.fontSize}
|
|
166
|
+
hideColorLegend={options.hideColorLegend}
|
|
167
|
+
hideMonthLabels={options.hideMonthLabels}
|
|
168
|
+
hideTotalCount={true}
|
|
169
|
+
showWeekdayLabels={options.showWeekdayLabels}
|
|
170
|
+
weekStart={options.weekStart}
|
|
171
|
+
colorScheme={options.colorScheme}
|
|
172
|
+
labels={{
|
|
173
|
+
totalCount: "{{count}} USD spent in {{year}}",
|
|
174
|
+
}}
|
|
175
|
+
theme={{
|
|
176
|
+
light: ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"],
|
|
177
|
+
dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
|
|
178
|
+
}}
|
|
179
|
+
renderBlock={(block, activity) => {
|
|
180
|
+
const a = activity as Activity;
|
|
181
|
+
if (a.count === 0) return block;
|
|
182
|
+
const lines = [
|
|
183
|
+
`<strong>${a.date}</strong>`,
|
|
184
|
+
`Cost: ${formatUSD(a.count)}`,
|
|
185
|
+
a.inputTokens != null ? `In: ${formatTokens(a.inputTokens)} / Out: ${formatTokens(a.outputTokens ?? 0)}` : "",
|
|
186
|
+
a.totalTokens ? `Total: ${formatTokens(a.totalTokens)}` : "",
|
|
187
|
+
a.cacheHitRate != null ? `Cache hit: ${a.cacheHitRate}%` : "",
|
|
188
|
+
...(a.modelBreakdowns?.map((m) =>
|
|
189
|
+
`${shortModel(m.model)}: ${formatUSD(m.cost)}`
|
|
190
|
+
) ?? []),
|
|
191
|
+
].filter(Boolean);
|
|
192
|
+
return (
|
|
193
|
+
<g data-tooltip-id="heatmap-tooltip" data-tooltip-html={lines.join("<br/>")}>
|
|
194
|
+
{block}
|
|
195
|
+
</g>
|
|
196
|
+
);
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
<ReactTooltip id="heatmap-tooltip" />
|
|
200
|
+
|
|
201
|
+
<details className="params-help">
|
|
202
|
+
<summary>Query Parameters</summary>
|
|
203
|
+
<table>
|
|
204
|
+
<thead>
|
|
205
|
+
<tr><th>Param</th><th>Default</th><th>Description</th></tr>
|
|
206
|
+
</thead>
|
|
207
|
+
<tbody>
|
|
208
|
+
<tr><td>blockSize</td><td>14</td><td>Block pixel size</td></tr>
|
|
209
|
+
<tr><td>blockMargin</td><td>4</td><td>Gap between blocks</td></tr>
|
|
210
|
+
<tr><td>blockRadius</td><td>2</td><td>Block border radius</td></tr>
|
|
211
|
+
<tr><td>fontSize</td><td>14</td><td>Label font size</td></tr>
|
|
212
|
+
<tr><td>hideColorLegend</td><td>false</td><td>Hide color legend</td></tr>
|
|
213
|
+
<tr><td>hideMonthLabels</td><td>false</td><td>Hide month labels</td></tr>
|
|
214
|
+
<tr><td>hideTotalCount</td><td>false</td><td>Hide total count</td></tr>
|
|
215
|
+
<tr><td>showWeekdayLabels</td><td>true</td><td>Show weekday labels</td></tr>
|
|
216
|
+
<tr><td>weekStart</td><td>0</td><td>Week start day (0=Sun)</td></tr>
|
|
217
|
+
<tr><td>colorScheme</td><td>light</td><td>light / dark</td></tr>
|
|
218
|
+
<tr><td>start</td><td>-</td><td>Start date (YYYY-MM-DD)</td></tr>
|
|
219
|
+
<tr><td>end</td><td>-</td><td>End date (YYYY-MM-DD)</td></tr>
|
|
220
|
+
</tbody>
|
|
221
|
+
</table>
|
|
222
|
+
</details>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"moduleDetection": "force",
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"forceConsistentCasingInFileNames": true
|
|
19
|
+
},
|
|
20
|
+
"include": ["src"]
|
|
21
|
+
}
|