Leaderboard Project Plan
Goal
Add an authenticated /leaderboard page that ranks Lifehub users against each other across four views β Activity, This Month, All-Time, and Hall of Fame β using XP already accumulated through the existing gamification system. The page must feel competitive and alive while remaining performant against a multi-user dataset and respecting the privacy of users who don't want to be ranked.
Inspiration
Visual and structural reference: the loggd.life leaderboard (https://loggd.life/@demo/leaderboard). We mirror its tab structure, podium presentation, "Your Position" callout, monthly close countdown, rank-delta chips, and Hall of Fame podium-of-the-month layout. We adapt the data model and queries to Lifehub's existing GamificationProfile and XpTransaction tables instead of building anything new.
User Experience
The first screen should answer four questions:
- Where do I rank against everyone right now?
- How is the community trending today?
- Did I move up or down this month?
- Who has dominated past months?
The page is dark-first, dense, and gamified. Top-three users appear on a podium with crown/silver/bronze accents. Ranked rows are compact, scannable, and link to public profiles where allowed.
Route
get "leaderboard", to: "leaderboard#show"
This is a new top-level page, not a replacement for gamification#show, which remains the single-user achievement detail page.
Tabs
The page is a single controller action with a ?tab= query param. Default tab is this_month.
Activity
Reverse-chronological feed of XP-earning events from all opted-in users, grouped by Today / Yesterday / This Week / Earlier. Each row shows the user's avatar, @username, the action ("unlocked the 7-Day Flame badge", "reached Level 12 (Devoted)", "completed a 1h 30m focus session"), and a relative timestamp.
Sources combined chronologically:
XpTransactionrows wherecreated_at >= 30 days ago(action descriptions for habit / finance / focus / tools categories).UserAchievementrows (badge unlocks).- Implicit "reached Level X" events derived from
GamificationProfile.levelupdates (we don't store level history yet β see Open Decisions for whether to log them).
This Month
Sum of XpTransaction.amount per user, grouped by user, where created_at >= Time.current.beginning_of_month, ordered descending. The page renders:
- Podium of the top three users (avatar,
@username, level,pts). - "Your Position" callout β current rank, percentile, total points this month, and a
deltachip vs prior-month rank (+3,-1,NEW). - Countdown timer to month end ("Closes 25d 03:25:34").
- Ranked list of all opted-in users beneath, paginated 25 per page.
- Each row shows position, avatar,
@username, level, points, and rank-delta.
All-Time
GamificationProfile.top_by_xp extended with pagination. Same layout as This Month minus the countdown and rank-delta (delta is meaningless for cumulative-XP rankings).
Hall of Fame
Carousel of past months as chip tabs ("April 2026", "March 2026", "February 2026", "January 2026"). For each month, render the three top earners as a podium with the same avatar/level/points presentation, plus seven honorable mentions (#4β#10) as small cards underneath.
This data is also computed from XpTransaction aggregations; for performance, we cache the result via Rails.cache.fetch(["leaderboard", :hall_of_fame, year, month]). Months never change, so the cache TTL is 30 days and it is invalidated only when a back-dated transaction is created (rare).
Identity & Privacy
Two new columns on users:
username(string, unique, indexed) β slugified handle for/@:usernameprofile URLs and leaderboard rows.leaderboard_visible(boolean, defaulttrue) β opt-out toggle exposed on the settings page. Users withleaderboard_visible = falseare excluded from rank queries entirely (their avatar/handle never appears in another user's leaderboard) but the user still sees their own progression in "Your Position".
Backfill migration:
- For each existing user, derive
usernamefromdisplay_name(lowercased, dashed, deduped with a numeric suffix on collision).
/@:username opens a public profile page (level, total XP, public Vision sections, optional bio). The profile page itself is part of the Vision and Leaderboard MVPs but only reaches MVP polish here β it can ship with just the avatar, level, total XP, and recent badges. Public Vision sections come for free once Vision ships.
Architecture
app/controllers/leaderboard_controller.rb
app/controllers/users/profiles_controller.rb # /@:username
app/models/leaderboard/snapshot.rb # tab-agnostic facade
app/models/leaderboard/this_month_query.rb
app/models/leaderboard/all_time_query.rb
app/models/leaderboard/activity_feed.rb
app/models/leaderboard/hall_of_fame.rb
app/policies/leaderboard_policy.rb
app/views/leaderboard/show.html.erb
app/views/leaderboard/_tabs.html.erb
app/views/leaderboard/_podium.html.erb
app/views/leaderboard/_ranked_list.html.erb
app/views/leaderboard/_your_position.html.erb
app/views/leaderboard/_activity_feed.html.erb
app/views/leaderboard/_hall_of_fame.html.erb
app/javascript/controllers/month_countdown_controller.js
LeaderboardController#show stays thin:
class LeaderboardController < ApplicationController
def show
authorize :leaderboard
@snapshot = Leaderboard::Snapshot.new(current_user, tab: params[:tab], page: params[:page])
end
end
Leaderboard::Snapshot is a small dispatcher: it picks the right query/feed object based on tab and exposes uniform reader methods (top_three, ranked_rows, your_position, activity_groups, hall_of_fame_months).
Each query object encapsulates a single SQL pattern. They never reach across tabs to share state, which keeps each one trivial to test in isolation.
Data Sources
XpTransaction: backbone for time-scoped queries. The existing(user_id, created_at)index makes month-bounded sums fast. We add a(created_at)index covering global feeds.GamificationProfile:total_xp,level,level_title. Already hastop_by_xpandtop_by_levelscopes.UserAchievement+Achievement: badge unlocks for the activity feed.User:username,display_name,avatar,leaderboard_visible.
Authorization
class LeaderboardPolicy < ApplicationPolicy
def show? = user.present?
end
The page is behind authenticate_user!. Other users' XP and identity are sensitive enough that we don't expose them pre-login. Public profile pages (/@:username) are accessible without auth but only render data the profile owner has marked public.
Performance
- All four tabs run in O(N log N) over rows scoped by
leaderboard_visible = true, paginated to 25 rows per page via Pagy. - Activity feed limited to 30 days and 100 events per page.
- Hall of Fame months cached for 30 days via
solid_cache. - Top-three for This Month / All-Time cached for 60 seconds β the podium is read far more often than ranks shift.
- All queries
selectonly the columns the views consume to avoid loading user blobs. - Eager-load
gamification_profileandavatar_attachmenton user rows to prevent N+1.
Security & Privacy
- Pundit gating on
show?. - Public profile pages strictly render only
username,level,total_xp, public Vision sections, and recent achievements.email,display_name,finance_settings, etc. are never exposed. leaderboard_visible = falseusers are excluded from every cross-user query (verified by tests).usernamevalidated against[a-z0-9_-]{3,30}and uniqueness, immutable after creation by default (toggle with admin-only override).- Rate limit the leaderboard page to 60 requests/min/user via Rack::Attack to prevent scraping.
Visual Style
Dark-first, matching the existing dashboard and gamification palette (background near #0d1117):
- Tab strip horizontally across the top, current tab underlined.
- Podium: three avatars on a small platform; #1 elevated, with a crown/glow accent. #2/#3 use silver and bronze chips.
- Ranked list rows:
64pxtall, avatar left,@username+ level metadata center, points right, rank-delta chip far right. - Rank-delta colors:
+Ngreen,-Nred,NEWamber,βgray. - "Your Position" pinned card sticks above the ranked list with a slight emerald accent ("you").
- Countdown display: monospaced, top-right of the This Month tab.
- Hall of Fame podium reuses the same podium component; honorable mentions are rendered as 7 small avatar cards in a single row.
Mobile layout: tabs become a horizontal scroll strip; podium stacks vertically; ranked list shrinks to two-line rows.
Internationalization
config/locales/en.yml and config/locales/pt-BR.yml:
leaderboard.titleleaderboard.subtitleleaderboard.tabs.activity / this_month / all_time / hall_of_fameleaderboard.your_position,top_n_percent,playersleaderboard.month_closes_inleaderboard.empty_state- Activity event templates (one per source category):
leaderboard.events.habit_completionleaderboard.events.focus_sessionleaderboard.events.level_upleaderboard.events.badge_unlock
Testing Plan
- Authenticated user can access
/leaderboard; unauthenticated redirects. - Each tab returns
:successand renders the expected partial. leaderboard_visible = falseusers never appear in any cross-user query but always see their own "Your Position".- This Month query sums only the current month's transactions and excludes opted-out users.
- All-Time query orders by
total_xpdesc with stable tie-breaking onlevelthenid. - Hall of Fame returns the right top-3 + 7 mentions for a given year/month.
- Activity feed groups events by Today/Yesterday/This Week and joins
User+Achievementwithout N+1 (assert with Bullet). - Username validation rejects bad characters, uniqueness collisions, and reserved words.
- Public profile renders only public Vision sections.
- Rack::Attack throttle returns 429 after configured threshold.
bin/rails test test/controllers/leaderboard_controller_test.rb
bin/rails test test/models/leaderboard
bin/ci
Implementation Phases
Phase 1 β Identity & Privacy
- Add
usernameandleaderboard_visiblecolumns tousers. - Backfill
usernamefromdisplay_name. - Expose
leaderboard_visibleon the settings page. - Add
/@:usernameroute +Users::ProfilesController#show(minimal β just level/avatar; full profile rendering ships with Vision).
Phase 2 β Tab Skeleton & This Month
- Add
/leaderboardroute, controller, policy. - Build
Leaderboard::SnapshotandLeaderboard::ThisMonthQuery. - Build the podium + ranked list + "Your Position" partials.
- Add the countdown Stimulus controller.
Phase 3 β All-Time & Hall of Fame
- Build
Leaderboard::AllTimeQueryandLeaderboard::HallOfFame. - Wire
solid_cachefor the Hall of Fame months.
Phase 4 β Activity Feed
- Build
Leaderboard::ActivityFeedcombiningXpTransaction+UserAchievement+ level-up events. - Group events by date bucket; render the feed partial.
Phase 5 β Polish, Security, Performance
- Add
(created_at)index onxp_transactions. - Configure Rack::Attack throttle.
- Tune cache keys and partial sizes.
- Add Bullet assertions to N+1-sensitive tests.
- Run
bin/ci.
Open Decisions
- Whether to record level-up events as their own table or derive them on the fly from
XpTransactiondeltas. Initial plan: derive them; only persist if rendering becomes too slow (>200ms p95). - Whether
usernameshould be user-editable post-signup. Initial plan: editable once via the settings page, then immutable until admin override. Reduces handle squatting and broken/@old-handlelinks. - Whether to ship
/@:usernamewith a full Vision-aware profile in this milestone or leave it minimal. Initial plan: minimal here; Vision plan extends it with public sections.