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.
- package/.github/workflows/deploy.yml +5 -3
- package/README.md +18 -2
- package/api/heatmap.ts +16 -163
- package/bin/cli.mjs +10 -4
- package/bin/init.mjs +35 -0
- package/package.json +6 -3
- package/public/heatmap.svg +373 -373
- package/scripts/generate-svg.mjs +120 -107
- package/scripts/generate-svg.ts +51 -0
- package/scripts/serve-svg.ts +60 -0
- package/src/App.tsx +4 -49
- package/src/lib/constants.ts +31 -0
- package/src/lib/svg-builder.ts +150 -0
- package/src/lib/utils.ts +92 -0
- package/vite.config.ts +3 -2
- package/scripts/serve-svg.mjs +0 -172
|
@@ -31,14 +31,16 @@ jobs:
|
|
|
31
31
|
- run: npm install
|
|
32
32
|
|
|
33
33
|
- name: Generate static SVG from data.json
|
|
34
|
-
run:
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
|
10
|
+
colorScheme,
|
|
35
11
|
blockSize: bs,
|
|
36
12
|
blockMargin: bm,
|
|
37
13
|
blockRadius: br,
|
|
38
14
|
start,
|
|
39
15
|
end,
|
|
40
|
-
theme
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
17
|
-
update
|
|
18
|
-
|
|
19
|
-
|
|
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(``, "");
|
|
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.
|
|
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
|
}
|