Skip to content

Commit eb307e8

Browse files
fix leaderboard sorting and profile timelines
1 parent abcefab commit eb307e8

12 files changed

Lines changed: 589 additions & 76 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"build": "next build",
99
"start": "next start",
1010
"lint": "next lint",
11-
"test": "node test/ccusage.test.mts"
11+
"test": "node test/ccusage.test.mts && for f in test/*.test.mts; do [ \"$f\" = \"test/ccusage.test.mts\" ] || node \"$f\" || exit 1; done"
1212
},
1313
"dependencies": {
1414
"@auth/core": "^0.40.0",

src/app/@modal/(.)profile/[username]/ProfileSheet.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { X, Github, ArrowUpRight } from "lucide-react";
77
import TierBadge from "@/components/TierBadge";
88
import { getTierProgress } from "@/lib/tiers";
99
import { formatNumber, toolLabel, sizedAvatarUrl } from "@/lib/utils";
10+
import { formatCompactDate, type DailyFreshness } from "@/lib/profile-timeline";
1011

1112
interface ProfileSheetProps {
1213
username: string;
@@ -21,6 +22,9 @@ interface ProfileSheetProps {
2122
tools: string[];
2223
/** Daily spend, oldest → newest, for the mini bar chart. */
2324
spark: number[];
25+
sparkStartDate: string | null;
26+
sparkEndDate: string | null;
27+
dailyFreshness: DailyFreshness;
2428
}
2529

2630
export default function ProfileSheet(props: ProfileSheetProps) {
@@ -126,7 +130,12 @@ export default function ProfileSheet(props: ProfileSheetProps) {
126130
{/* Daily spend sparkline */}
127131
{props.spark.length > 1 && (
128132
<div className="rounded-lg bg-background border border-border-subtle p-3 mb-3">
129-
<p className="micro-label mb-2">Daily spend · last {props.spark.length} days</p>
133+
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
134+
<p className="micro-label">Daily spend · last {props.spark.length} calendar days</p>
135+
<span className="text-[10px] font-mono text-muted">
136+
{formatCompactDate(props.sparkStartDate)} - {formatCompactDate(props.sparkEndDate)}
137+
</span>
138+
</div>
130139
<div className="flex items-end gap-px h-12">
131140
{props.spark.map((v, i) => {
132141
const max = Math.max(...props.spark) || 1;
@@ -139,6 +148,11 @@ export default function ProfileSheet(props: ProfileSheetProps) {
139148
);
140149
})}
141150
</div>
151+
{props.dailyFreshness.isStale && (
152+
<p className="mt-2 text-[11px] text-muted">
153+
Data ends {formatCompactDate(props.dailyFreshness.lastRecordedDate)}. Run <code className="font-mono text-accent">npx viberank-cli</code> to update recent days.
154+
</p>
155+
)}
142156
</div>
143157
)}
144158

src/app/@modal/(.)profile/[username]/page.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getServerDataLayer } from "@/lib/data";
22
import { getProfileCached } from "@/app/profile/[username]/getProfile";
3+
import { buildRecentSpendSpark, getDailyFreshness, todayIso } from "@/lib/profile-timeline";
34
import ProfileSheet from "./ProfileSheet";
45

56
interface Params {
@@ -32,11 +33,14 @@ export default async function InterceptedProfile({ params }: Params) {
3233
const tools = Array.from(new Set(submissions.flatMap((s) => s.tools ?? []))).sort();
3334
const bestCost = submissions.length > 0 ? Math.max(...submissions.map((s) => s.totalCost)) : 0;
3435

35-
// Last 30 calendar days of spend for the sheet's sparkline.
36-
const byDate = new Map<string, number>();
37-
for (const d of allDaily) byDate.set(d.date, (byDate.get(d.date) ?? 0) + d.totalCost);
38-
const days = [...byDate.keys()].sort();
39-
const spark = days.slice(-30).map((d) => byDate.get(d) ?? 0);
36+
const today = todayIso();
37+
const dailyPoints = allDaily.map((d) => ({
38+
date: d.date,
39+
cost: d.totalCost,
40+
tokens: d.totalTokens,
41+
}));
42+
const spark = buildRecentSpendSpark(dailyPoints, { days: 30, today });
43+
const dailyFreshness = getDailyFreshness(dailyPoints, today);
4044

4145
let globalRank: number | null = null;
4246
try {
@@ -60,7 +64,10 @@ export default async function InterceptedProfile({ params }: Params) {
6064
bestCost={bestCost}
6165
globalRank={globalRank}
6266
tools={tools}
63-
spark={spark}
67+
spark={spark.values}
68+
sparkStartDate={spark.startDate}
69+
sparkEndDate={spark.endDate}
70+
dailyFreshness={dailyFreshness}
6471
/>
6572
);
6673
}

src/app/profile/[username]/UsageChart.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import {
1111
Tooltip,
1212
ResponsiveContainer,
1313
} from "recharts";
14+
import { formatCompactDate, type DailyFreshness } from "@/lib/profile-timeline";
1415

1516
type DailyPoint = { date: string; cost: number; tokens: number };
1617

17-
export default function UsageChart({ daily }: { daily: DailyPoint[] }) {
18+
export default function UsageChart({ daily, freshness }: { daily: DailyPoint[]; freshness?: DailyFreshness }) {
1819
const [range, setRange] = useState<"7d" | "30d" | "all">("30d");
1920

2021
const data = [...daily]
@@ -24,10 +25,17 @@ export default function UsageChart({ daily }: { daily: DailyPoint[] }) {
2425
return (
2526
<div className="bg-surface-1 border border-border rounded-lg p-5 mb-6">
2627
<div className="flex items-center justify-between mb-4">
27-
<h2 className="text-base font-medium flex items-center gap-2">
28-
<TrendingUp className="w-4 h-4 text-accent" />
29-
Usage over time
30-
</h2>
28+
<div>
29+
<h2 className="text-base font-medium flex items-center gap-2">
30+
<TrendingUp className="w-4 h-4 text-accent" />
31+
Usage over time
32+
</h2>
33+
{freshness?.isStale && (
34+
<p className="text-xs text-muted mt-1">
35+
Data ends {formatCompactDate(freshness.lastRecordedDate)}. Run <code className="font-mono text-accent">npx viberank-cli</code> to update recent days.
36+
</p>
37+
)}
38+
</div>
3139
<div className="flex gap-1">
3240
{(["7d", "30d", "all"] as const).map((r) => (
3341
<button

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { formatNumber, formatCurrency, toolLabel, sizedAvatarUrl } from "@/lib/utils";
1616
import { getTierProgress } from "@/lib/tiers";
1717
import { getServerDataLayer } from "@/lib/data";
18+
import { buildCalendarDailySeries, getDailyFreshness, todayIso } from "@/lib/profile-timeline";
1819
import { getProfileCached } from "./getProfile";
1920
import UsageChart from "./UsageChartLazy";
2021
import Footer from "@/components/Footer";
@@ -66,14 +67,22 @@ export default async function ProfilePage({ params }: ProfileParams) {
6667
const daysActive = new Set(allDaily.map((d) => d.date)).size || 1;
6768
const avgDailyCost = totalCost / daysActive;
6869

69-
// Per-day chart series (summed across submissions).
70-
const dailyMap = allDaily.reduce((acc, d) => {
71-
if (!acc[d.date]) acc[d.date] = { date: d.date, cost: 0, tokens: 0 };
72-
acc[d.date].cost += d.totalCost;
73-
acc[d.date].tokens += d.totalTokens;
74-
return acc;
75-
}, {} as Record<string, { date: string; cost: number; tokens: number }>);
76-
const dailySeries = Object.values(dailyMap);
70+
const today = todayIso();
71+
const dailyPoints = allDaily.map((d) => ({
72+
date: d.date,
73+
cost: d.totalCost,
74+
tokens: d.totalTokens,
75+
}));
76+
const firstTrackedDate = submissions
77+
.map((s) => s.dateRange.start)
78+
.filter(Boolean)
79+
.sort()[0];
80+
const dailySeries = buildCalendarDailySeries(dailyPoints, {
81+
range: "all",
82+
today,
83+
startDate: firstTrackedDate,
84+
});
85+
const dailyFreshness = getDailyFreshness(dailyPoints, today);
7786

7887
// Tools used across the profile (Claude sorts first).
7988
const tools = Array.from(new Set(submissions.flatMap((s) => s.tools ?? []))).sort();
@@ -296,7 +305,7 @@ export default async function ProfilePage({ params }: ProfileParams) {
296305
</div>
297306

298307
{/* Usage chart (client island) */}
299-
<UsageChart daily={dailySeries} />
308+
<UsageChart daily={dailySeries} freshness={dailyFreshness} />
300309

301310
{/* Token breakdown + insights */}
302311
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">

src/components/Leaderboard.tsx

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

3-
import { useState, useEffect, useRef } from "react";
3+
import { useState, useEffect, useMemo, useRef } from "react";
44
import { motion, AnimatePresence } from "framer-motion";
55
import { Trophy, DollarSign, Zap, Calendar, Share2, X, BadgeCheck, Loader2 } from "lucide-react";
66
import { useSession } from "next-auth/react";
@@ -13,6 +13,7 @@ import SponsorSlot from "./SponsorSlot";
1313
import TierBadge from "./TierBadge";
1414
import { TIERS } from "@/lib/tiers";
1515
import { formatNumber, formatCurrency, toolLabel } from "@/lib/utils";
16+
import { isLeaderboardResultForRequest, shouldUseServerSeededLeaderboard } from "@/lib/leaderboard-view";
1617
import { useLeaderboard, useLeaderboardByDateRange } from "@/lib/data/hooks/useSubmissions";
1718
import { useGlobalStats } from "@/lib/data/hooks/useStats";
1819
import type { Submission, GlobalStats } from "@/lib/data/types";
@@ -60,59 +61,83 @@ export default function Leaderboard({ initialItems, initialStats, initialHasMore
6061
const [verifiedOnly, setVerifiedOnly] = useState(false);
6162
const [page, setPage] = useState(0);
6263
const [allItems, setAllItems] = useState<Submission[]>(initialItems ?? []);
64+
const [hasUserInteracted, setHasUserInteracted] = useState(false);
6365
const { data: session } = useSession();
6466
const loadMoreRef = useRef<HTMLDivElement>(null);
65-
const firstRender = useRef(true);
6667
const { data: liveStats } = useGlobalStats();
6768
const globalStats = liveStats ?? initialStats;
6869

6970
const ITEMS_PER_PAGE = 25;
7071
const isDateFiltered = dateFrom && dateTo;
7172

7273
// The server already rendered page 0 of the default view — don't re-fetch
73-
// it on mount. The hook only runs for non-default filters or later pages.
74-
const isSeededDefaultView =
75-
(initialItems?.length ?? 0) > 0 &&
76-
page === 0 &&
77-
sortBy === "cost" &&
78-
!tool &&
79-
!verifiedOnly &&
80-
!isDateFiltered;
74+
// it on mount. After any user sort/filter click, page 0 must be fetched
75+
// fresh; otherwise stale seeded or later-page results can masquerade as top.
76+
const isSeededDefaultView = shouldUseServerSeededLeaderboard({
77+
hasInitialItems: (initialItems?.length ?? 0) > 0,
78+
hasUserInteracted,
79+
page,
80+
sortBy,
81+
hasToolFilter: !!tool,
82+
verifiedOnly,
83+
isDateFiltered: !!isDateFiltered,
84+
});
85+
86+
const regularParams = useMemo(() => ({
87+
sortBy,
88+
page,
89+
pageSize: ITEMS_PER_PAGE,
90+
tool: tool ?? undefined,
91+
verifiedOnly: verifiedOnly || undefined,
92+
}), [sortBy, page, tool, verifiedOnly]);
93+
const dateRangeParams = useMemo(() => ({
94+
dateFrom,
95+
dateTo,
96+
sortBy,
97+
limit: 100,
98+
tool: tool ?? undefined,
99+
verifiedOnly: verifiedOnly || undefined,
100+
}), [dateFrom, dateTo, sortBy, tool, verifiedOnly]);
101+
const dateRangeRequestKey = useMemo(() => JSON.stringify({
102+
dateFrom,
103+
dateTo,
104+
sortBy,
105+
limit: 100,
106+
cursor: null,
107+
tool: tool ?? null,
108+
verifiedOnly: Boolean(verifiedOnly),
109+
}), [dateFrom, dateTo, sortBy, tool, verifiedOnly]);
81110

82111
const { data: regularResult, isLoading } = useLeaderboard(
83-
!isDateFiltered && !isSeededDefaultView
84-
? { sortBy, page, pageSize: ITEMS_PER_PAGE, tool: tool ?? undefined, verifiedOnly: verifiedOnly || undefined }
85-
: "skip"
112+
!isDateFiltered && !isSeededDefaultView ? regularParams : "skip"
86113
);
87114

88115
const hasMore = regularResult?.hasMore ?? (isSeededDefaultView ? initialHasMore ?? false : false);
89116

90117
const { data: dateFilteredResult } = useLeaderboardByDateRange(
91-
isDateFiltered
92-
? { dateFrom, dateTo, sortBy, limit: 100, tool: tool ?? undefined, verifiedOnly: verifiedOnly || undefined }
93-
: "skip"
118+
isDateFiltered ? dateRangeParams : "skip"
94119
);
95120

96121
// Tools available to filter by, sourced from the global per-tool stats.
97122
const availableTools = globalStats?.modelUsage
98123
? Object.keys(globalStats.modelUsage).sort()
99124
: [];
100125

101-
useEffect(() => {
102-
// Keep the server-seeded items on first render; only reset when a filter
103-
// actually changes (avoids clearing the SSR'd rows during hydration).
104-
if (firstRender.current) {
105-
firstRender.current = false;
106-
return;
107-
}
126+
const resetBoardForUserChange = () => {
127+
setHasUserInteracted(true);
108128
setAllItems([]);
109129
setPage(0);
110-
}, [sortBy, dateFrom, dateTo, tool, verifiedOnly]);
130+
};
131+
132+
const updateSort = (nextSort: SortBy) => {
133+
if (nextSort !== sortBy) resetBoardForUserChange();
134+
setSortBy(nextSort);
135+
};
111136

112137
useEffect(() => {
113-
if (isDateFiltered && dateFilteredResult?.items) {
138+
if (isDateFiltered && dateFilteredResult?.items && dateFilteredResult.requestKey === dateRangeRequestKey) {
114139
setAllItems(dateFilteredResult.items);
115-
} else if (!isDateFiltered && regularResult?.items) {
140+
} else if (!isDateFiltered && regularResult?.items && isLeaderboardResultForRequest(regularResult, regularParams)) {
116141
if (page === 0) {
117142
setAllItems(regularResult.items);
118143
} else {
@@ -123,7 +148,7 @@ export default function Leaderboard({ initialItems, initialStats, initialHasMore
123148
});
124149
}
125150
}
126-
}, [regularResult, dateFilteredResult, page, isDateFiltered]);
151+
}, [regularResult, regularParams, dateFilteredResult, dateRangeRequestKey, page, isDateFiltered]);
127152

128153
useEffect(() => {
129154
if (isDateFiltered || !hasMore) return;
@@ -145,6 +170,7 @@ export default function Leaderboard({ initialItems, initialStats, initialHasMore
145170
}, [hasMore, isLoading, isDateFiltered, allItems.length]);
146171

147172
const setQuickFilter = (days: number | null) => {
173+
resetBoardForUserChange();
148174
if (days === null) {
149175
setDateFrom("");
150176
setDateTo("");
@@ -196,7 +222,10 @@ export default function Leaderboard({ initialItems, initialStats, initialHasMore
196222
</button>
197223

198224
<button
199-
onClick={() => setVerifiedOnly(v => !v)}
225+
onClick={() => {
226+
resetBoardForUserChange();
227+
setVerifiedOnly(v => !v);
228+
}}
200229
aria-pressed={verifiedOnly}
201230
title="Only show GitHub-verified submissions"
202231
className={`px-2 py-1 text-xs font-mono font-medium rounded flex items-center gap-1 transition-colors ${
@@ -210,7 +239,10 @@ export default function Leaderboard({ initialItems, initialStats, initialHasMore
210239
{availableTools.length > 1 && (
211240
<select
212241
value={tool ?? ""}
213-
onChange={(e) => setTool(e.target.value || null)}
242+
onChange={(e) => {
243+
resetBoardForUserChange();
244+
setTool(e.target.value || null);
245+
}}
214246
aria-label="Filter by tool"
215247
className={`px-2 py-1 text-xs font-mono font-medium rounded bg-surface-2 border border-border transition-colors focus:outline-none focus:ring-1 focus:ring-accent ${
216248
tool ? "text-accent" : "text-muted hover:text-foreground"
@@ -228,7 +260,7 @@ export default function Leaderboard({ initialItems, initialStats, initialHasMore
228260

229261
<div className="flex items-center gap-1">
230262
<button
231-
onClick={() => setSortBy("cost")}
263+
onClick={() => updateSort("cost")}
232264
className={`px-2.5 py-1 text-xs font-mono font-medium rounded flex items-center gap-1 transition-colors ${
233265
sortBy === "cost" ? "bg-accent text-white" : "text-muted hover:text-foreground hover:bg-surface-2"
234266
}`}
@@ -237,7 +269,7 @@ export default function Leaderboard({ initialItems, initialStats, initialHasMore
237269
<span className="hidden sm:inline">Cost</span>
238270
</button>
239271
<button
240-
onClick={() => setSortBy("tokens")}
272+
onClick={() => updateSort("tokens")}
241273
className={`px-2.5 py-1 text-xs font-mono font-medium rounded flex items-center gap-1 transition-colors ${
242274
sortBy === "tokens" ? "bg-accent text-white" : "text-muted hover:text-foreground hover:bg-surface-2"
243275
}`}
@@ -260,18 +292,31 @@ export default function Leaderboard({ initialItems, initialStats, initialHasMore
260292
<input
261293
type="date"
262294
value={dateFrom}
263-
onChange={(e) => setDateFrom(e.target.value)}
295+
onChange={(e) => {
296+
resetBoardForUserChange();
297+
setDateFrom(e.target.value);
298+
}}
264299
className="px-3 py-1.5 bg-background border border-border rounded-md"
265300
/>
266301
<span className="text-muted"></span>
267302
<input
268303
type="date"
269304
value={dateTo}
270-
onChange={(e) => setDateTo(e.target.value)}
305+
onChange={(e) => {
306+
resetBoardForUserChange();
307+
setDateTo(e.target.value);
308+
}}
271309
className="px-3 py-1.5 bg-background border border-border rounded-md"
272310
/>
273311
{(dateFrom || dateTo) && (
274-
<button onClick={() => { setDateFrom(""); setDateTo(""); }} className="text-muted hover:text-foreground">
312+
<button
313+
onClick={() => {
314+
resetBoardForUserChange();
315+
setDateFrom("");
316+
setDateTo("");
317+
}}
318+
className="text-muted hover:text-foreground"
319+
>
275320
<X className="w-4 h-4" />
276321
</button>
277322
)}

0 commit comments

Comments
 (0)