Skip to content

Commit 1fdc6f6

Browse files
fricobenclaude
andauthored
fix: dashboard charts cache tokens, timeframe selectors, and percentile calculation (#11)
- Fix Usage by Model chart to include cache tokens (cacheReadTokens + cacheCreationTokens) in Tokens mode, which was causing GPT models to appear above Claude models incorrectly - Add shared TimeframeSelector component (7D/30D/1Y/All) used across all charts - Add timeframe selector to Model Usage Over Time chart - Fix user percentile calculation: with 1 user shows "Top 100%" (correct) instead of "Top 1%" (misleading) - Improve Insights card: when no reasoning tokens, show just "Top X%" without progress bar - Add EXCLUDED_MODELS filter to hide synthetic models (auto, cursor-small, etc.) from charts - Use pre-calculated cost from database instead of estimation for Fun Facts - Change token_usage insert to upsert for re-import support - Add migration to cleanup synthetic model entries - Update model name mappings for newer Claude/GPT/Gemini models 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c95f35b commit 1fdc6f6

14 files changed

Lines changed: 330 additions & 117 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ SUPABASE_SERVICE_ROLE_KEY=xxx # For server-side operations
244244
### Development
245245

246246
```bash
247+
# Ensure Node.js 22+ is active (if using nvm)
248+
nvm use 22
249+
247250
# Install dependencies
248251
pnpm install
249252

conductor.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
{ "scripts": { "setup": "bun install", "run": "bun run dev", "archive": "" }}
1+
{
2+
"scripts": {
3+
"setup": "export NVM_DIR=\"$HOME/.nvm\" && [ -s \"$NVM_DIR/nvm.sh\" ] && . \"$NVM_DIR/nvm.sh\" && nvm use 22 && pnpm install && cd packages/core && pnpm build && pnpm test",
4+
"run": "export NVM_DIR=\"$HOME/.nvm\" && [ -s \"$NVM_DIR/nvm.sh\" ] && . \"$NVM_DIR/nvm.sh\" && nvm use 22 && bun run dev",
5+
"archive": ""
6+
}
7+
}

src/app/api/import/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,12 @@ export async function POST(request: Request) {
280280
if (error) console.error("daily_activity error:", error);
281281
}
282282
})(),
283-
// Token usage chunks (insert, not upsert)
283+
// Token usage chunks (upsert to handle re-imports)
284284
(async () => {
285285
for (const chunk of tokenChunks) {
286286
const { error } = await serviceSupabase
287287
.from("token_usage")
288-
.insert(chunk);
288+
.upsert(chunk, { onConflict: "user_id,date,tool,model" });
289289
if (error) console.error("token_usage error:", error);
290290
}
291291
})(),

src/app/api/submit/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ async function handleTokenContributionData(
229229
for (const chunk of tokenChunks) {
230230
const { error } = await supabase
231231
.from("token_usage")
232-
.insert(chunk);
232+
.upsert(chunk, { onConflict: "user_id,date,tool,model" });
233233
if (error) console.error("token_usage error:", error);
234234
}
235235
})(),

src/app/user/[username]/page.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export default async function UserProfilePage({ params }: PageParams) {
9797
? {
9898
totalTokens: mockStats.total_tokens,
9999
totalSessions: mockStats.total_sessions,
100+
totalCost: mockStats.total_cost || 0,
100101
favoriteModel: mockStats.favorite_model,
101102
favoriteTool: mockStats.favorite_tool,
102103
longestSessionMs: mockStats.longest_session_ms,
@@ -159,14 +160,15 @@ export default async function UserProfilePage({ params }: PageParams) {
159160

160161
// Calculate user percentile based on estimated spend
161162
// "Top X%" means you're in the top X% of users by API spend
162-
// E.g., "Top 5%" means you're better than 95% of users
163+
// E.g., "Top 5%" means you're rank 5 out of 100 (better than 95%)
164+
// Formula: percentile = (rank / totalUsers) * 100
163165
let userPercentile = 50; // Default
164166
if (stats) {
165167
const { data: allUserStats } = await supabase
166168
.from("user_stats")
167169
.select("user_id, total_tokens, favorite_model");
168170

169-
if (allUserStats && allUserStats.length > 1) {
171+
if (allUserStats && allUserStats.length > 0) {
170172
const userSpend = estimateApiSpendUsd({
171173
model: stats.favorite_model,
172174
totalTokens: stats.total_tokens,
@@ -177,15 +179,14 @@ export default async function UserProfilePage({ params }: PageParams) {
177179
totalTokens: u.total_tokens,
178180
})
179181
);
180-
// Count users with higher spend (higher rank)
182+
// Count users with higher spend (better rank)
181183
const usersAbove = allSpends.filter((spend) => spend > userSpend).length;
182184
// Your rank = usersAbove + 1 (1-indexed)
183185
// Percentile = (rank / total) * 100
184-
// If 0 users above, you're rank 1 = top (1/N * 100)%
186+
// With 1 user: rank 1 / 1 = 100% (you ARE the top 100%)
187+
// With 2 users: if #1, rank 1 / 2 = 50% (top half)
188+
// With 100 users: if #1, rank 1 / 100 = 1% (top 1%)
185189
userPercentile = Math.max(1, Math.ceil(((usersAbove + 1) / allUserStats.length) * 100));
186-
} else {
187-
// Only 1 user in the system = you're #1
188-
userPercentile = 1;
189190
}
190191
}
191192

@@ -205,6 +206,7 @@ export default async function UserProfilePage({ params }: PageParams) {
205206
? {
206207
totalTokens: stats.total_tokens,
207208
totalSessions: stats.total_sessions,
209+
totalCost: parseFloat(stats.total_cost) || 0,
208210
favoriteModel: stats.favorite_model,
209211
favoriteTool: stats.favorite_tool,
210212
longestSessionMs: stats.longest_session_ms,

src/components/dashboard/ModelMigrationTimeline.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useMemo } from "react";
3+
import { useMemo, useState } from "react";
44
import {
55
AreaChart,
66
Area,
@@ -12,12 +12,15 @@ import {
1212
Legend,
1313
} from "recharts";
1414
import { formatModelName } from "@/lib/formatModelName";
15+
import { TimeframeSelector, filterByTimeframe, type Timeframe } from "./TimeframeSelector";
1516

1617
interface TokenUsage {
1718
date: string;
1819
model: string;
1920
inputTokens: number;
2021
outputTokens: number;
22+
cacheReadTokens?: number;
23+
cacheCreationTokens?: number;
2124
}
2225

2326
interface ModelMigrationTimelineProps {
@@ -49,13 +52,26 @@ function getModelColor(model: string): string {
4952
return colors[Math.abs(hash) % colors.length];
5053
}
5154

55+
// Models to exclude from the timeline chart (synthetic/placeholder entries)
56+
const EXCLUDED_MODELS = new Set([
57+
"<synthetic>",
58+
"auto",
59+
"unknown",
60+
"cursor-small",
61+
"agent_review",
62+
"composer-1",
63+
]);
64+
5265
// Aggregate data by week and model
5366
function aggregateByWeekAndModel(
5467
data: TokenUsage[]
5568
): { week: string; [model: string]: number | string }[] {
5669
const weekMap = new Map<string, Map<string, number>>();
5770

5871
data.forEach((item) => {
72+
// Skip excluded models
73+
if (EXCLUDED_MODELS.has(item.model)) return;
74+
5975
const date = new Date(item.date);
6076
// Get Monday of the week
6177
const dayOfWeek = date.getDay();
@@ -68,7 +84,9 @@ function aggregateByWeekAndModel(
6884
weekMap.set(weekKey, new Map());
6985
}
7086
const modelMap = weekMap.get(weekKey)!;
71-
const tokens = item.inputTokens + item.outputTokens;
87+
// Include all token types for accurate total
88+
const tokens = item.inputTokens + item.outputTokens +
89+
(item.cacheReadTokens || 0) + (item.cacheCreationTokens || 0);
7290
modelMap.set(item.model, (modelMap.get(item.model) || 0) + tokens);
7391
});
7492

@@ -86,25 +104,32 @@ function aggregateByWeekAndModel(
86104
}
87105

88106
export function ModelMigrationTimeline({ tokenUsage }: ModelMigrationTimelineProps) {
107+
const [timeframe, setTimeframe] = useState<Timeframe>("all");
108+
89109
const { chartData, models } = useMemo(() => {
90110
if (!tokenUsage || tokenUsage.length === 0) {
91111
return { chartData: [], models: [] };
92112
}
93113

94-
const aggregated = aggregateByWeekAndModel(tokenUsage);
114+
// Filter by timeframe
115+
const filteredUsage = filterByTimeframe(tokenUsage, timeframe);
95116

96-
// Get all unique models
117+
const aggregated = aggregateByWeekAndModel(filteredUsage);
118+
119+
// Get all unique models (excluding synthetic ones)
97120
const modelSet = new Set<string>();
98121
aggregated.forEach((week) => {
99122
Object.keys(week).forEach((key) => {
100-
if (key !== "week") modelSet.add(key);
123+
if (key !== "week" && !EXCLUDED_MODELS.has(key)) modelSet.add(key);
101124
});
102125
});
103126

104-
// Sort models by total usage
127+
// Sort models by total usage (including cache tokens)
105128
const modelTotals = new Map<string, number>();
106-
tokenUsage.forEach((item) => {
107-
const tokens = item.inputTokens + item.outputTokens;
129+
filteredUsage.forEach((item) => {
130+
if (EXCLUDED_MODELS.has(item.model)) return;
131+
const tokens = item.inputTokens + item.outputTokens +
132+
(item.cacheReadTokens || 0) + (item.cacheCreationTokens || 0);
108133
modelTotals.set(item.model, (modelTotals.get(item.model) || 0) + tokens);
109134
});
110135

@@ -116,7 +141,7 @@ export function ModelMigrationTimeline({ tokenUsage }: ModelMigrationTimelinePro
116141
const topModels = sortedModels.slice(0, 6);
117142

118143
return { chartData: aggregated, models: topModels };
119-
}, [tokenUsage]);
144+
}, [tokenUsage, timeframe]);
120145

121146
if (!tokenUsage || tokenUsage.length === 0 || chartData.length < 2) {
122147
return null;
@@ -171,7 +196,10 @@ export function ModelMigrationTimeline({ tokenUsage }: ModelMigrationTimelinePro
171196

172197
return (
173198
<div className="card">
174-
<h3 className="font-bold mb-4">Model Usage Over Time</h3>
199+
<div className="flex items-center justify-between mb-4">
200+
<h3 className="font-bold">Model Usage Over Time</h3>
201+
<TimeframeSelector value={timeframe} onChange={setTimeframe} />
202+
</div>
175203
<div className="h-[280px]">
176204
<ResponsiveContainer width="100%" height="100%">
177205
<AreaChart
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"use client";
2+
3+
export type Timeframe = "7d" | "30d" | "1y" | "all";
4+
5+
interface TimeframeSelectorProps {
6+
value: Timeframe;
7+
onChange: (timeframe: Timeframe) => void;
8+
compact?: boolean;
9+
}
10+
11+
const TIMEFRAME_LABELS: Record<Timeframe, string> = {
12+
"7d": "7D",
13+
"30d": "30D",
14+
"1y": "1Y",
15+
all: "All",
16+
};
17+
18+
export function TimeframeSelector({ value, onChange, compact = false }: TimeframeSelectorProps) {
19+
const timeframes: Timeframe[] = ["7d", "30d", "1y", "all"];
20+
21+
return (
22+
<div className="flex items-center gap-0.5 bg-[#EEF0F2] rounded-full p-0.5 border border-[#232323]/10">
23+
{timeframes.map((tf) => (
24+
<button
25+
key={tf}
26+
onClick={() => onChange(tf)}
27+
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-all ${
28+
value === tf
29+
? "bg-white text-[#232323] shadow-sm"
30+
: "text-[#232323]/60 hover:text-[#232323]"
31+
}`}
32+
>
33+
{TIMEFRAME_LABELS[tf]}
34+
</button>
35+
))}
36+
</div>
37+
);
38+
}
39+
40+
// Helper to filter data by timeframe
41+
export function filterByTimeframe<T extends { date: string }>(
42+
data: T[],
43+
timeframe: Timeframe
44+
): T[] {
45+
if (timeframe === "all") return data;
46+
47+
const now = new Date();
48+
const cutoff = new Date();
49+
50+
switch (timeframe) {
51+
case "7d":
52+
cutoff.setDate(now.getDate() - 7);
53+
break;
54+
case "30d":
55+
cutoff.setDate(now.getDate() - 30);
56+
break;
57+
case "1y":
58+
cutoff.setFullYear(now.getFullYear() - 1);
59+
break;
60+
}
61+
62+
const cutoffStr = cutoff.toISOString().split("T")[0];
63+
return data.filter((item) => item.date >= cutoffStr);
64+
}

0 commit comments

Comments
 (0)