Skip to content

Commit 96e90ba

Browse files
committed
Generated by Spark: I want to create a SPA app that allows the user to upload a CSV file from an export on the useage of GitHub Copilot premium requests. The csv looks like this:
"Timestamp","User","Model","Requests Used","Exceeds Monthly Quota","Total Monthly Quota" "2025-06-11T05:13:27.8766440Z","KoenLuyten","gpt-4.1-2025-04-14","1","False","Unlimited" "2025-06-11T05:09:40.8432110Z","KoenLuyten","gpt-4.1-2025-04-14","1","False","Unlimited" "2025-06-06T13:49:36.4346470Z","wulfland","Coding Agent","1","False","Unlimited" "2025-06-06T13:49:22.0791870Z","wulfland","Coding Agent","1","False","Unlimited" I want to show a stacked line graph with the total request per model per day. The first line shows the number of requests in green that do NOT exceed the monthly quota, and the second line (in red) shows the number of requests that did exceed the quota.
1 parent 50ecc4e commit 96e90ba

5 files changed

Lines changed: 509 additions & 4 deletions

File tree

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<head>
55
<meta charset="UTF-8" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title></title>
7+
<title>GitHub Copilot Usage Analyzer</title>
88
<link href="/src/main.css" rel="stylesheet" />
99
</head>
1010

src/App.tsx

Lines changed: 242 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,245 @@
1+
import { useState, useCallback } from "react";
2+
import { Upload } from "@phosphor-icons/react";
3+
import { toast } from "sonner";
4+
import {
5+
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
6+
} from "recharts";
7+
import { Card } from "@/components/ui/card";
8+
import { Button } from "@/components/ui/button";
9+
import { Separator } from "@/components/ui/separator";
10+
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
11+
import { AggregatedData, CopilotUsageData, aggregateDataByDay, parseCSV } from "@/lib/utils";
12+
113
function App() {
2-
return <div></div>
14+
const [data, setData] = useState<CopilotUsageData[] | null>(null);
15+
const [aggregatedData, setAggregatedData] = useState<AggregatedData[]>([]);
16+
const [uniqueModels, setUniqueModels] = useState<string[]>([]);
17+
18+
const handleFileUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
19+
const file = event.target.files?.[0];
20+
if (!file) return;
21+
22+
const reader = new FileReader();
23+
reader.onload = (e) => {
24+
try {
25+
const csvContent = e.target?.result as string;
26+
if (!csvContent) throw new Error("Failed to read file");
27+
28+
const parsedData = parseCSV(csvContent);
29+
setData(parsedData);
30+
31+
// Get unique models
32+
const models = Array.from(new Set(parsedData.map(item => item.model)));
33+
setUniqueModels(models);
34+
35+
// Aggregate data by day and model
36+
const aggregated = aggregateDataByDay(parsedData);
37+
setAggregatedData(aggregated);
38+
39+
toast.success(`Loaded ${parsedData.length} records successfully`);
40+
} catch (error) {
41+
console.error("Error parsing CSV:", error);
42+
toast.error("Failed to parse CSV file. Please check the format.");
43+
setData(null);
44+
setAggregatedData([]);
45+
}
46+
};
47+
48+
reader.readAsText(file);
49+
}, []);
50+
51+
// Generate chart data grouped by date with a stacked entry for each model
52+
const chartData = useCallback(() => {
53+
if (!aggregatedData.length) return [];
54+
55+
// Group by date first
56+
const groupedByDate: Record<string, any> = {};
57+
58+
aggregatedData.forEach(item => {
59+
if (!groupedByDate[item.date]) {
60+
groupedByDate[item.date] = { date: item.date };
61+
}
62+
63+
// Add compliant and exceeding requests for this model
64+
groupedByDate[item.date][`${item.model}_compliant`] = item.compliantRequests;
65+
groupedByDate[item.date][`${item.model}_exceeding`] = item.exceedingRequests;
66+
});
67+
68+
// Convert to array sorted by date
69+
return Object.values(groupedByDate).sort((a, b) =>
70+
a.date.localeCompare(b.date)
71+
);
72+
}, [aggregatedData]);
73+
74+
return (
75+
<div className="container max-w-7xl mx-auto py-8 px-4 min-h-screen">
76+
<header className="mb-8">
77+
<h1 className="text-3xl font-bold tracking-tight text-foreground mb-2">
78+
GitHub Copilot Usage Analyzer
79+
</h1>
80+
<p className="text-muted-foreground">
81+
Upload your Copilot usage CSV export to visualize request patterns
82+
</p>
83+
</header>
84+
85+
<Card className="mb-8">
86+
<div className="p-6 text-center">
87+
<div className="mb-4">
88+
<Upload size={48} weight="thin" className="mx-auto text-muted-foreground" />
89+
</div>
90+
91+
<h2 className="text-xl font-medium mb-2">Upload CSV File</h2>
92+
<p className="text-muted-foreground mb-4 max-w-md mx-auto">
93+
Upload your GitHub Copilot premium requests usage CSV export to visualize the data
94+
</p>
95+
96+
<label htmlFor="csv-upload">
97+
<Button as="div" className="cursor-pointer">
98+
Select CSV File
99+
</Button>
100+
<input
101+
id="csv-upload"
102+
type="file"
103+
accept=".csv"
104+
onChange={handleFileUpload}
105+
className="hidden"
106+
/>
107+
</label>
108+
</div>
109+
</Card>
110+
111+
{data && data.length > 0 && (
112+
<div className="space-y-8">
113+
<div>
114+
<h2 className="text-2xl font-semibold mb-2">Usage Statistics</h2>
115+
<Separator className="mb-4" />
116+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
117+
<Card>
118+
<div className="p-5">
119+
<h3 className="text-sm font-medium text-muted-foreground mb-1">Total Requests</h3>
120+
<p className="text-2xl font-bold">
121+
{data.reduce((sum, item) => sum + item.requestsUsed, 0).toLocaleString()}
122+
</p>
123+
</div>
124+
</Card>
125+
<Card>
126+
<div className="p-5">
127+
<h3 className="text-sm font-medium text-muted-foreground mb-1">Unique Users</h3>
128+
<p className="text-2xl font-bold">
129+
{new Set(data.map(item => item.user)).size.toLocaleString()}
130+
</p>
131+
</div>
132+
</Card>
133+
<Card>
134+
<div className="p-5">
135+
<h3 className="text-sm font-medium text-muted-foreground mb-1">Models Used</h3>
136+
<p className="text-2xl font-bold">
137+
{uniqueModels.length}
138+
</p>
139+
</div>
140+
</Card>
141+
</div>
142+
</div>
143+
144+
<div>
145+
<h2 className="text-2xl font-semibold mb-2">Daily Usage by Model</h2>
146+
<Separator className="mb-6" />
147+
<div className="bg-card p-4 rounded-lg border">
148+
<ChartContainer
149+
config={{
150+
compliant: { color: "#10b981" }, // green
151+
exceeding: { color: "#ef4444" }, // red
152+
}}
153+
className="h-[400px] w-full"
154+
>
155+
<LineChart data={chartData()}>
156+
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
157+
<XAxis
158+
dataKey="date"
159+
tick={{ fill: 'var(--foreground)' }}
160+
tickLine={{ stroke: 'var(--border)' }}
161+
/>
162+
<YAxis
163+
tick={{ fill: 'var(--foreground)' }}
164+
tickLine={{ stroke: 'var(--border)' }}
165+
/>
166+
<ChartTooltip
167+
content={({ active, payload, label }) => {
168+
if (active && payload && payload.length) {
169+
const modelsData: Record<string, { compliant: number, exceeding: number }> = {};
170+
171+
payload.forEach(item => {
172+
const keyParts = String(item.dataKey).split('_');
173+
const model = keyParts[0];
174+
const type = keyParts[1];
175+
176+
if (!modelsData[model]) {
177+
modelsData[model] = { compliant: 0, exceeding: 0 };
178+
}
179+
180+
modelsData[model][type as 'compliant' | 'exceeding'] = Number(item.value || 0);
181+
});
182+
183+
return (
184+
<div className="border rounded-lg bg-background shadow-lg p-3 text-xs">
185+
<div className="font-medium mb-2">{label}</div>
186+
<div className="space-y-2">
187+
{Object.entries(modelsData).map(([model, stats]) => (
188+
<div key={model} className="grid grid-cols-2 gap-2">
189+
<div className="font-medium">{model}</div>
190+
<div />
191+
<div className="flex items-center gap-1.5">
192+
<div className="w-2 h-2 rounded-full bg-[#10b981]" />
193+
<span>Compliant:</span>
194+
</div>
195+
<div className="text-right">{stats.compliant}</div>
196+
<div className="flex items-center gap-1.5">
197+
<div className="w-2 h-2 rounded-full bg-[#ef4444]" />
198+
<span>Exceeding:</span>
199+
</div>
200+
<div className="text-right">{stats.exceeding}</div>
201+
</div>
202+
))}
203+
</div>
204+
</div>
205+
);
206+
}
207+
return null;
208+
}}
209+
/>
210+
<Legend />
211+
212+
{/* Create lines for each model */}
213+
{uniqueModels.flatMap(model => [
214+
<Line
215+
key={`${model}_compliant`}
216+
type="monotone"
217+
dataKey={`${model}_compliant`}
218+
name={`${model} (Compliant)`}
219+
stroke="#10b981"
220+
strokeWidth={2}
221+
activeDot={{ r: 6 }}
222+
stackId="1"
223+
/>,
224+
<Line
225+
key={`${model}_exceeding`}
226+
type="monotone"
227+
dataKey={`${model}_exceeding`}
228+
name={`${model} (Exceeding)`}
229+
stroke="#ef4444"
230+
strokeWidth={2}
231+
activeDot={{ r: 6 }}
232+
stackId="1"
233+
/>
234+
])}
235+
</LineChart>
236+
</ChartContainer>
237+
</div>
238+
</div>
239+
</div>
240+
)}
241+
</div>
242+
);
3243
}
4244

5-
export default App
245+
export default App;

src/index.css

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,75 @@
1-
/* This is where custom CSS goes */
1+
@import 'tailwindcss';
2+
@import "tw-animate-css";
3+
4+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
5+
6+
@layer base {
7+
* {
8+
@apply border-border
9+
}
10+
}
11+
12+
:root {
13+
/* Base colors */
14+
--background: oklch(0.98 0.01 240);
15+
--foreground: oklch(0.25 0.01 240);
16+
17+
--card: oklch(1 0 0);
18+
--card-foreground: oklch(0.25 0.01 240);
19+
--popover: oklch(1 0 0);
20+
--popover-foreground: oklch(0.25 0.01 240);
21+
22+
/* Action colors */
23+
--primary: oklch(0.55 0.18 250);
24+
--primary-foreground: oklch(1 0 0);
25+
--secondary: oklch(0.9 0.03 240);
26+
--secondary-foreground: oklch(0.3 0.01 240);
27+
--accent: oklch(0.65 0.15 145);
28+
--accent-foreground: oklch(0.2 0.01 240);
29+
--destructive: oklch(0.65 0.15 30);
30+
--destructive-foreground: oklch(1 0 0);
31+
32+
/* Supporting UI colors */
33+
--muted: oklch(0.95 0.02 240);
34+
--muted-foreground: oklch(0.55 0.01 240);
35+
--border: oklch(0.85 0.01 240);
36+
--input: oklch(0.85 0.01 240);
37+
--ring: oklch(0.55 0.18 250);
38+
39+
/* Border radius */
40+
--radius: 0.5rem;
41+
}
42+
43+
@theme {
44+
--color-background: var(--background);
45+
--color-foreground: var(--foreground);
46+
--color-card: var(--card);
47+
--color-card-foreground: var(--card-foreground);
48+
--color-popover: var(--popover);
49+
--color-popover-foreground: var(--popover-foreground);
50+
--color-primary: var(--primary);
51+
--color-primary-foreground: var(--primary-foreground);
52+
--color-secondary: var(--secondary);
53+
--color-secondary-foreground: var(--secondary-foreground);
54+
--color-muted: var(--muted);
55+
--color-muted-foreground: var(--muted-foreground);
56+
--color-accent: var(--accent);
57+
--color-accent-foreground: var(--accent-foreground);
58+
--color-destructive: var(--destructive);
59+
--color-destructive-foreground: var(--destructive-foreground);
60+
--color-border: var(--border);
61+
--color-input: var(--input);
62+
--color-ring: var(--ring);
63+
64+
/* Map radius variables */
65+
--radius-sm: calc(var(--radius) * 0.5);
66+
--radius-md: var(--radius);
67+
--radius-lg: calc(var(--radius) * 1.5);
68+
--radius-xl: calc(var(--radius) * 2);
69+
--radius-2xl: calc(var(--radius) * 3);
70+
--radius-full: 9999px;
71+
}
72+
73+
body {
74+
font-family: 'Inter', sans-serif;
75+
}

0 commit comments

Comments
 (0)