Skip to content

Commit 0dfe64a

Browse files
authored
feat(leaderboard): handle empty and partial states (#934)
- Add LeaderboardLoadingState, LeaderboardErrorState, LeaderboardEmptyState, LeaderboardSkeleton components - Create useLeaderboard hook for managing loading, error, empty, stale, and retry states - Update LeaderboardSection to use hook and handle all states with fallback data - Update LeaderboardTable to accept isLoading, error, onRetry props and render appropriate states - Fix sorting bugs (sortDescending undefined, scrollParentRef typo) - Update LeaderboardPodium to render empty state when topThree is empty - Update LeaderboardCards to handle loading, error, and empty states - Update YourRankChip to gracefully handle missing user data (user is optional) - Add comprehensive tests for all states and components Closes #898 Co-authored-by: Tekh134 <tekh134@users.noreply.github.com>
1 parent 1aca1af commit 0dfe64a

14 files changed

Lines changed: 1351 additions & 126 deletions

‎components/leaderboard/LeaderboardCards.tsx‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,60 @@ import React, { useRef } from "react";
44
import { useVirtualizer } from "@tanstack/react-virtual";
55
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
66
import { cn } from "@/lib/utils";
7+
import { LeaderboardEmptyState } from "./leaderboard-states";
78
import { LeaderboardUser } from "@/lib/leaderboard-data";
89

910
interface LeaderboardCardsProps {
1011
users: LeaderboardUser[];
1112
onUserVisibilityChange?: (isVisible: boolean) => void;
13+
isLoading?: boolean;
14+
error?: string | null;
15+
onRetry?: () => void;
1216
}
1317

14-
export function LeaderboardCards({ users, onUserVisibilityChange }: LeaderboardCardsProps) {
18+
export function LeaderboardCards({ users, onUserVisibilityChange, isLoading, error, onRetry }: LeaderboardCardsProps) {
1519
const parentRef = useRef<HTMLDivElement>(null);
1620

21+
if (isLoading) {
22+
return (
23+
<div className="space-y-3">
24+
{Array.from({ length: 4 }).map((_, i) => (
25+
<div
26+
key={i}
27+
className="flex items-center gap-4 p-4 rounded-xl border border-slate-800 bg-slate-900/40"
28+
>
29+
<div className="w-6 h-4 rounded bg-slate-800 animate-pulse" />
30+
<div className="w-10 h-10 rounded-full bg-slate-800 animate-pulse" />
31+
<div className="flex-1 space-y-2">
32+
<div className="h-4 w-32 rounded bg-slate-800 animate-pulse" />
33+
<div className="h-3 w-20 rounded bg-slate-800 animate-pulse" />
34+
</div>
35+
<div className="w-16 h-4 rounded bg-slate-800 animate-pulse hidden sm:block" />
36+
</div>
37+
))}
38+
</div>
39+
);
40+
}
41+
42+
if (error) {
43+
return (
44+
<LeaderboardErrorState
45+
error={error}
46+
onRetry={onRetry}
47+
className="rounded-2xl border border-slate-800"
48+
/>
49+
);
50+
}
51+
52+
if (users.length === 0) {
53+
return (
54+
<LeaderboardEmptyState
55+
onRetry={onRetry}
56+
className="rounded-2xl border border-slate-800"
57+
/>
58+
);
59+
}
60+
1761
const rowVirtualizer = useVirtualizer({
1862
count: users.length,
1963
getScrollElement: () => parentRef.current,
@@ -54,7 +98,7 @@ export function LeaderboardCards({ users, onUserVisibilityChange }: LeaderboardC
5498
: "bg-slate-900/40 border-slate-800"
5599
)}
56100
style={{
57-
height: `72px`, // slightly less than estimate to allow gap
101+
height: `72px`,
58102
transform: `translateY(${virtualRow.start}px)`,
59103
}}
60104
>

‎components/leaderboard/LeaderboardPodium.tsx‎

Lines changed: 97 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
55
import { motion } from "framer-motion";
66
import { LeaderboardUser } from "@/lib/leaderboard-data";
77
import { useReducedMotion } from "@/hooks/useReducedMotion";
8+
import { LeaderboardEmptyState } from "./leaderboard-states";
89

910
interface LeaderboardPodiumProps {
1011
topThree: LeaderboardUser[];
@@ -24,6 +25,7 @@ interface LeaderboardPodiumProps {
2425
* - Responsive design with proper spacing and sizing
2526
* - Accessible: proper alt text, semantic structure
2627
* - Static fallback maintains visual hierarchy without motion
28+
* - Gracefully handles empty or partial top-three data
2729
*
2830
* WCAG 2.1 AA Compliance:
2931
* - Respects prefers-reduced-motion for users with vestibular disorders
@@ -38,6 +40,17 @@ export function LeaderboardPodium({
3840
const prefersReducedMotion = useReducedMotion();
3941
const reducedMotion = reducedMotionProp ?? prefersReducedMotion;
4042

43+
if (topThree.length === 0) {
44+
return (
45+
<div className="flex items-center justify-center py-12">
46+
<LeaderboardEmptyState
47+
title="No Rankings Yet"
48+
description="The leaderboard is empty. Be the first to start predicting and claim the top spot."
49+
/>
50+
</div>
51+
);
52+
}
53+
4154
// Static fallback component for reduced motion
4255
const StaticPodium = () => (
4356
<div
@@ -47,24 +60,26 @@ export function LeaderboardPodium({
4760
aria-label="Leaderboard top 3 positions"
4861
>
4962
{/* 2nd Place */}
50-
<div className="flex flex-col items-center">
51-
<div className="relative mb-2">
52-
<Avatar className="h-16 w-16 border-2 border-slate-300 ring-4 ring-slate-300/20">
53-
<AvatarImage src={second?.avatarUrl} alt={second?.name} />
54-
<AvatarFallback>{second?.name?.[0]}</AvatarFallback>
55-
</Avatar>
56-
<div
57-
className="absolute -top-2 -right-2 bg-slate-300 text-slate-900 text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center"
58-
aria-label="2nd place"
59-
>
60-
2
63+
{second ? (
64+
<div className="flex flex-col items-center">
65+
<div className="relative mb-2">
66+
<Avatar className="h-16 w-16 border-2 border-slate-300 ring-4 ring-slate-300/20">
67+
<AvatarImage src={second.avatarUrl} alt={second.name} />
68+
<AvatarFallback>{second.name?.[0]}</AvatarFallback>
69+
</Avatar>
70+
<div
71+
className="absolute -top-2 -right-2 bg-slate-300 text-slate-900 text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center"
72+
aria-label="2nd place"
73+
>
74+
2
75+
</div>
76+
</div>
77+
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-24 flex flex-col items-center justify-center p-2 text-center">
78+
<span className="text-xs font-semibold text-white truncate w-full">{second.name}</span>
79+
<span className="text-[10px] text-cyan-400 tabular-nums">+{second.profit}</span>
6180
</div>
6281
</div>
63-
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-24 flex flex-col items-center justify-center p-2 text-center">
64-
<span className="text-xs font-semibold text-white truncate w-full">{second?.name}</span>
65-
<span className="text-[10px] text-cyan-400 tabular-nums">+{second?.profit}</span>
66-
</div>
67-
</div>
82+
) : <div className="w-20 h-40" aria-hidden="true" />}
6883

6984
{/* 1st Place */}
7085
<div className="flex flex-col items-center -mt-8">
@@ -91,24 +106,26 @@ export function LeaderboardPodium({
91106
</div>
92107

93108
{/* 3rd Place */}
94-
<div className="flex flex-col items-center">
95-
<div className="relative mb-2">
96-
<Avatar className="h-16 w-16 border-2 border-amber-600 ring-4 ring-amber-600/20">
97-
<AvatarImage src={third?.avatarUrl} alt={third?.name} />
98-
<AvatarFallback>{third?.name?.[0]}</AvatarFallback>
99-
</Avatar>
100-
<div
101-
className="absolute -top-2 -right-2 bg-amber-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center"
102-
aria-label="3rd place"
103-
>
104-
3
109+
{third ? (
110+
<div className="flex flex-col items-center">
111+
<div className="relative mb-2">
112+
<Avatar className="h-16 w-16 border-2 border-amber-600 ring-4 ring-amber-600/20">
113+
<AvatarImage src={third.avatarUrl} alt={third.name} />
114+
<AvatarFallback>{third.name?.[0]}</AvatarFallback>
115+
</Avatar>
116+
<div
117+
className="absolute -top-2 -right-2 bg-amber-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center"
118+
aria-label="3rd place"
119+
>
120+
3
121+
</div>
122+
</div>
123+
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-20 flex flex-col items-center justify-center p-2 text-center">
124+
<span className="text-xs font-semibold text-white truncate w-full">{third.name}</span>
125+
<span className="text-[10px] text-cyan-400 tabular-nums">+{third.profit}</span>
105126
</div>
106127
</div>
107-
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-20 flex flex-col items-center justify-center p-2 text-center">
108-
<span className="text-xs font-semibold text-white truncate w-full">{third?.name}</span>
109-
<span className="text-[10px] text-cyan-400 tabular-nums">+{third?.profit}</span>
110-
</div>
111-
</div>
128+
) : <div className="w-20 h-20" aria-hidden="true" />}
112129
</div>
113130
);
114131

@@ -126,29 +143,31 @@ export function LeaderboardPodium({
126143
aria-label="Leaderboard top 3 positions"
127144
>
128145
{/* 2nd Place */}
129-
<motion.div
130-
initial={{ opacity: 0, y: 20 }}
131-
animate={{ opacity: 1, y: 0 }}
132-
transition={{ delay: 0.2, duration: 0.6, ease: "easeOut" }}
133-
className="flex flex-col items-center"
134-
>
135-
<div className="relative mb-2">
136-
<Avatar className="h-16 w-16 border-2 border-slate-300 ring-4 ring-slate-300/20">
137-
<AvatarImage src={second?.avatarUrl} alt={second?.name} />
138-
<AvatarFallback>{second?.name?.[0]}</AvatarFallback>
139-
</Avatar>
140-
<div
141-
className="absolute -top-2 -right-2 bg-slate-300 text-slate-900 text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center"
142-
aria-label="2nd place"
143-
>
144-
2
146+
{second ? (
147+
<motion.div
148+
initial={{ opacity: 0, y: 20 }}
149+
animate={{ opacity: 1, y: 0 }}
150+
transition={{ delay: 0.2, duration: 0.6, ease: "easeOut" }}
151+
className="flex flex-col items-center"
152+
>
153+
<div className="relative mb-2">
154+
<Avatar className="h-16 w-16 border-2 border-slate-300 ring-4 ring-slate-300/20">
155+
<AvatarImage src={second.avatarUrl} alt={second.name} />
156+
<AvatarFallback>{second.name?.[0]}</AvatarFallback>
157+
</Avatar>
158+
<div
159+
className="absolute -top-2 -right-2 bg-slate-300 text-slate-900 text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center"
160+
aria-label="2nd place"
161+
>
162+
2
163+
</div>
145164
</div>
146-
</div>
147-
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-24 flex flex-col items-center justify-center p-2 text-center">
148-
<span className="text-xs font-semibold text-white truncate w-full">{second?.name}</span>
149-
<span className="text-[10px] text-cyan-400 tabular-nums">+{second?.profit}</span>
150-
</div>
151-
</motion.div>
165+
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-24 flex flex-col items-center justify-center p-2 text-center">
166+
<span className="text-xs font-semibold text-white truncate w-full">{second.name}</span>
167+
<span className="text-[10px] text-cyan-400 tabular-nums">+{second.profit}</span>
168+
</div>
169+
</motion.div>
170+
) : <div className="w-20 h-40" aria-hidden="true" />}
152171

153172
{/* 1st Place */}
154173
<motion.div
@@ -180,29 +199,31 @@ export function LeaderboardPodium({
180199
</motion.div>
181200

182201
{/* 3rd Place */}
183-
<motion.div
184-
initial={{ opacity: 0, y: 20 }}
185-
animate={{ opacity: 1, y: 0 }}
186-
transition={{ delay: 0.4, duration: 0.6, ease: "easeOut" }}
187-
className="flex flex-col items-center"
188-
>
189-
<div className="relative mb-2">
190-
<Avatar className="h-16 w-16 border-2 border-amber-600 ring-4 ring-amber-600/20">
191-
<AvatarImage src={third?.avatarUrl} alt={third?.name} />
192-
<AvatarFallback>{third?.name?.[0]}</AvatarFallback>
193-
</Avatar>
194-
<div
195-
className="absolute -top-2 -right-2 bg-amber-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center"
196-
aria-label="3rd place"
197-
>
198-
3
202+
{third ? (
203+
<motion.div
204+
initial={{ opacity: 0, y: 20 }}
205+
animate={{ opacity: 1, y: 0 }}
206+
transition={{ delay: 0.4, duration: 0.6, ease: "easeOut" }}
207+
className="flex flex-col items-center"
208+
>
209+
<div className="relative mb-2">
210+
<Avatar className="h-16 w-16 border-2 border-amber-600 ring-4 ring-amber-600/20">
211+
<AvatarImage src={third.avatarUrl} alt={third.name} />
212+
<AvatarFallback>{third.name?.[0]}</AvatarFallback>
213+
</Avatar>
214+
<div
215+
className="absolute -top-2 -right-2 bg-amber-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center"
216+
aria-label="3rd place"
217+
>
218+
3
219+
</div>
199220
</div>
200-
</div>
201-
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-20 flex flex-col items-center justify-center p-2 text-center">
202-
<span className="text-xs font-semibold text-white truncate w-full">{third?.name}</span>
203-
<span className="text-[10px] text-cyan-400 tabular-nums">+{third?.profit}</span>
204-
</div>
205-
</motion.div>
221+
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-20 flex flex-col items-center justify-center p-2 text-center">
222+
<span className="text-xs font-semibold text-white truncate w-full">{third.name}</span>
223+
<span className="text-[10px] text-cyan-400 tabular-nums">+{third.profit}</span>
224+
</div>
225+
</motion.div>
226+
) : <div className="w-20 h-20" aria-hidden="true" />}
206227
</div>
207228
);
208229
}

0 commit comments

Comments
 (0)