Skip to content

Commit 3b21914

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 d0da2e2 commit 3b21914

2 files changed

Lines changed: 343 additions & 37 deletions

File tree

frontend/leaderboard.html

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,117 @@ <h1 class="page-title">Leaderboard</h1>
223223
</div>
224224
</div>
225225

226-
<div id="leaderboard-body"></div>
226+
<div id="leaderboard-body">
227+
<!-- Skeleton rows — shown on initial load, cleared by renderLeaderboard() -->
228+
<div class="skeleton-row">
229+
<div class="skeleton-cell"></div>
230+
<div class="skeleton-cell"></div>
231+
<div class="skeleton-cell"></div>
232+
<div class="skeleton-cell"></div>
233+
<div class="skeleton-cell"></div>
234+
<div class="skeleton-cell"></div>
235+
<div class="skeleton-cell"></div>
236+
<div class="skeleton-cell"></div>
237+
</div>
238+
<div class="skeleton-row">
239+
<div class="skeleton-cell"></div>
240+
<div class="skeleton-cell"></div>
241+
<div class="skeleton-cell"></div>
242+
<div class="skeleton-cell"></div>
243+
<div class="skeleton-cell"></div>
244+
<div class="skeleton-cell"></div>
245+
<div class="skeleton-cell"></div>
246+
<div class="skeleton-cell"></div>
247+
</div>
248+
<div class="skeleton-row">
249+
<div class="skeleton-cell"></div>
250+
<div class="skeleton-cell"></div>
251+
<div class="skeleton-cell"></div>
252+
<div class="skeleton-cell"></div>
253+
<div class="skeleton-cell"></div>
254+
<div class="skeleton-cell"></div>
255+
<div class="skeleton-cell"></div>
256+
<div class="skeleton-cell"></div>
257+
</div>
258+
<div class="skeleton-row">
259+
<div class="skeleton-cell"></div>
260+
<div class="skeleton-cell"></div>
261+
<div class="skeleton-cell"></div>
262+
<div class="skeleton-cell"></div>
263+
<div class="skeleton-cell"></div>
264+
<div class="skeleton-cell"></div>
265+
<div class="skeleton-cell"></div>
266+
<div class="skeleton-cell"></div>
267+
</div>
268+
<div class="skeleton-row">
269+
<div class="skeleton-cell"></div>
270+
<div class="skeleton-cell"></div>
271+
<div class="skeleton-cell"></div>
272+
<div class="skeleton-cell"></div>
273+
<div class="skeleton-cell"></div>
274+
<div class="skeleton-cell"></div>
275+
<div class="skeleton-cell"></div>
276+
<div class="skeleton-cell"></div>
277+
</div>
278+
<div class="skeleton-row">
279+
<div class="skeleton-cell"></div>
280+
<div class="skeleton-cell"></div>
281+
<div class="skeleton-cell"></div>
282+
<div class="skeleton-cell"></div>
283+
<div class="skeleton-cell"></div>
284+
<div class="skeleton-cell"></div>
285+
<div class="skeleton-cell"></div>
286+
<div class="skeleton-cell"></div>
287+
</div>
288+
<div class="skeleton-row">
289+
<div class="skeleton-cell"></div>
290+
<div class="skeleton-cell"></div>
291+
<div class="skeleton-cell"></div>
292+
<div class="skeleton-cell"></div>
293+
<div class="skeleton-cell"></div>
294+
<div class="skeleton-cell"></div>
295+
<div class="skeleton-cell"></div>
296+
<div class="skeleton-cell"></div>
297+
</div>
298+
<div class="skeleton-row">
299+
<div class="skeleton-cell"></div>
300+
<div class="skeleton-cell"></div>
301+
<div class="skeleton-cell"></div>
302+
<div class="skeleton-cell"></div>
303+
<div class="skeleton-cell"></div>
304+
<div class="skeleton-cell"></div>
305+
<div class="skeleton-cell"></div>
306+
<div class="skeleton-cell"></div>
307+
</div>
308+
</div>
227309
</div>
228310

229-
<div class="mobile-cards" id="mobile-cards"></div>
311+
<div class="mobile-cards" id="mobile-cards">
312+
<!-- Skeleton mobile cards — shown on initial load, cleared by renderLeaderboard() -->
313+
<div class="skeleton-card"></div>
314+
<div class="skeleton-card"></div>
315+
<div class="skeleton-card"></div>
316+
<div class="skeleton-card"></div>
317+
<div class="skeleton-card"></div>
318+
<div class="skeleton-card"></div>
319+
<div class="skeleton-card"></div>
320+
<div class="skeleton-card"></div>
321+
</div>
322+
323+
<!-- Error State -->
324+
<div id="leaderboard-error" class="leaderboard-error">
325+
<div class="leaderboard-error-content">
326+
<div class="leaderboard-error-icon">[!]</div>
327+
<div class="leaderboard-error-msg">
328+
LEADERBOARD_DATA_UNAVAILABLE
329+
</div>
330+
<div class="leaderboard-error-desc">
331+
Failed to fetch leaderboard data. The upstream API may be
332+
rate-limited or unreachable. Please try again.
333+
</div>
334+
<button id="retry-btn" class="btn btn-error">[RETRY]</button>
335+
</div>
336+
</div>
230337

231338
<div id="pagination-controls" class="pagination-controls">
232339
<button id="prev-page-btn" class="page-nav-btn">&lt; PREV</button>
@@ -252,6 +359,15 @@ <h1 class="page-title">Leaderboard</h1>
252359
</div>
253360

254361
<script nonce="__NONCE__">
362+
// ── Error State Helpers ──
363+
function showError() {
364+
document.getElementById("leaderboard-error").classList.add("active");
365+
}
366+
367+
function hideError() {
368+
document.getElementById("leaderboard-error").classList.remove("active");
369+
}
370+
255371
document.addEventListener("DOMContentLoaded", () => {
256372
document.querySelectorAll(".tab").forEach((tab) => {
257373
tab.addEventListener("click", () => {
@@ -261,6 +377,7 @@ <h1 class="page-title">Leaderboard</h1>
261377

262378
setupSearchListeners();
263379
setupPaginationListeners();
380+
// Skeleton rows are already in #leaderboard-body — shown until renderLeaderboard() clears them
264381
fetchLeaderboardData();
265382
// Poll every 2 minutes to detect new syncs
266383
// Page Visibility API — pause polling when tab is hidden
@@ -285,6 +402,18 @@ <h1 class="page-title">Leaderboard</h1>
285402
});
286403

287404
startPolling();
405+
406+
// Retry button — re-fetches data
407+
document
408+
.getElementById("retry-btn")
409+
.addEventListener("click", function () {
410+
hideError();
411+
leaderboardData["overall"] = null;
412+
leaderboardData["monthly"] = null;
413+
leaderboardData["weekly"] = null;
414+
leaderboardData["daily"] = null;
415+
fetchLeaderboardData();
416+
});
288417
});
289418

290419
window.leaderboardData = {};
@@ -308,14 +437,27 @@ <h1 class="page-title">Leaderboard</h1>
308437
),
309438
);
310439

440+
let anySuccess = false;
311441
results.forEach((result, index) => {
312442
if (result.status === "fulfilled") {
313443
window.leaderboardData[endpoints[index]] = result.value;
444+
anySuccess = true;
314445
} else {
315446
console.error("Failed to fetch", endpoints[index], result.reason);
316447
}
317448
});
318449

450+
// If ALL endpoints failed, show error state instead of blank page
451+
if (!anySuccess) {
452+
document.getElementById("leaderboard-body").innerHTML = "";
453+
document.getElementById("mobile-cards").innerHTML = "";
454+
document.getElementById("leaderboard-stats").innerHTML = "";
455+
hideError(); // reset in case retry was clicked
456+
showError();
457+
return;
458+
}
459+
hideError();
460+
319461
try {
320462
// Fetch directly from github to avoid needing a server redeploy for new data
321463
const syncRes = await fetch(
@@ -379,6 +521,16 @@ <h1 class="page-title">Leaderboard</h1>
379521

380522
const originalData = window.leaderboardData[activeDatasetType];
381523

524+
// Empty state — data exists but is empty array
525+
if (originalData.length === 0) {
526+
document.getElementById("leaderboard-body").innerHTML =
527+
'<div class="leaderboard-empty">[SYS]: NO_LEADERBOARD_DATA_YET</div>';
528+
document.getElementById("mobile-cards").innerHTML =
529+
'<div class="leaderboard-empty">[SYS]: NO_LEADERBOARD_DATA_YET</div>';
530+
document.getElementById("leaderboard-stats").innerHTML = "";
531+
return;
532+
}
533+
382534
const filteredData = originalData.filter((user) => {
383535
if (!currentSearchTerm) return true;
384536

0 commit comments

Comments
 (0)