Skip to content

Commit 983fd9c

Browse files
author
Mark Saroufim
committed
Add /live page for side-by-side hackathon leaderboards
Hidden page (no nav link) at /live that lets you pick N active problems and displays their rankings in compact columns showing rank, name, and time. Auto-refreshes every 30s. Selected problem IDs stored in ?ids= query param for shareable URLs.
1 parent 0c80ccf commit 983fd9c

2 files changed

Lines changed: 365 additions & 0 deletions

File tree

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import WorkingGroups from "./pages/working-groups/WorkingGroups";
1313
import Lectures from "./pages/lectures/Lectures";
1414
import ErrorPage from "./pages/Error";
1515
import Login from "./pages/login/login";
16+
import Live from "./pages/live/Live";
1617
import { useAuthStore } from "./lib/store/authStore";
1718
import { useThemeStore } from "./lib/store/themeStore";
1819
import { useEffect, useMemo } from "react";
@@ -75,6 +76,7 @@ function App() {
7576
<Route path="/working-groups" element={<WorkingGroups />} />
7677
<Route path="/events" element={<Lectures />} />
7778
<Route path="/lectures" element={<Lectures />} />
79+
<Route path="/live" element={<Live />} />
7880
<Route path="/login" element={<Login />} />
7981
{/* error handling page */}
8082
{errorRoutes.map(({ path, code, title, description }) => (

frontend/src/pages/live/Live.tsx

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import { useEffect, useState, useRef, useCallback } from "react";
2+
import { useSearchParams } from "react-router-dom";
3+
import {
4+
Box,
5+
Typography,
6+
Chip,
7+
CircularProgress,
8+
Autocomplete,
9+
TextField,
10+
IconButton,
11+
Tooltip,
12+
} from "@mui/material";
13+
import RefreshIcon from "@mui/icons-material/Refresh";
14+
import {
15+
fetchLeaderBoard,
16+
fetchLeaderboardSummaries,
17+
type LeaderboardDetail,
18+
type LeaderboardSummary,
19+
} from "../../api/api";
20+
import { formatMicroseconds } from "../../lib/utils/ranking";
21+
import { getMedalIcon } from "../../components/common/medal";
22+
import { isExpired } from "../../lib/date/utils";
23+
24+
const REFRESH_INTERVAL_MS = 30_000;
25+
26+
interface LeaderboardData {
27+
id: number;
28+
detail: LeaderboardDetail;
29+
}
30+
31+
export default function Live() {
32+
const [searchParams, setSearchParams] = useSearchParams();
33+
const [summaries, setSummaries] = useState<LeaderboardSummary[]>([]);
34+
const [loadingSummaries, setLoadingSummaries] = useState(true);
35+
const [boards, setBoards] = useState<LeaderboardData[]>([]);
36+
const [loading, setLoading] = useState(false);
37+
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
38+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
39+
40+
// Parse selected IDs from URL
41+
const idsParam = searchParams.get("ids") || "";
42+
const selectedIds = idsParam
43+
.split(",")
44+
.filter(Boolean)
45+
.map(Number)
46+
.filter((n) => !isNaN(n));
47+
48+
// Load summaries on mount
49+
useEffect(() => {
50+
fetchLeaderboardSummaries()
51+
.then((resp) => {
52+
// Only show active (non-expired) leaderboards in picker
53+
const active = resp.leaderboards.filter(
54+
(lb) => !isExpired(lb.deadline),
55+
);
56+
setSummaries(active);
57+
})
58+
.catch(console.error)
59+
.finally(() => setLoadingSummaries(false));
60+
}, []);
61+
62+
// Fetch details for all selected leaderboards
63+
const fetchAll = useCallback(async () => {
64+
const ids = idsParam
65+
.split(",")
66+
.filter(Boolean)
67+
.map(Number)
68+
.filter((n) => !isNaN(n));
69+
if (ids.length === 0) {
70+
setBoards([]);
71+
return;
72+
}
73+
setLoading(true);
74+
try {
75+
const results = await Promise.all(
76+
ids.map(async (id) => {
77+
const detail = await fetchLeaderBoard(String(id));
78+
return { id, detail };
79+
}),
80+
);
81+
setBoards(results);
82+
setLastRefresh(new Date());
83+
} catch (err) {
84+
console.error("Failed to fetch leaderboards:", err);
85+
} finally {
86+
setLoading(false);
87+
}
88+
}, [idsParam]);
89+
90+
// Fetch on selection change
91+
useEffect(() => {
92+
fetchAll();
93+
}, [fetchAll]);
94+
95+
// Auto-refresh
96+
useEffect(() => {
97+
if (selectedIds.length === 0) return;
98+
intervalRef.current = setInterval(fetchAll, REFRESH_INTERVAL_MS);
99+
return () => {
100+
if (intervalRef.current) clearInterval(intervalRef.current);
101+
};
102+
}, [fetchAll, selectedIds.length]);
103+
104+
const handleSelectionChange = (
105+
_: unknown,
106+
value: LeaderboardSummary[],
107+
) => {
108+
const ids = value.map((v) => v.id).join(",");
109+
setSearchParams(ids ? { ids } : {}, { replace: true });
110+
};
111+
112+
const selectedSummaries = summaries.filter((s) =>
113+
selectedIds.includes(s.id),
114+
);
115+
116+
return (
117+
<Box sx={{ px: 2, py: 3, maxWidth: "100%", overflow: "hidden" }}>
118+
{/* Header */}
119+
<Box
120+
sx={{
121+
display: "flex",
122+
alignItems: "center",
123+
gap: 2,
124+
mb: 2,
125+
flexWrap: "wrap",
126+
}}
127+
>
128+
<Typography variant="h5" sx={{ fontWeight: 700 }}>
129+
Live Leaderboards
130+
</Typography>
131+
{lastRefresh && (
132+
<Typography variant="caption" sx={{ color: "text.secondary" }}>
133+
Updated {lastRefresh.toLocaleTimeString()}
134+
</Typography>
135+
)}
136+
{selectedIds.length > 0 && (
137+
<Tooltip title="Refresh now">
138+
<IconButton size="small" onClick={fetchAll} disabled={loading}>
139+
<RefreshIcon fontSize="small" />
140+
</IconButton>
141+
</Tooltip>
142+
)}
143+
{selectedIds.length > 0 && (
144+
<Chip
145+
label={`Auto-refresh ${REFRESH_INTERVAL_MS / 1000}s`}
146+
size="small"
147+
color="success"
148+
variant="outlined"
149+
/>
150+
)}
151+
</Box>
152+
153+
{/* Problem picker */}
154+
{loadingSummaries ? (
155+
<CircularProgress size={24} />
156+
) : (
157+
<Autocomplete
158+
multiple
159+
options={summaries}
160+
getOptionLabel={(opt) => opt.name}
161+
value={selectedSummaries}
162+
onChange={handleSelectionChange}
163+
isOptionEqualToValue={(opt, val) => opt.id === val.id}
164+
renderInput={(params) => (
165+
<TextField
166+
{...params}
167+
label="Select problems"
168+
placeholder="Search problems..."
169+
size="small"
170+
/>
171+
)}
172+
sx={{ mb: 3, maxWidth: 700 }}
173+
/>
174+
)}
175+
176+
{/* Loading indicator */}
177+
{loading && boards.length === 0 && (
178+
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
179+
<CircularProgress />
180+
</Box>
181+
)}
182+
183+
{/* Leaderboard columns */}
184+
{boards.length > 0 && (
185+
<Box
186+
sx={{
187+
display: "grid",
188+
gridTemplateColumns: `repeat(${boards.length}, 1fr)`,
189+
gap: 1.5,
190+
overflow: "auto",
191+
}}
192+
>
193+
{boards.map(({ id, detail }) => (
194+
<LeaderboardColumn key={id} id={id} detail={detail} />
195+
))}
196+
</Box>
197+
)}
198+
199+
{selectedIds.length === 0 && !loadingSummaries && (
200+
<Typography sx={{ color: "text.secondary", mt: 2 }}>
201+
Select problems above to display live rankings side by side.
202+
</Typography>
203+
)}
204+
</Box>
205+
);
206+
}
207+
208+
function LeaderboardColumn({
209+
id,
210+
detail,
211+
}: {
212+
id: number;
213+
detail: LeaderboardDetail;
214+
}) {
215+
// Show the priority GPU type rankings (first one), or all if only one
216+
const gpuTypes = Object.keys(detail.rankings);
217+
218+
return (
219+
<Box
220+
sx={{
221+
minWidth: 200,
222+
borderRight: 1,
223+
borderColor: "divider",
224+
pr: 1.5,
225+
"&:last-child": { borderRight: 0 },
226+
}}
227+
>
228+
<Typography
229+
variant="subtitle2"
230+
sx={{
231+
fontWeight: 700,
232+
mb: 1,
233+
whiteSpace: "nowrap",
234+
overflow: "hidden",
235+
textOverflow: "ellipsis",
236+
}}
237+
title={detail.name}
238+
>
239+
<a
240+
href={`/leaderboard/${id}`}
241+
style={{ color: "inherit", textDecoration: "none" }}
242+
>
243+
{detail.name}
244+
</a>
245+
</Typography>
246+
247+
{gpuTypes.map((gpu) => {
248+
const items = detail.rankings[gpu] || [];
249+
return (
250+
<Box key={gpu} sx={{ mb: 1.5 }}>
251+
{gpuTypes.length > 1 && (
252+
<Typography
253+
variant="caption"
254+
sx={{
255+
fontWeight: 600,
256+
color: "text.secondary",
257+
textTransform: "uppercase",
258+
fontSize: "0.65rem",
259+
letterSpacing: 0.5,
260+
}}
261+
>
262+
{gpu}
263+
</Typography>
264+
)}
265+
266+
{/* Header row */}
267+
<Box
268+
sx={{
269+
display: "grid",
270+
gridTemplateColumns: "28px 1fr auto",
271+
gap: 0.5,
272+
borderBottom: 1,
273+
borderColor: "divider",
274+
pb: 0.25,
275+
mb: 0.5,
276+
}}
277+
>
278+
<Typography
279+
variant="caption"
280+
sx={{ fontWeight: 700, fontSize: "0.65rem" }}
281+
>
282+
#
283+
</Typography>
284+
<Typography
285+
variant="caption"
286+
sx={{ fontWeight: 700, fontSize: "0.65rem" }}
287+
>
288+
Name
289+
</Typography>
290+
<Typography
291+
variant="caption"
292+
sx={{
293+
fontWeight: 700,
294+
fontSize: "0.65rem",
295+
textAlign: "right",
296+
}}
297+
>
298+
Time
299+
</Typography>
300+
</Box>
301+
302+
{/* Ranking rows */}
303+
{items.length === 0 ? (
304+
<Typography
305+
variant="caption"
306+
sx={{ color: "text.secondary", fontStyle: "italic" }}
307+
>
308+
No submissions
309+
</Typography>
310+
) : (
311+
items.map((item) => (
312+
<Box
313+
key={item.submission_id}
314+
sx={{
315+
display: "grid",
316+
gridTemplateColumns: "28px 1fr auto",
317+
gap: 0.5,
318+
py: 0.25,
319+
borderBottom: "1px solid",
320+
borderColor: "divider",
321+
"&:last-child": { borderBottom: 0 },
322+
"&:hover": { bgcolor: "action.hover" },
323+
}}
324+
>
325+
<Typography
326+
variant="body2"
327+
sx={{ fontSize: "0.8rem", fontWeight: 600 }}
328+
>
329+
{item.rank <= 3 ? getMedalIcon(item.rank) : item.rank}
330+
</Typography>
331+
<Typography
332+
variant="body2"
333+
sx={{
334+
fontSize: "0.8rem",
335+
fontWeight: item.rank <= 3 ? 700 : 400,
336+
overflow: "hidden",
337+
textOverflow: "ellipsis",
338+
whiteSpace: "nowrap",
339+
}}
340+
title={item.user_name}
341+
>
342+
{item.user_name}
343+
</Typography>
344+
<Typography
345+
variant="body2"
346+
sx={{
347+
fontSize: "0.8rem",
348+
fontFamily: "monospace",
349+
textAlign: "right",
350+
whiteSpace: "nowrap",
351+
}}
352+
>
353+
{formatMicroseconds(item.score)}
354+
</Typography>
355+
</Box>
356+
))
357+
)}
358+
</Box>
359+
);
360+
})}
361+
</Box>
362+
);
363+
}

0 commit comments

Comments
 (0)