Skip to content

feat: introduce leaderboard page with filtering, table, and user rank#62

Merged
0xdevcollins merged 3 commits intoboundlessfi:mainfrom
Dprof-in-tech:feat-implement-the-leaderboard-page
Jan 30, 2026
Merged

feat: introduce leaderboard page with filtering, table, and user rank#62
0xdevcollins merged 3 commits intoboundlessfi:mainfrom
Dprof-in-tech:feat-implement-the-leaderboard-page

Conversation

@Dprof-in-tech
Copy link
Contributor

@Dprof-in-tech Dprof-in-tech commented Jan 30, 2026

This pull request introduces a complete mock leaderboard feature, including backend API endpoints, frontend hooks, and supporting types and utilities. The changes enable fetching, filtering, and paginating leaderboard data, retrieving user ranks, and obtaining top contributors, all using mock data for development and testing purposes.

This pull request introduces a new, fully client-rendered leaderboard page along with a set of reusable leaderboard UI components. The changes add filtering, infinite pagination, and current user rank display. The implementation includes modular components for the leaderboard table, filters, rank/tier/streak badges, and a sidebar for the user's rank and stats. Additionally, there is a minor cleanup in the Bounty type to remove obsolete comments.

Leaderboard Page and UI Components:

  • Added a new client-side LeaderboardPage (app/leaderboard/page.tsx) that fetches leaderboard data, manages filters via URL, and displays the leaderboard table and user rank sidebar.
  • Implemented LeaderboardTable with infinite scroll, loading skeletons, and highlighting for the current user. Includes rank, contributor info, tier, score, completed, earnings, and streak columns.
  • Added LeaderboardFilters component for filtering by timeframe and tier, with a clear filters button.
  • Created modular badge components: RankBadge for rank display, TierBadge for contributor tier, and StreakIndicator for streaks with tooltip. [1] [2] [3]
  • Added UserRankSidebar to show the current user's rank, avatar, tier, and stats with loading and empty states.

Type and Code Cleanup:

  • Removed obsolete comments from the Bounty type definition, keeping requirements and scope as optional fields for backward compatibility.

closes #58

Screenshot 2026-01-30 at 01 48 28

Summary by CodeRabbit

  • New Features
    • New Leaderboard page with URL-synced filters (timeframe, tier, tags), debounced filtering, and infinite scrolling.
    • Leaderboard table with avatars, rank highlights (including “You”), responsive tag display, loading states, and keyboard-accessible rows.
    • User rank sidebar showing rank, score, earnings, completed, streak, and progress toward next tier.
    • New UI badges/components: rank, tier, and streak displays.
    • Contributor stats extended with optional fields for current points and next-tier threshold.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 30, 2026

📝 Walkthrough

Walkthrough

Adds a client-side Leaderboard page with debounced URL-synced filters, paginated/infinite-scrolling leaderboard table, user-rank sidebar, and multiple UI subcomponents (rank/tier/streak badges). Handles loading, error, empty states, and highlights the current user.

Changes

Cohort / File(s) Summary
Leaderboard Page
app/leaderboard/page.tsx
New client page component implementing filter state (timeframe, tier, tags) synced to URL, debounced updates, useLeaderboard pagination (pageSize 20), error handling, and layout composition (hero header, filters, table, sidebar).
Filters UI
components/leaderboard/leaderboard-filters.tsx
New LeaderboardFilters component: timeframe/tier selects, tag multi-select with popover/command, clear/reset, hasActiveFilters computation, and controlled onFilterChange callback.
Table & Row Components
components/leaderboard/leaderboard-table.tsx, components/leaderboard/rank-badge.tsx
LeaderboardTable: responsive rows, initial skeletons, IntersectionObserver infinite scroll, keyboard-row activation, current-user highlighting. RankBadge: special styling for top-3.
Badge & Indicator Widgets
components/leaderboard/tier-badge.tsx, components/leaderboard/streak-indicator.tsx
TierBadge: styled badge for ReputationTier. StreakIndicator: flame icon with tooltip and accessibility; hides when streak is 0.
Sidebar
components/leaderboard/user-rank-sidebar.tsx
UserRankSidebar uses useUserRank: covers no-user prompt, loading skeleton, error card, empty state, and populated rank card with avatar, RankBadge, TierBadge, metrics, streak, and progress to next tier.
Types
types/leaderboard.ts, types/bounty.ts
ContributorStats gains optional nextTierThreshold? and currentTierPoints?; types/bounty.ts had comment block removal (no signature changes).

Sequence Diagram

sequenceDiagram
    participant User
    participant Page as LeaderboardPage
    participant Router as NextRouter
    participant Filters as LeaderboardFilters
    participant Hook as useLeaderboard
    participant Table as LeaderboardTable
    participant Sidebar as UserRankSidebar

    User->>Filters: change filters
    Filters->>Page: onFilterChange(newFilters)
    Page->>Router: router.replace(newURL, { scroll: false })
    Router-->>Page: URL updated
    Page->>Hook: fetch with debounced filters
    Hook-->>Page: entries + pagination state
    Page->>Table: render entries + pagination props
    User->>Table: scroll -> sentinel intersects
    Table->>Hook: fetchNextPage()
    Hook-->>Table: next page entries
    Page->>Sidebar: provide currentUserId
    Sidebar-->>User: display user rank & metrics
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I hopped in code to craft a board so bright,
Filters whisper, rows page through the night,
Badges gleam and streaks give a cheer,
Sidebar hums your rank so near,
Hop, hop—celebrate the sight! 🏆

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: introducing a leaderboard page with filtering, table, and user rank sidebar components.
Linked Issues check ✅ Passed The PR implements all primary objectives from issue #58: leaderboard page with table, filters (timeframe/tier/tags), user rank sidebar, infinite scroll, rank badges, tier badges, streak indicator, and URL-synced filter state.
Out of Scope Changes check ✅ Passed All changes are directly related to issue #58 requirements. The only tangential change is removing obsolete comments from types/bounty.ts, which is a minor cleanup.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@app/leaderboard/page.tsx`:
- Around line 15-23: Validate and sanitize URL query params before using them to
initialize state: instead of directly casting searchParams.get("timeframe") and
searchParams.get("tier"), check that the returned timeframe value is one of the
allowed FiltersType["timeframe"] options and that the tier value matches a valid
ReputationTier; if validation fails, fall back to the defaults ("ALL_TIME" for
timeframe and undefined for tier). Update the initialization logic around
initialTimeframe and initialTier (the variables used to build the FiltersType
passed to useState and ultimately consumed by useLeaderboard) to perform these
checks (or call a small helper like isValidTimeframe/isValidTier) so only
validated values are stored in filters.
- Around line 29-35: The leaderboard hook's error state isn't handled: update
the call to useLeaderboard in page.tsx to extract the error / isError values
(e.g., add error and/or isError from useLeaderboard) and then either render a
concise error UI in the leaderboard component (showing a retry button that calls
fetchNextPage or a refresh action) or rethrow the error to let an ErrorBoundary
handle it; adjust conditional rendering around data, isLoading and hasNextPage
to prefer showing the error UI when isError or error is present and ensure
fetchNextPage remains available for retries.
- Around line 29-44: Filter changes currently trigger immediate fetches and URL
updates; introduce a debounced filter state (e.g., debouncedFilters via
useState) and update it from filters with a useEffect that sets a timeout
(300–500ms) and clears it on cleanup so rapid changes coalesce; then pass
debouncedFilters to useLeaderboard and use the same debouncedFilters when
calling router.replace inside the other useEffect (or combine into a single
effect) so both data fetching (useLeaderboard) and URL sync only occur after the
debounce delay; keep the original filters state as the immediate UI source and
reference the unique symbols useLeaderboard, filters, debouncedFilters,
useEffect, and router.replace when making the change.

In `@components/leaderboard/leaderboard-filters.tsx`:
- Around line 52-101: Add a multi-select tags control bound to filters.tags and
the existing updateFilter handler: introduce a Tags selector (e.g., a Select or
TagMultiSelect component) that uses value={filters.tags} (or value={filters.tags
|| []}) and calls onValueChange={(vals) => updateFilter("tags", vals)} so
selecting/deselecting tags updates filters.tags; ensure the selector is included
alongside the Timeframe and Tier selects and that clearFilters still clears
filters.tags.

In `@components/leaderboard/leaderboard-table.tsx`:
- Around line 87-148: The table rows are not keyboard-focusable; update the
TableRow (the element with key={entry.contributor.id}) to be tabbable and
accessible by adding tabIndex={0}, role="row", and a keyboard handler that
mirrors click behavior (handle Enter and Space to invoke the same row action).
Add focus-visible styles to the className (e.g., via cn(...) include
"focus:outline-none focus:ring-2 focus:ring-primary" or similar) so focused rows
are visible, and ensure the onKeyDown calls the same onRowClick/onSelectRow
handler you use (or create one) so keyboard and mouse interactions behave
identically.
- Around line 110-128: The mobile and desktop tag renderers are inconsistent:
change both uses of entry.contributor.topTags so they only render the top 3 tags
(replace slice(0, 1) with slice(0, 3) for the mobile tags and limit the desktop
Badge map with slice(0, 3)); alternatively extract a small constant (e.g.,
TOP_TAG_COUNT = 3) and use topTags.slice(0, TOP_TAG_COUNT) in both the mobile
div that currently maps tags and the desktop <Badge> mapping to ensure
consistent “top 3” behavior.
- Around line 39-56: The IntersectionObserver callback in the useEffect can
trigger overlapping fetches; update it to check that a next page exists and that
a fetch isn't already in progress by adding a guard: only call onLoadMore() when
entries[0].isIntersecting && hasNextPage && !isFetchingNextPage. Also include
isFetchingNextPage in the useEffect dependency array so the observer respects
the current fetch state; keep observing loadMoreRef and disconnecting the
observer as-is.

In `@components/leaderboard/streak-indicator.tsx`:
- Around line 14-23: The TooltipTrigger currently wraps a non-focusable div so
keyboard users can't open the tooltip; replace that wrapper with a focusable
element by using TooltipTrigger asChild around a button (or add tabIndex and
role) and preserve the existing className and children (Flame and
<span>{streak}</span>), ensure the button has type="button" to avoid form
submits, and add an accessible name (aria-label or aria-describedby pointing to
the TooltipContent) so screen readers can announce the trigger; update
references to TooltipTrigger, TooltipContent, Flame, className, and streak
accordingly.

In `@components/leaderboard/user-rank-sidebar.tsx`:
- Around line 15-49: The component UserRankSidebar currently treats any missing
data as "Rank not found"; update it to handle an explicit error state from the
useUserRank hook: read the error (e.g., const { data, isLoading, error } =
useUserRank(userId)), add a conditional branch that renders a clear error UI
when error is truthy (showing a user-friendly message like "Failed to load rank"
and optionally include error.message or a retry action), keep the existing "Rank
not found" branch for the case where !data && !error, and ensure isLoading still
short-circuits as before.

… error handling for leaderboard data and UI.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@components/leaderboard/user-rank-sidebar.tsx`:
- Around line 90-131: The sidebar is missing the "progress to next tier"
indicator; add a visual progress (percent or bar) driven by contributor tier
fields and render it in user-rank-sidebar (e.g., inside the Details Grid or
below the StreakIndicator). If the payload already has
contributor.stats.nextTierThreshold and contributor.stats.currentTierPoints
compute percent = (currentTierPoints / nextTierThreshold)*100 and render a
progress bar + percent text; if those fields are not present, extend the
Contributor type/interface and the API response to include nextTierThreshold (or
nextTierPoints) and currentTierPoints, update any props passed into the
component, and then use that data to display the progress indicator. Ensure the
UI element is accessible and uses the same styling conventions as existing
metric blocks (reference contributor.stats, StreakIndicator, and the surrounding
grid).
🧹 Nitpick comments (1)
components/leaderboard/leaderboard-filters.tsx (1)

54-60: Make the tags list injectable instead of hard‑coded.
This keeps the component reusable when real tag data comes from the API.

♻️ Proposed refactor
 interface LeaderboardFiltersProps {
     filters: FiltersType;
     onFilterChange: (filters: FiltersType) => void;
+    availableTags?: string[];
 }
@@
-export function LeaderboardFilters({ filters, onFilterChange }: LeaderboardFiltersProps) {
+export function LeaderboardFilters({
+    filters,
+    onFilterChange,
+    availableTags = AVAILABLE_TAGS,
+}: LeaderboardFiltersProps) {
@@
-                                {AVAILABLE_TAGS.map((tag) => {
+                                {availableTags.map((tag) => {

Also applies to: 158-178

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@app/leaderboard/page.tsx`:
- Line 31: The current initialization of initialTags uses
searchParams.get("tags")?.split(",") which yields [""] when the query param is
an empty string; update the logic around searchParams.get("tags") (the
initialTags expression) to treat an empty string as no tags by checking the raw
value first (e.g., get the raw tagsParam via searchParams.get("tags"), and set
initialTags = [] if tagsParam is null or empty, otherwise split tagsParam by
","), ensuring you also trim or ignore empty segments if needed.

In `@components/leaderboard/leaderboard-table.tsx`:
- Line 117: Guard against empty displayName in the AvatarFallback by computing a
safe first-character fallback instead of using entry.contributor.displayName[0];
update the AvatarFallback usage (referencing AvatarFallback and
entry.contributor.displayName) to use a null/empty-safe expression such as
reading displayName via .charAt(0) or optional chaining and falling back to a
default character (e.g. '?') when displayName is undefined or an empty string.

In `@components/leaderboard/user-rank-sidebar.tsx`:
- Line 77: Guard against empty or missing contributor.displayName when rendering
the initial in AvatarFallback: update the code that uses
contributor.displayName[0] (in the AvatarFallback render inside
user-rank-sidebar.tsx) to safely derive an initial—e.g., compute a
single-character fallback by checking contributor.displayName for truthy length
and using contributor.displayName.charAt(0) or contributor.displayName?.[0] with
a fallback like '?' or the contributor's username; ensure the change is applied
where AvatarFallback is rendered so it never receives undefined.
🧹 Nitpick comments (4)
components/leaderboard/streak-indicator.tsx (1)

14-14: Consider lifting TooltipProvider to a parent component.

Each StreakIndicator instance creates its own TooltipProvider. When rendering many rows in the leaderboard table, this adds unnecessary component overhead. If a single TooltipProvider exists higher in the tree (e.g., at the app layout level), you can remove it here.

app/leaderboard/page.tsx (1)

74-74: Minor: URL includes trailing ? when no filters are active.

When params is empty, router.replace navigates to /leaderboard? instead of /leaderboard. This is cosmetic but could be cleaner.

Proposed fix
-        router.replace(`/leaderboard?${params.toString()}`, { scroll: false });
+        const queryString = params.toString();
+        router.replace(queryString ? `/leaderboard?${queryString}` : '/leaderboard', { scroll: false });
components/leaderboard/leaderboard-table.tsx (1)

42-49: Variable shadowing: entries shadows the component prop.

The IntersectionObserver callback parameter entries shadows the component's entries prop. While this doesn't cause a bug here (the callback only uses entries[0]), it can lead to confusion and accidental misuse.

Proposed fix
     useEffect(() => {
         const observer = new IntersectionObserver(
-            (entries) => {
-                if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
+            (observerEntries) => {
+                if (observerEntries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
                     onLoadMore();
                 }
             },
components/leaderboard/user-rank-sidebar.tsx (1)

135-154: Consider extracting the progress calculation to a named function.

The IIFE pattern works but reduces readability. Extracting this logic would make the component easier to follow and test.

Proposed refactor
+function TierProgress({ contributor }: { contributor: LeaderboardContributor }) {
+    // TODO: remove mock values once API provides them
+    const currentPoints = contributor.stats.currentTierPoints ?? contributor.totalScore;
+    const nextThreshold = contributor.stats.nextTierThreshold ?? (contributor.totalScore * 1.5);
+
+    if (nextThreshold <= 0) return null;
+
+    const progressPercent = Math.min(100, Math.max(0, (currentPoints / nextThreshold) * 100));
+    return (
+        <div className="space-y-2">
+            <div className="flex justify-between text-xs">
+                <span className="text-muted-foreground font-medium uppercase">Progress to Next Tier</span>
+                <span className="text-white font-mono">{Math.round(progressPercent)}%</span>
+            </div>
+            <Progress value={progressPercent} className="h-2" />
+        </div>
+    );
+}

Then replace the IIFE with <TierProgress contributor={contributor} />.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build Leaderboard Page with Table, Filters, and User Rank Display

2 participants