feat: introduce leaderboard page with filtering, table, and user rank#62
Conversation
… display components.
📝 WalkthroughWalkthroughAdds 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
…te leaderboard text colors.
There was a problem hiding this comment.
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 liftingTooltipProviderto a parent component.Each
StreakIndicatorinstance creates its ownTooltipProvider. When rendering many rows in the leaderboard table, this adds unnecessary component overhead. If a singleTooltipProviderexists 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
paramsis empty,router.replacenavigates 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:entriesshadows the component prop.The IntersectionObserver callback parameter
entriesshadows the component'sentriesprop. While this doesn't cause a bug here (the callback only usesentries[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} />.
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
Bountytype to remove obsolete comments.Leaderboard Page and UI Components:
LeaderboardPage(app/leaderboard/page.tsx) that fetches leaderboard data, manages filters via URL, and displays the leaderboard table and user rank sidebar.LeaderboardTablewith infinite scroll, loading skeletons, and highlighting for the current user. Includes rank, contributor info, tier, score, completed, earnings, and streak columns.LeaderboardFilterscomponent for filtering by timeframe and tier, with a clear filters button.RankBadgefor rank display,TierBadgefor contributor tier, andStreakIndicatorfor streaks with tooltip. [1] [2] [3]UserRankSidebarto show the current user's rank, avatar, tier, and stats with loading and empty states.Type and Code Cleanup:
Bountytype definition, keepingrequirementsandscopeas optional fields for backward compatibility.closes #58
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.