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:

  • XpTransaction rows where created_at >= 30 days ago (action descriptions for habit / finance / focus / tools categories).
  • UserAchievement rows (badge unlocks).
  • Implicit "reached Level X" events derived from GamificationProfile.level updates (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 delta chip 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 /@:username profile URLs and leaderboard rows.
  • leaderboard_visible (boolean, default true) β€” opt-out toggle exposed on the settings page. Users with leaderboard_visible = false are 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 username from display_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 has top_by_xp and top_by_level scopes.
  • 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 select only the columns the views consume to avoid loading user blobs.
  • Eager-load gamification_profile and avatar_attachment on 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 = false users are excluded from every cross-user query (verified by tests).
  • username validated 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: 64px tall, avatar left, @username + level metadata center, points right, rank-delta chip far right.
  • Rank-delta colors: +N green, -N red, NEW amber, β€” 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.title
  • leaderboard.subtitle
  • leaderboard.tabs.activity / this_month / all_time / hall_of_fame
  • leaderboard.your_position, top_n_percent, players
  • leaderboard.month_closes_in
  • leaderboard.empty_state
  • Activity event templates (one per source category):
    • leaderboard.events.habit_completion
    • leaderboard.events.focus_session
    • leaderboard.events.level_up
    • leaderboard.events.badge_unlock

Testing Plan

  • Authenticated user can access /leaderboard; unauthenticated redirects.
  • Each tab returns :success and renders the expected partial.
  • leaderboard_visible = false users 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_xp desc with stable tie-breaking on level then id.
  • 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+Achievement without 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 username and leaderboard_visible columns to users.
  • Backfill username from display_name.
  • Expose leaderboard_visible on the settings page.
  • Add /@:username route + Users::ProfilesController#show (minimal β€” just level/avatar; full profile rendering ships with Vision).

Phase 2 β€” Tab Skeleton & This Month

  • Add /leaderboard route, controller, policy.
  • Build Leaderboard::Snapshot and Leaderboard::ThisMonthQuery.
  • Build the podium + ranked list + "Your Position" partials.
  • Add the countdown Stimulus controller.

Phase 3 β€” All-Time & Hall of Fame

  • Build Leaderboard::AllTimeQuery and Leaderboard::HallOfFame.
  • Wire solid_cache for the Hall of Fame months.

Phase 4 β€” Activity Feed

  • Build Leaderboard::ActivityFeed combining XpTransaction + UserAchievement + level-up events.
  • Group events by date bucket; render the feed partial.

Phase 5 β€” Polish, Security, Performance

  • Add (created_at) index on xp_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 XpTransaction deltas. Initial plan: derive them; only persist if rendering becomes too slow (>200ms p95).
  • Whether username should be user-editable post-signup. Initial plan: editable once via the settings page, then immutable until admin override. Reduces handle squatting and broken /@old-handle links.
  • Whether to ship /@:username with a full Vision-aware profile in this milestone or leave it minimal. Initial plan: minimal here; Vision plan extends it with public sections.