Skip to content

Commit 8aba773

Browse files
committed
feat: add loading skeleton, error states, and empty state to leaderboard UI
Implement Issue #179: Loading Skeleton + Error States for Leaderboard. - Add 8 skeleton shimmer rows (desktop) and 8 skeleton cards (mobile) that display on page load, naturally cleared by renderLeaderboard() - Add error banner with [RETRY] button when all 4 data endpoints fail - Error re-fetches data on click, clears cached entries - Add empty state message when API returns no data - Add CSS shimmer animation, fade-in transition for data rows - All states responsive — skeleton rows hidden on mobile, skeleton cards hidden on desktop Resolves #179
1 parent 75f36d6 commit 8aba773

2 files changed

Lines changed: 305 additions & 2 deletions

File tree

frontend/leaderboard.html

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,117 @@ <h1 class="page-title">Leaderboard</h1>
147147
</div>
148148
</div>
149149

150-
<div id="leaderboard-body"></div>
150+
<div id="leaderboard-body">
151+
<!-- Skeleton rows — shown on initial load, cleared by renderLeaderboard() -->
152+
<div class="skeleton-row">
153+
<div class="skeleton-cell"></div>
154+
<div class="skeleton-cell"></div>
155+
<div class="skeleton-cell"></div>
156+
<div class="skeleton-cell"></div>
157+
<div class="skeleton-cell"></div>
158+
<div class="skeleton-cell"></div>
159+
<div class="skeleton-cell"></div>
160+
<div class="skeleton-cell"></div>
161+
</div>
162+
<div class="skeleton-row">
163+
<div class="skeleton-cell"></div>
164+
<div class="skeleton-cell"></div>
165+
<div class="skeleton-cell"></div>
166+
<div class="skeleton-cell"></div>
167+
<div class="skeleton-cell"></div>
168+
<div class="skeleton-cell"></div>
169+
<div class="skeleton-cell"></div>
170+
<div class="skeleton-cell"></div>
171+
</div>
172+
<div class="skeleton-row">
173+
<div class="skeleton-cell"></div>
174+
<div class="skeleton-cell"></div>
175+
<div class="skeleton-cell"></div>
176+
<div class="skeleton-cell"></div>
177+
<div class="skeleton-cell"></div>
178+
<div class="skeleton-cell"></div>
179+
<div class="skeleton-cell"></div>
180+
<div class="skeleton-cell"></div>
181+
</div>
182+
<div class="skeleton-row">
183+
<div class="skeleton-cell"></div>
184+
<div class="skeleton-cell"></div>
185+
<div class="skeleton-cell"></div>
186+
<div class="skeleton-cell"></div>
187+
<div class="skeleton-cell"></div>
188+
<div class="skeleton-cell"></div>
189+
<div class="skeleton-cell"></div>
190+
<div class="skeleton-cell"></div>
191+
</div>
192+
<div class="skeleton-row">
193+
<div class="skeleton-cell"></div>
194+
<div class="skeleton-cell"></div>
195+
<div class="skeleton-cell"></div>
196+
<div class="skeleton-cell"></div>
197+
<div class="skeleton-cell"></div>
198+
<div class="skeleton-cell"></div>
199+
<div class="skeleton-cell"></div>
200+
<div class="skeleton-cell"></div>
201+
</div>
202+
<div class="skeleton-row">
203+
<div class="skeleton-cell"></div>
204+
<div class="skeleton-cell"></div>
205+
<div class="skeleton-cell"></div>
206+
<div class="skeleton-cell"></div>
207+
<div class="skeleton-cell"></div>
208+
<div class="skeleton-cell"></div>
209+
<div class="skeleton-cell"></div>
210+
<div class="skeleton-cell"></div>
211+
</div>
212+
<div class="skeleton-row">
213+
<div class="skeleton-cell"></div>
214+
<div class="skeleton-cell"></div>
215+
<div class="skeleton-cell"></div>
216+
<div class="skeleton-cell"></div>
217+
<div class="skeleton-cell"></div>
218+
<div class="skeleton-cell"></div>
219+
<div class="skeleton-cell"></div>
220+
<div class="skeleton-cell"></div>
221+
</div>
222+
<div class="skeleton-row">
223+
<div class="skeleton-cell"></div>
224+
<div class="skeleton-cell"></div>
225+
<div class="skeleton-cell"></div>
226+
<div class="skeleton-cell"></div>
227+
<div class="skeleton-cell"></div>
228+
<div class="skeleton-cell"></div>
229+
<div class="skeleton-cell"></div>
230+
<div class="skeleton-cell"></div>
231+
</div>
232+
</div>
151233
</div>
152234

153-
<div class="mobile-cards" id="mobile-cards"></div>
235+
<div class="mobile-cards" id="mobile-cards">
236+
<!-- Skeleton mobile cards — shown on initial load, cleared by renderLeaderboard() -->
237+
<div class="skeleton-card"></div>
238+
<div class="skeleton-card"></div>
239+
<div class="skeleton-card"></div>
240+
<div class="skeleton-card"></div>
241+
<div class="skeleton-card"></div>
242+
<div class="skeleton-card"></div>
243+
<div class="skeleton-card"></div>
244+
<div class="skeleton-card"></div>
245+
</div>
246+
247+
<!-- Error State -->
248+
<div id="leaderboard-error" class="leaderboard-error">
249+
<div class="leaderboard-error-content">
250+
<div class="leaderboard-error-icon">[!]</div>
251+
<div class="leaderboard-error-msg">
252+
LEADERBOARD_DATA_UNAVAILABLE
253+
</div>
254+
<div class="leaderboard-error-desc">
255+
Failed to fetch leaderboard data. The upstream API may be
256+
rate-limited or unreachable. Please try again.
257+
</div>
258+
<button id="retry-btn" class="btn btn-error">[RETRY]</button>
259+
</div>
260+
</div>
154261

155262
<div id="pagination-controls" class="pagination-controls">
156263
<button id="prev-page-btn" class="page-nav-btn">&lt; PREV</button>
@@ -176,6 +283,15 @@ <h1 class="page-title">Leaderboard</h1>
176283
</div>
177284

178285
<script nonce="__NONCE__">
286+
// ── Error State Helpers ──
287+
function showError() {
288+
document.getElementById("leaderboard-error").classList.add("active");
289+
}
290+
291+
function hideError() {
292+
document.getElementById("leaderboard-error").classList.remove("active");
293+
}
294+
179295
document.addEventListener("DOMContentLoaded", () => {
180296
document.querySelectorAll(".tab").forEach((tab) => {
181297
tab.addEventListener("click", () => {
@@ -185,9 +301,22 @@ <h1 class="page-title">Leaderboard</h1>
185301

186302
setupSearchListeners();
187303
setupPaginationListeners();
304+
// Skeleton rows are already in #leaderboard-body — shown until renderLeaderboard() clears them
188305
fetchLeaderboardData();
189306
// Poll every 2 minutes to detect new syncs
190307
setInterval(fetchLeaderboardData, 2 * 60 * 1000);
308+
309+
// Retry button — re-fetches data
310+
document
311+
.getElementById("retry-btn")
312+
.addEventListener("click", function () {
313+
hideError();
314+
leaderboardData["overall"] = null;
315+
leaderboardData["monthly"] = null;
316+
leaderboardData["weekly"] = null;
317+
leaderboardData["daily"] = null;
318+
fetchLeaderboardData();
319+
});
191320
});
192321

193322
const leaderboardData = {};
@@ -211,14 +340,27 @@ <h1 class="page-title">Leaderboard</h1>
211340
),
212341
);
213342

343+
let anySuccess = false;
214344
results.forEach((result, index) => {
215345
if (result.status === "fulfilled") {
216346
leaderboardData[endpoints[index]] = result.value;
347+
anySuccess = true;
217348
} else {
218349
console.error("Failed to fetch", endpoints[index], result.reason);
219350
}
220351
});
221352

353+
// If ALL endpoints failed, show error state instead of blank page
354+
if (!anySuccess) {
355+
document.getElementById("leaderboard-body").innerHTML = "";
356+
document.getElementById("mobile-cards").innerHTML = "";
357+
document.getElementById("leaderboard-stats").innerHTML = "";
358+
hideError(); // reset in case retry was clicked
359+
showError();
360+
return;
361+
}
362+
hideError();
363+
222364
try {
223365
// Fetch directly from github to avoid needing a server redeploy for new data
224366
const syncRes = await fetch(
@@ -282,6 +424,16 @@ <h1 class="page-title">Leaderboard</h1>
282424

283425
const originalData = leaderboardData[activeDatasetType];
284426

427+
// Empty state — data exists but is empty array
428+
if (originalData.length === 0) {
429+
document.getElementById("leaderboard-body").innerHTML =
430+
'<div class="leaderboard-empty">[SYS]: NO_LEADERBOARD_DATA_YET</div>';
431+
document.getElementById("mobile-cards").innerHTML =
432+
'<div class="leaderboard-empty">[SYS]: NO_LEADERBOARD_DATA_YET</div>';
433+
document.getElementById("leaderboard-stats").innerHTML = "";
434+
return;
435+
}
436+
285437
const filteredData = originalData.filter((user) => {
286438
if (!currentSearchTerm) return true;
287439

frontend/styles/main.css

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2804,3 +2804,154 @@ body::-webkit-scrollbar-thumb {
28042804
.changes-content-line.no-icon::before {
28052805
display: none;
28062806
}
2807+
2808+
/* Fade-in for rendered leaderboard rows (smooth skeleton→data transition) */
2809+
#leaderboard-body > .leaderboard-row {
2810+
animation: fadeIn 0.25s ease-out;
2811+
}
2812+
2813+
/* ── Skeleton Loading States ── */
2814+
.skeleton-row {
2815+
display: grid;
2816+
grid-template-columns: 55px 1fr 170px 65px 65px 65px 85px 85px;
2817+
padding: 0.7rem 1.25rem;
2818+
border-bottom: 1px solid var(--border);
2819+
align-items: center;
2820+
gap: 8px;
2821+
}
2822+
2823+
.skeleton-cell {
2824+
height: 14px;
2825+
background: linear-gradient(
2826+
90deg,
2827+
var(--bg-raised) 25%,
2828+
var(--green-muted) 50%,
2829+
var(--bg-raised) 75%
2830+
);
2831+
background-size: 200% 100%;
2832+
animation: skeleton-shimmer 1.5s ease-in-out infinite;
2833+
border-radius: 2px;
2834+
}
2835+
2836+
.skeleton-cell:nth-child(2) {
2837+
width: 160px;
2838+
}
2839+
2840+
.skeleton-cell:nth-child(3) {
2841+
width: 130px;
2842+
}
2843+
2844+
.skeleton-cell:nth-child(4),
2845+
.skeleton-cell:nth-child(5),
2846+
.skeleton-cell:nth-child(6) {
2847+
width: 35px;
2848+
}
2849+
2850+
.skeleton-cell:nth-child(7) {
2851+
width: 45px;
2852+
}
2853+
2854+
.skeleton-cell:nth-child(8) {
2855+
width: 55px;
2856+
}
2857+
2858+
.skeleton-row .skeleton-cell:first-child {
2859+
width: 40px;
2860+
}
2861+
2862+
@keyframes skeleton-shimmer {
2863+
0% {
2864+
background-position: 200% 0;
2865+
}
2866+
2867+
100% {
2868+
background-position: -200% 0;
2869+
}
2870+
}
2871+
2872+
/* Mobile Skeleton */
2873+
.skeleton-card {
2874+
height: 82px;
2875+
background: linear-gradient(
2876+
90deg,
2877+
var(--bg-surface) 25%,
2878+
var(--green-muted) 50%,
2879+
var(--bg-surface) 75%
2880+
);
2881+
background-size: 200% 100%;
2882+
animation: skeleton-shimmer 1.5s ease-in-out infinite;
2883+
border-radius: 4px;
2884+
margin-bottom: 0.6rem;
2885+
border: 1px solid var(--border);
2886+
border-left: 3px solid var(--green-dim);
2887+
}
2888+
2889+
/* ── Leaderboard Error State ── */
2890+
.leaderboard-error {
2891+
display: none;
2892+
background: var(--bg-surface);
2893+
border: 1px solid rgba(255, 51, 51, 0.3);
2894+
border-radius: 6px;
2895+
padding: 2.5rem 1.5rem;
2896+
text-align: center;
2897+
margin-top: 1rem;
2898+
box-shadow:
2899+
0 0 20px rgba(255, 51, 51, 0.05),
2900+
inset 0 0 40px rgba(255, 51, 51, 0.02);
2901+
}
2902+
2903+
.leaderboard-error.active {
2904+
display: block;
2905+
}
2906+
2907+
.leaderboard-error-icon {
2908+
font-family: "Space Mono", monospace;
2909+
font-size: 2.5rem;
2910+
color: var(--red);
2911+
margin-bottom: 0.75rem;
2912+
text-shadow: 0 0 20px rgba(255, 51, 51, 0.4);
2913+
}
2914+
2915+
.leaderboard-error-msg {
2916+
font-family: "Space Mono", monospace;
2917+
font-size: 1.1rem;
2918+
font-weight: 700;
2919+
color: var(--red);
2920+
margin-bottom: 0.5rem;
2921+
letter-spacing: 1px;
2922+
}
2923+
2924+
.leaderboard-error-desc {
2925+
font-size: 0.82rem;
2926+
color: var(--text-dim);
2927+
margin-bottom: 1.5rem;
2928+
line-height: 1.6;
2929+
max-width: 400px;
2930+
margin-left: auto;
2931+
margin-right: auto;
2932+
}
2933+
2934+
/* ── Leaderboard Empty State ── */
2935+
.leaderboard-empty {
2936+
text-align: center;
2937+
padding: 3rem 1rem;
2938+
color: var(--text-muted);
2939+
font-family: "Fira Code", monospace;
2940+
font-size: 0.9rem;
2941+
}
2942+
2943+
@media (max-width: 940px) {
2944+
.skeleton-row {
2945+
display: none;
2946+
}
2947+
2948+
.skeleton-card {
2949+
display: block;
2950+
}
2951+
}
2952+
2953+
@media (min-width: 941px) {
2954+
.skeleton-card {
2955+
display: none;
2956+
}
2957+
}

0 commit comments

Comments
 (0)