Leaderboard Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship /leaderboard with four tabs (Activity / This Month / All-Time / Hall of Fame) backed by existing XpTransaction and GamificationProfile data, plus the username + leaderboard_visible identity additions required to render /@:username rows safely.

Architecture: Add a thin LeaderboardController, a Leaderboard::Snapshot facade, four namespaced query objects (ThisMonthQuery, AllTimeQuery, ActivityFeed, HallOfFame), one LeaderboardPolicy, and one Stimulus countdown controller. Reuse existing models entirely — no new tables for ranking.

Tech Stack: Rails 8.1, Pundit, Pagy, solid_cache, Rack::Attack, Minitest fixtures, Turbo-compatible ERB, Tailwind CSS v4.

Cross-cutting principles enforced in every task:

  • DRY: top-three / podium / rank-row partials are reused across This Month / All-Time / Hall of Fame.
  • Performance: every cross-user query selects only required columns, uses (user_id, created_at) and the new (created_at) index, eager-loads avatars, and is paginated. Hall-of-Fame months and top-three slots are cached via solid_cache.
  • Security: Pundit on every action, allowlisted username regex, Rack::Attack throttle, public profiles render only opted-in fields, leaderboard_visible = false users excluded from every cross-user query.

File Structure

  • Modify: db/schema.rb (via migrations)
    • Add username, leaderboard_visible to users.
    • Add (created_at) index to xp_transactions.
  • Modify: app/models/user.rb
    • validates :username, before_validation :assign_username, scope :leaderboard_visible.
  • Create: db/migrate/<ts>_add_username_to_users.rb
  • Create: db/migrate/<ts>_backfill_user_usernames.rb
  • Create: db/migrate/<ts>_add_leaderboard_visible_to_users.rb
  • Create: db/migrate/<ts>_add_index_to_xp_transactions_created_at.rb
  • Modify: app/models/user.rb
  • Create: app/controllers/leaderboard_controller.rb
  • Create: app/controllers/users/profiles_controller.rb
  • Create: app/policies/leaderboard_policy.rb
  • Create: app/policies/users/profile_policy.rb
  • Create: app/models/leaderboard/snapshot.rb
  • Create: app/models/leaderboard/this_month_query.rb
  • Create: app/models/leaderboard/all_time_query.rb
  • Create: app/models/leaderboard/activity_feed.rb
  • Create: app/models/leaderboard/hall_of_fame.rb
  • Create: app/views/leaderboard/show.html.erb
  • Create: app/views/leaderboard/_tabs.html.erb
  • Create: app/views/leaderboard/_podium.html.erb
  • Create: app/views/leaderboard/_rank_row.html.erb
  • Create: app/views/leaderboard/_ranked_list.html.erb
  • Create: app/views/leaderboard/_your_position.html.erb
  • Create: app/views/leaderboard/_activity_feed.html.erb
  • Create: app/views/leaderboard/_hall_of_fame.html.erb
  • Create: app/views/users/profiles/show.html.erb
  • Create: app/javascript/controllers/month_countdown_controller.js
  • Modify: config/routes.rb
  • Modify: config/initializers/rack_attack.rb (create if missing)
  • Modify: app/views/shared/_sidebar.html.erb
  • Modify: app/views/shared/_navbar.html.erb
  • Modify: app/views/shared/_footer_nav.html.erb
  • Modify: app/views/finance_settings/show.html.erb (or settings shell) — toggle for leaderboard_visible.
  • Modify: app/controllers/finance_settings_controller.rb
  • Modify: config/locales/en.yml
  • Modify: config/locales/pt-BR.yml
  • Create: test/controllers/leaderboard_controller_test.rb
  • Create: test/controllers/users/profiles_controller_test.rb
  • Create: test/models/leaderboard/this_month_query_test.rb
  • Create: test/models/leaderboard/all_time_query_test.rb
  • Create: test/models/leaderboard/activity_feed_test.rb
  • Create: test/models/leaderboard/hall_of_fame_test.rb
  • Modify: test/fixtures/users.yml
  • Modify: test/fixtures/xp_transactions.yml

Task 1: Add Username & Leaderboard-Visible Columns to Users

Files:

  • Create: db/migrate/<ts>_add_username_to_users.rb
  • Create: db/migrate/<ts>_backfill_user_usernames.rb
  • Create: db/migrate/<ts>_add_leaderboard_visible_to_users.rb
  • Modify: app/models/user.rb
  • Modify: test/fixtures/users.yml

  • Step 1: Generate migrations
bin/rails g migration add_username_to_users username:string
bin/rails g migration backfill_user_usernames
bin/rails g migration add_leaderboard_visible_to_users leaderboard_visible:boolean
  • Step 2: Edit add_username_to_users migration
class AddUsernameToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :username, :string
    add_index  :users, :username, unique: true, where: "username IS NOT NULL"
  end
end

Index is partial so backfill can run lazily without violating uniqueness on NULL.

  • Step 3: Edit backfill_user_usernames migration
class BackfillUserUsernames < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!

  def up
    User.reset_column_information
    User.where(username: nil).find_each(batch_size: 200) do |user|
      base = user.display_name.to_s.parameterize.presence || "user"
      candidate = base
      suffix = 0
      while User.exists?(username: candidate)
        suffix += 1
        candidate = "#{base}-#{suffix}"
      end
      user.update_columns(username: candidate)
    end
    change_column_null :users, :username, false
  end

  def down
    change_column_null :users, :username, true
  end
end
  • Step 4: Edit add_leaderboard_visible_to_users migration
class AddLeaderboardVisibleToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :leaderboard_visible, :boolean, default: true, null: false
    add_index  :users, :leaderboard_visible
  end
end
  • Step 5: Run migrations
bin/rails db:migrate

Expected: users table has both columns; backfill assigns usernames; the partial unique index exists.

  • Step 6: Update User model

In app/models/user.rb, add:

USERNAME_REGEX = /\A[a-z0-9][a-z0-9_-]{2,29}\z/

validates :username,
          presence: true,
          uniqueness: { case_sensitive: false },
          format: { with: USERNAME_REGEX }

before_validation :assign_username, on: :create

scope :leaderboard_visible, -> { where(leaderboard_visible: true) }

def to_param = username

private

def assign_username
  return if username.present?

  base = display_name.to_s.parameterize.presence || "user"
  candidate = base
  suffix = 0
  while self.class.where(username: candidate).where.not(id: id).exists?
    suffix += 1
    candidate = "#{base}-#{suffix}"
  end
  self.username = candidate
end
  • Step 7: Update fixtures

Add username: and leaderboard_visible: true to every user fixture in test/fixtures/users.yml. Use stable handles (e.g., admin, member-one, member-two).

Add three new fixtures used by leaderboard tests in later tasks:

member_one:
  email: [email protected]
  encrypted_password: <%= User.new.send(:password_digest, "password123") %>
  display_name: Member One
  username: member-one
  leaderboard_visible: true
  confirmed_at: <%= Time.current.iso8601 %>
  finance_settings: {}

member_two:
  email: [email protected]
  encrypted_password: <%= User.new.send(:password_digest, "password123") %>
  display_name: Member Two
  username: member-two
  leaderboard_visible: true
  confirmed_at: <%= Time.current.iso8601 %>
  finance_settings: {}

opted_out:
  email: [email protected]
  encrypted_password: <%= User.new.send(:password_digest, "password123") %>
  display_name: Opted Out
  username: opted-out
  leaderboard_visible: false
  confirmed_at: <%= Time.current.iso8601 %>
  finance_settings: {}

Also add matching gamification_profiles fixtures (member_one_profile, member_two_profile, opted_out_profile) keyed by user, with sensible non-zero total_xp and level values.

  • Step 8: Run user model tests
bin/rails test test/models/user_test.rb

Expected: GREEN. Fix any fixture-driven failures (existing tests that pass display_name only must still get a username via before_validation).

  • Step 8a: Audit existing URL helpers that take a User

User#to_param = username changes user_path(user) and friends to use the slug instead of the integer id. Run:

grep -RE "user(s)?_(path|url)|edit_user_(path|url)|admin_user_(path|url)" app config | grep -v sessions | grep -v passwords | grep -v registrations

For each match, decide whether the route already constraints the param to an integer (route-level constraint) or expects the slug. If existing admin/users routes need integers, add constraints: { id: /\d+/ } to those routes so the slug-aware to_param doesn't poison admin lookups.

  • Step 9: Add specific tests for the new behavior

In test/models/user_test.rb, add:

test "username is assigned from display_name on create" do
  user = User.create!(email: "[email protected]", password: "password123",
                      password_confirmation: "password123",
                      display_name: "Ada Lovelace", confirmed_at: Time.current,
                      finance_settings: {})

  assert_equal "ada-lovelace", user.username
end

test "username collision adds numeric suffix" do
  User.create!(email: "[email protected]", password: "password123",
               password_confirmation: "password123",
               display_name: "Ada", confirmed_at: Time.current,
               finance_settings: {})

  user = User.create!(email: "[email protected]", password: "password123",
                      password_confirmation: "password123",
                      display_name: "Ada", confirmed_at: Time.current,
                      finance_settings: {})

  assert_equal "ada-1", user.username
end

test "username rejects bad characters" do
  user = User.new(email: "[email protected]", password: "password123",
                  password_confirmation: "password123", username: "BAD HANDLE",
                  display_name: "x", confirmed_at: Time.current, finance_settings: {})

  refute user.valid?
  assert_includes user.errors[:username], "is invalid"
end

test "leaderboard_visible scope filters opted-out users" do
  visible_count = User.leaderboard_visible.count
  users(:admin).update!(leaderboard_visible: false)
  assert_equal visible_count - 1, User.leaderboard_visible.count
end
  • Step 10: Commit
git add db/migrate app/models/user.rb test/fixtures/users.yml test/models/user_test.rb
git commit -m "feat: add username slug and leaderboard_visible flag to users"

Task 2: Add (created_at) Index to xp_transactions

Files:

  • Create: db/migrate/<ts>_add_index_to_xp_transactions_created_at.rb

  • Step 1: Generate migration

bin/rails g migration add_index_to_xp_transactions_created_at
  • Step 2: Edit migration
class AddIndexToXpTransactionsCreatedAt < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!

  def change
    add_index :xp_transactions, :created_at, algorithm: :concurrently
  end
end

This index supports the global activity feed (cross-user, time-bounded) and the Hall-of-Fame month aggregation.

  • Step 3: Run migration & commit
bin/rails db:migrate
git add db/migrate db/schema.rb
git commit -m "perf: index xp_transactions.created_at for leaderboard queries"

Task 3: Add Settings Toggle for Leaderboard Visibility

Files:

  • Modify: app/controllers/finance_settings_controller.rb
  • Modify: app/views/finance_settings/show.html.erb
  • Modify: config/locales/en.yml, pt-BR.yml
  • Modify: test/controllers/finance_settings_controller_test.rb

  • Step 1: Failing controller test

In test/controllers/finance_settings_controller_test.rb, add:

test "user can toggle leaderboard visibility" do
  sign_in users(:admin)
  patch settings_path, params: { user: { leaderboard_visible: "0" } }

  assert_redirected_to settings_path
  assert_not users(:admin).reload.leaderboard_visible?
end
bin/rails test test/controllers/finance_settings_controller_test.rb -n test_user_can_toggle_leaderboard_visibility

Expected: FAIL.

  • Step 2: Update controller to permit the column

In app/controllers/finance_settings_controller.rb#update, allow leaderboard_visible through strong params and persist it on current_user.

def update
  authorize :finance_settings, :update?
  user_params = params.expect(user: %i[leaderboard_visible])
  current_user.update!(user_params)
  redirect_to settings_path, notice: t("finance_settings.updated")
end

If update already exists, merge :leaderboard_visible into the existing permitted attributes; do not collapse other behavior.

  • Step 3: Add toggle UI

In app/views/finance_settings/show.html.erb, add a labeled checkbox bound to current_user.leaderboard_visible with helper copy.

  • Step 4: Translations

Add finance_settings.leaderboard_visible.label / hint to both locales.

  • Step 5: Tests pass
bin/rails test test/controllers/finance_settings_controller_test.rb

Expected: GREEN.

  • Step 6: Commit
git commit -am "feat: settings toggle for leaderboard visibility"

Task 4: Routes, Policy, Controller Skeleton & Access Tests

Files:

  • Modify: config/routes.rb
  • Create: app/controllers/leaderboard_controller.rb
  • Create: app/policies/leaderboard_policy.rb
  • Create: app/views/leaderboard/show.html.erb
  • Create: test/controllers/leaderboard_controller_test.rb

  • Step 1: Failing controller test
require "test_helper"

class LeaderboardControllerTest < ActionDispatch::IntegrationTest
  include Devise::Test::IntegrationHelpers

  test "authenticated user can access leaderboard" do
    sign_in users(:admin)
    get leaderboard_url
    assert_response :success
    assert_select "h1", I18n.t("leaderboard.title")
  end

  test "unauthenticated user is redirected" do
    get leaderboard_url
    assert_redirected_to new_user_session_path
  end

  test "tab param defaults to this_month" do
    sign_in users(:admin)
    get leaderboard_url
    assert_select "[data-tab=this_month][aria-current=page]"
  end

  test "tab param accepts only known values" do
    sign_in users(:admin)
    get leaderboard_url(tab: "definitely-not-real")
    assert_response :success
    assert_select "[data-tab=this_month][aria-current=page]"
  end
end
bin/rails test test/controllers/leaderboard_controller_test.rb

Expected: FAIL.

  • Step 2: Add route

In config/routes.rb, inside authenticated :user do block:

get "leaderboard", to: "leaderboard#show"
  • Step 3: Add policy
# app/policies/leaderboard_policy.rb
class LeaderboardPolicy < ApplicationPolicy
  def show? = user.present?
end
  • Step 4: Add controller skeleton
# app/controllers/leaderboard_controller.rb
class LeaderboardController < ApplicationController
  KNOWN_TABS = %w[activity this_month all_time hall_of_fame].freeze

  def show
    authorize :leaderboard
    tab = KNOWN_TABS.include?(params[:tab]) ? params[:tab] : "this_month"
    @snapshot = Leaderboard::Snapshot.new(current_user, tab:, page: params[:page])
  end
end
  • Step 5: Minimal view
<%# app/views/leaderboard/show.html.erb %>
<h1><%= t("leaderboard.title") %></h1>
<div data-tab="<%= @snapshot.tab %>" aria-current="page"></div>
  • Step 6: Translations
# en.yml under en:
leaderboard:
  title: Leaderboard
  subtitle: See how you rank among the community
  tabs:
    activity: Activity
    this_month: This Month
    all_time: All-Time
    hall_of_fame: Hall of Fame
# pt-BR.yml under pt-BR:
leaderboard:
  title: Leaderboard
  subtitle: Veja como você se posiciona na comunidade
  tabs:
    activity: Atividade
    this_month: Este mês
    all_time: Todos os tempos
    hall_of_fame: Hall da fama
  • Step 7: Snapshot stub so the controller runs

Create the file with a minimal interface so the access tests pass:

# app/models/leaderboard/snapshot.rb
class Leaderboard::Snapshot
  attr_reader :tab

  def initialize(user, tab:, page: nil)
    @user = user
    @tab  = tab
    @page = page
  end
end
  • Step 8: Tests pass
bin/rails test test/controllers/leaderboard_controller_test.rb

Expected: GREEN.

  • Step 9: Commit
git add config/routes.rb app/controllers/leaderboard_controller.rb app/policies/leaderboard_policy.rb \
        app/views/leaderboard/show.html.erb app/models/leaderboard/snapshot.rb \
        config/locales/en.yml config/locales/pt-BR.yml \
        test/controllers/leaderboard_controller_test.rb
git commit -m "feat: leaderboard route and policy"

Task 5: This Month Query

Files:

  • Create: app/models/leaderboard/this_month_query.rb
  • Create: test/models/leaderboard/this_month_query_test.rb
  • Modify: test/fixtures/xp_transactions.yml

  • Step 1: Add fixtures

In test/fixtures/xp_transactions.yml, add at least three users worth of transactions:

  • 3 transactions for users(:admin) this month totaling 200 XP.
  • 2 transactions for users(:member_one) this month totaling 350 XP.
  • 1 transaction for users(:member_two) this month totaling 100 XP.
  • 1 transaction for users(:opted_out) (whose leaderboard_visible: false).
  • 2 transactions dated last month, ignored by this query.

  • Step 2: Failing test
require "test_helper"

class Leaderboard::ThisMonthQueryTest < ActiveSupport::TestCase
  test "ranks users by current-month xp sum descending" do
    rows = Leaderboard::ThisMonthQuery.new.call

    assert_equal users(:member_one).id, rows.first.user_id
    assert_equal 350, rows.first.points
  end

  test "excludes opted-out users" do
    rows = Leaderboard::ThisMonthQuery.new.call
    refute_includes rows.map(&:user_id), users(:opted_out).id
  end

  test "ignores transactions outside the current month" do
    rows = Leaderboard::ThisMonthQuery.new.call
    assert rows.all? { |row| row.points <= row.user.xp_transactions.where(created_at: Time.current.all_month).sum(:amount) }
  end

  test "your_position computes rank, percentile, points for a given user" do
    position = Leaderboard::ThisMonthQuery.new.your_position(users(:admin))

    assert_equal 2, position.rank
    assert_equal 200, position.points
    assert position.percentile.between?(0, 100)
  end

  test "delta_for compares this month vs last month rank" do
    delta = Leaderboard::ThisMonthQuery.new.delta_for(users(:admin))
    assert_kind_of Integer, delta.value if delta.kind == :change
  end
end
bin/rails test test/models/leaderboard/this_month_query_test.rb

Expected: FAIL.

  • Step 3: Implement the query
# app/models/leaderboard/this_month_query.rb
class Leaderboard::ThisMonthQuery
  Row = Struct.new(:user_id, :user, :points, :delta, keyword_init: true)
  Position = Struct.new(:rank, :points, :total_players, :percentile, keyword_init: true)
  Delta = Struct.new(:kind, :value, keyword_init: true)

  def initialize(time: Time.current)
    @range      = time.all_month
    @prev_range = (time - 1.month).all_month
  end

  def call(limit: 25, offset: 0)
    sums = aggregate(@range)
    user_ids = sums.keys
    users    = User.leaderboard_visible.where(id: user_ids).includes(:gamification_profile, avatar_attachment: :blob).index_by(&:id)
    prev_ranks = ranks_for(aggregate(@prev_range))

    rows = user_ids
      .map { |id| [id, sums[id]] }
      .sort_by { |id, points| [-points, id] }
      .map.with_index(1) do |(id, points), rank|
        next unless users[id]
        Row.new(user_id: id, user: users[id], points: points, delta: compute_delta(rank, prev_ranks[id]))
      end
      .compact

    rows.drop(offset).first(limit)
  end

  def top_three(cache: true)
    if cache
      Rails.cache.fetch(["leaderboard", :this_month_top3, @range.first.to_date], expires_in: 60) { call(limit: 3) }
    else
      call(limit: 3)
    end
  end

  def your_position(user)
    sums = aggregate(@range)
    return Position.new(rank: 0, points: 0, total_players: 0, percentile: 0) unless sums[user.id]

    sorted = sums.sort_by { |id, points| [-points, id] }
    rank   = sorted.index { |id, _| id == user.id }.to_i + 1
    total  = sorted.size
    percentile = total.zero? ? 0 : ((total - rank + 1).to_f / total * 100).round
    Position.new(rank:, points: sums[user.id], total_players: total, percentile:)
  end

  def delta_for(user)
    compute_delta(your_position(user).rank, ranks_for(aggregate(@prev_range))[user.id])
  end

  private

  def aggregate(range)
    XpTransaction
      .joins(:user)
      .where(users: { leaderboard_visible: true })
      .where(created_at: range)
      .group(:user_id)
      .sum(:amount)
  end

  def ranks_for(sums)
    sums.sort_by { |id, points| [-points, id] }.each_with_index.to_h { |(id, _), idx| [id, idx + 1] }
  end

  def compute_delta(rank, prev_rank)
    return Delta.new(kind: :new, value: nil)        if prev_rank.nil?
    return Delta.new(kind: :unchanged, value: 0)    if prev_rank == rank
    Delta.new(kind: :change, value: prev_rank - rank)
  end
end

User#xp_transactions association must exist — has_many :xp_transactions, dependent: :destroy (verify in User model; add if missing).

  • Step 4: Tests pass
bin/rails test test/models/leaderboard/this_month_query_test.rb

Expected: GREEN.

  • Step 5: Commit
git commit -am "feat: leaderboard this-month query with delta and position"

Task 6: All-Time Query

Files:

  • Create: app/models/leaderboard/all_time_query.rb
  • Create: test/models/leaderboard/all_time_query_test.rb

  • Step 1: Failing test
require "test_helper"

class Leaderboard::AllTimeQueryTest < ActiveSupport::TestCase
  test "orders by total_xp desc with stable tie-break" do
    gamification_profiles(:member_one_profile).update!(total_xp: 5000, level: 28)
    gamification_profiles(:member_two_profile).update!(total_xp: 5000, level: 27)
    gamification_profiles(:admin_profile).update!(total_xp: 4500, level: 14)

    rows = Leaderboard::AllTimeQuery.new.call(limit: 5)

    assert_equal [users(:member_one).id, users(:member_two).id, users(:admin).id], rows.map(&:user_id)
  end

  test "excludes opted-out users" do
    users(:member_one).update!(leaderboard_visible: false)
    rows = Leaderboard::AllTimeQuery.new.call(limit: 50)
    refute_includes rows.map(&:user_id), users(:member_one).id
  end

  test "your_position handles users with no profile" do
    users(:no_profile).gamification_profile&.destroy
    position = Leaderboard::AllTimeQuery.new.your_position(users(:no_profile))
    assert_equal 0, position.rank
  end
end
  • Step 2: Implement
# app/models/leaderboard/all_time_query.rb
class Leaderboard::AllTimeQuery
  Row = Struct.new(:user_id, :user, :points, :level, keyword_init: true)
  Position = Struct.new(:rank, :points, :total_players, :percentile, keyword_init: true)

  def call(limit: 25, offset: 0)
    GamificationProfile
      .joins(:user)
      .where(users: { leaderboard_visible: true })
      .order(total_xp: :desc, level: :desc, user_id: :asc)
      .limit(limit).offset(offset)
      .includes(user: { avatar_attachment: :blob })
      .map { |p| Row.new(user_id: p.user_id, user: p.user, points: p.total_xp, level: p.level) }
  end

  def top_three(cache: true)
    return call(limit: 3) unless cache
    Rails.cache.fetch(["leaderboard", :all_time_top3], expires_in: 60) { call(limit: 3) }
  end

  def your_position(user)
    profile = user.gamification_profile
    return Position.new(rank: 0, points: 0, total_players: 0, percentile: 0) unless profile

    rank = GamificationProfile
             .joins(:user)
             .where(users: { leaderboard_visible: true })
             .where(<<~SQL, profile.total_xp,
                          profile.total_xp, profile.level,
                          profile.total_xp, profile.level, profile.user_id)
               total_xp > ?
               OR (total_xp = ? AND level > ?)
               OR (total_xp = ? AND level = ? AND user_id < ?)
             SQL
             .count + 1

    total = GamificationProfile.joins(:user).where(users: { leaderboard_visible: true }).count
    percentile = total.zero? ? 0 : ((total - rank + 1).to_f / total * 100).round
    Position.new(rank:, points: profile.total_xp, total_players: total, percentile:)
  end
end
  • Step 3: Tests pass + commit
bin/rails test test/models/leaderboard/all_time_query_test.rb
git commit -am "feat: leaderboard all-time query"

Task 7: Activity Feed

Files:

  • Create: app/models/leaderboard/activity_feed.rb
  • Create: test/models/leaderboard/activity_feed_test.rb
  • Modify: test/fixtures/user_achievements.yml (if needed)

  • Step 1: Failing test
require "test_helper"

class Leaderboard::ActivityFeedTest < ActiveSupport::TestCase
  test "groups events by today, yesterday, this week, earlier" do
    feed = Leaderboard::ActivityFeed.new(window: 30.days).call

    assert_includes feed.keys, :today
    assert_includes feed.keys, :yesterday
    assert_includes feed.keys, :this_week
    assert_includes feed.keys, :earlier
  end

  test "excludes events from opted-out users" do
    feed = Leaderboard::ActivityFeed.new(window: 30.days).call.values.flatten
    refute feed.any? { |e| e.user_id == users(:opted_out).id }
  end

  test "merges xp transactions and achievement unlocks chronologically" do
    feed = Leaderboard::ActivityFeed.new(window: 30.days).call.values.flatten
    types = feed.map(&:kind).uniq.sort
    assert_includes types, :badge_unlock
    assert_includes types, :focus_session
  end
end
  • Step 2: Implement
# app/models/leaderboard/activity_feed.rb
class Leaderboard::ActivityFeed
  Event = Struct.new(:user_id, :user, :kind, :description, :created_at, keyword_init: true)

  def initialize(window: 30.days, limit: 100)
    @window = window
    @limit  = limit
  end

  def call
    events = (xp_events + badge_events).sort_by { |e| -e.created_at.to_i }.first(@limit)
    bucketize(events)
  end

  private

  def xp_events
    XpTransaction
      .joins(:user)
      .where(users: { leaderboard_visible: true })
      .where(created_at: @window.ago..)
      .order(created_at: :desc).limit(@limit)
      .includes(user: { avatar_attachment: :blob })
      .map { |t| Event.new(user_id: t.user_id, user: t.user, kind: kind_for(t.category), description: describe_xp(t), created_at: t.created_at) }
  end

  def badge_events
    UserAchievement
      .joins(:user, :achievement)
      .where(users: { leaderboard_visible: true })
      .where(unlocked_at: @window.ago..)
      .order(unlocked_at: :desc).limit(@limit)
      .includes(:achievement, user: { avatar_attachment: :blob })
      .map { |ua| Event.new(user_id: ua.user_id, user: ua.user, kind: :badge_unlock, description: I18n.t("leaderboard.events.badge_unlock", name: ua.achievement.name), created_at: ua.unlocked_at) }
  end

  def kind_for(category)
    case category
    when "habits"  then :habit_completion
    when "focus"   then :focus_session
    when "finance" then :finance_action
    when "tools"   then :tools_action
    when "sports"  then :sports_action
    else :other_action
    end
  end

  def describe_xp(t)
    I18n.t("leaderboard.events.#{kind_for(t.category)}", description: t.description)
  end

  def bucketize(events)
    today      = Time.zone.today
    yesterday  = today - 1
    week_start = today.beginning_of_week

    {
      today:     events.select { |e| e.created_at.to_date == today },
      yesterday: events.select { |e| e.created_at.to_date == yesterday },
      this_week: events.select { |e| e.created_at.to_date.between?(week_start, today - 2) },
      earlier:   events.select { |e| e.created_at.to_date < week_start }
    }
  end
end

Add localized templates to both locales:

leaderboard:
  events:
    habit_completion: "%{description}"
    focus_session: "%{description}"
    finance_action: "%{description}"
    tools_action: "%{description}"
    sports_action: "%{description}"
    level_up: "leveled up — %{description}"
    badge_unlock: 'unlocked the "%{name}" badge'
  • Step 3: Tests pass + commit
bin/rails test test/models/leaderboard/activity_feed_test.rb
git commit -am "feat: leaderboard activity feed"

Task 8: Hall of Fame

Files:

  • Create: app/models/leaderboard/hall_of_fame.rb
  • Create: test/models/leaderboard/hall_of_fame_test.rb

  • Step 1: Failing test
require "test_helper"

class Leaderboard::HallOfFameTest < ActiveSupport::TestCase
  test "returns top three plus seven honorable mentions for a month" do
    last_month = Time.current - 1.month
    result = Leaderboard::HallOfFame.new.for(last_month.year, last_month.month)

    assert_equal 3, result.top_three.size
    assert result.honorable_mentions.size <= 7
  end

  test "available_months returns the four most recent months" do
    months = Leaderboard::HallOfFame.new.available_months
    assert_equal 4, months.size
    assert months.first.first > months.last.first || months.first.last >= months.last.last
  end

  test "results are cached" do
    hof = Leaderboard::HallOfFame.new
    Rails.cache.delete_matched("leaderboard/hall_of_fame/*")
    initial = hof.for(2026, 4)
    XpTransaction.create!(user: users(:opted_out), amount: 9999, category: "habits", description: "should not invalidate", created_at: Time.utc(2026, 4, 15))
    cached  = hof.for(2026, 4)
    assert_equal initial.top_three.map(&:user_id), cached.top_three.map(&:user_id)
  end
end
  • Step 2: Implement
# app/models/leaderboard/hall_of_fame.rb
class Leaderboard::HallOfFame
  Result = Struct.new(:year, :month, :top_three, :honorable_mentions, keyword_init: true)
  Row    = Struct.new(:user_id, :user, :points, :rank, keyword_init: true)

  MONTHS_TO_SHOW = 4

  def available_months
    today = Time.zone.today
    (1..MONTHS_TO_SHOW).map { |i| date = today << i; [date.year, date.month] }
  end

  def for(year, month)
    Rails.cache.fetch(cache_key(year, month), expires_in: 30.days) do
      sums = aggregate_for(year, month)
      ranked = sums.sort_by { |id, points| [-points, id] }.each_with_index.map do |(id, points), idx|
        Row.new(user_id: id, user: users_by_id[id], points: points, rank: idx + 1)
      end
      Result.new(year:, month:, top_three: ranked.first(3), honorable_mentions: ranked[3, 7] || [])
    end
  end

  private

  def aggregate_for(year, month)
    range = Date.new(year, month, 1).all_month
    XpTransaction
      .joins(:user)
      .where(users: { leaderboard_visible: true })
      .where(created_at: range)
      .group(:user_id)
      .sum(:amount)
  end

  def users_by_id
    @users_by_id ||= User
      .leaderboard_visible
      .includes(:gamification_profile, avatar_attachment: :blob)
      .index_by(&:id)
  end

  def cache_key(year, month) = "leaderboard/hall_of_fame/#{year}-#{month.to_s.rjust(2, '0')}"
end
  • Step 3: Cache invalidation hook

In app/models/xp_transaction.rb, add a hook to bust the cached month if a back-dated transaction lands:

after_commit :bust_hall_of_fame_cache, on: %i[create update destroy]

private

def bust_hall_of_fame_cache
  return if created_at.nil?
  Rails.cache.delete("leaderboard/hall_of_fame/#{created_at.year}-#{created_at.month.to_s.rjust(2, '0')}")
end
  • Step 4: Tests pass + commit
bin/rails test test/models/leaderboard/hall_of_fame_test.rb
git commit -am "feat: leaderboard hall of fame with month cache"

Task 9: Snapshot Facade

Files:

  • Modify: app/models/leaderboard/snapshot.rb
  • Create: test/models/leaderboard/snapshot_test.rb

  • Step 1: Replace stub with real facade
class Leaderboard::Snapshot
  attr_reader :tab, :user

  def initialize(user, tab:, page: nil)
    @user = user
    @tab  = tab
    @page = (page.presence || 1).to_i.clamp(1, 999)
  end

  def offset = (@page - 1) * 25

  def top_three
    case @tab
    when "this_month"   then this_month_query.top_three
    when "all_time"     then all_time_query.top_three
    when "hall_of_fame" then hall_of_fame.for(*hall_of_fame.available_months.first).top_three
    end
  end

  def ranked_rows
    case @tab
    when "this_month" then this_month_query.call(limit: 25, offset: offset)
    when "all_time"   then all_time_query.call(limit: 25, offset: offset)
    end
  end

  def your_position
    case @tab
    when "this_month" then this_month_query.your_position(user)
    when "all_time"   then all_time_query.your_position(user)
    end
  end

  def your_delta
    return nil unless @tab == "this_month"
    this_month_query.delta_for(user)
  end

  def month_closes_at = Time.current.end_of_month

  def activity_groups = Leaderboard::ActivityFeed.new.call

  def hall_of_fame_months = hall_of_fame.available_months

  def hall_of_fame_for(year, month) = hall_of_fame.for(year, month)

  private

  def this_month_query = @this_month_query ||= Leaderboard::ThisMonthQuery.new
  def all_time_query   = @all_time_query   ||= Leaderboard::AllTimeQuery.new
  def hall_of_fame     = @hall_of_fame     ||= Leaderboard::HallOfFame.new
end
  • Step 2: Snapshot test
require "test_helper"

class Leaderboard::SnapshotTest < ActiveSupport::TestCase
  test "this_month tab returns rows and a position" do
    snap = Leaderboard::Snapshot.new(users(:admin), tab: "this_month")
    assert snap.top_three.respond_to?(:each)
    assert snap.your_position.is_a?(Leaderboard::ThisMonthQuery::Position)
  end

  test "page is clamped" do
    snap = Leaderboard::Snapshot.new(users(:admin), tab: "this_month", page: -3)
    assert_equal 0, snap.offset
  end
end
  • Step 3: Tests pass + commit
bin/rails test test/models/leaderboard/snapshot_test.rb
git commit -am "feat: leaderboard snapshot facade"

Task 10: Views — Tabs, Podium, Rank Row, Ranked List, Your Position, Activity Feed, Hall of Fame

Files:

  • Modify: app/views/leaderboard/show.html.erb
  • Create: app/views/leaderboard/_tabs.html.erb
  • Create: app/views/leaderboard/_podium.html.erb
  • Create: app/views/leaderboard/_rank_row.html.erb
  • Create: app/views/leaderboard/_ranked_list.html.erb
  • Create: app/views/leaderboard/_your_position.html.erb
  • Create: app/views/leaderboard/_activity_feed.html.erb
  • Create: app/views/leaderboard/_hall_of_fame.html.erb
  • Create: app/javascript/controllers/month_countdown_controller.js

  • Step 1: Build _tabs.html.erb

A horizontal tab strip wrapped in <nav role="radiogroup" aria-label="Leaderboard view">. Each tab is a link with ?tab=... and aria-current="page" when selected. Style with Tailwind underline indicator.

  • Step 2: Build _podium.html.erb

Takes a rows: array of three. Renders #2 / #1 / #3 left-to-right with #1 elevated. Uses image_tag user.avatar.variant(...) and shows level + points beneath. Crown SVG on #1.

  • Step 3: Build _rank_row.html.erb

A single reusable row shared between This Month, All-Time, and Hall of Fame mention cards. Inputs: rank:, user:, points:, delta: nil. Uses link_to user_profile_path(user) (route added in Task 12).

  • Step 4: Build _ranked_list.html.erb

Loops over @snapshot.ranked_rows, rendering _rank_row.html.erb per row. Includes Pagy pagination at the bottom.

  • Step 5: Build _your_position.html.erb

Sticky pinned card above the ranked list. Shows rank, total players, percentile, points, delta chip.

  • Step 6: Build _activity_feed.html.erb

Iterates @snapshot.activity_groups. Each group titled with a date label; events rendered as compact rows with avatar + handle + description + relative time.

  • Step 7: Build _hall_of_fame.html.erb

Top: month chip strip from @snapshot.hall_of_fame_months. Below: podium for the selected month + 7 honorable-mention cards.

  • Step 8: Wire show.html.erb
<section class="mx-auto max-w-5xl px-4 py-8">
  <header class="flex items-center justify-between mb-4">
    <div>
      <h1 class="text-3xl font-bold"><%= t("leaderboard.title") %></h1>
      <p class="text-gray-400"><%= t("leaderboard.subtitle") %></p>
    </div>
    <%= render "tabs", snapshot: @snapshot %>
  </header>

  <% case @snapshot.tab %>
  <% when "this_month" %>
    <div class="flex justify-end mb-2 text-sm text-gray-400 font-mono"
         data-controller="month-countdown"
         data-month-countdown-target-time-value="<%= @snapshot.month_closes_at.iso8601 %>">
      <%= t("leaderboard.month_closes_in") %>: <span data-month-countdown-target="output">…</span>
    </div>
    <%= render "podium", rows: @snapshot.top_three %>
    <%= render "your_position", position: @snapshot.your_position, delta: @snapshot.your_delta %>
    <%= render "ranked_list", snapshot: @snapshot %>
  <% when "all_time" %>
    <%= render "podium", rows: @snapshot.top_three %>
    <%= render "your_position", position: @snapshot.your_position %>
    <%= render "ranked_list", snapshot: @snapshot %>
  <% when "activity" %>
    <%= render "activity_feed", groups: @snapshot.activity_groups %>
  <% when "hall_of_fame" %>
    <%= render "hall_of_fame", snapshot: @snapshot %>
  <% end %>
</section>
  • Step 9: Stimulus countdown controller
// app/javascript/controllers/month_countdown_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]
  static values  = { time: String }

  connect() {
    this.tick()
    this.interval = setInterval(() => this.tick(), 1000)
  }

  disconnect() {
    if (this.interval) clearInterval(this.interval)
  }

  tick() {
    const ms = new Date(this.timeValue) - new Date()
    if (ms <= 0) { this.outputTarget.textContent = "0d 00:00:00"; return }
    const d  = Math.floor(ms / 86400000)
    const h  = Math.floor(ms / 3600000) % 24
    const m  = Math.floor(ms / 60000)   % 60
    const s  = Math.floor(ms / 1000)    % 60
    this.outputTarget.textContent = `${d}d ${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
  }
}
  • Step 10: Tests + commit
bin/rails test test/controllers/leaderboard_controller_test.rb
git commit -am "feat: leaderboard views and countdown"

Task 11: Public Profile Page (/@:username)

Files:

  • Modify: config/routes.rb
  • Create: app/controllers/users/profiles_controller.rb
  • Create: app/policies/users/profile_policy.rb
  • Create: app/views/users/profiles/show.html.erb
  • Create: test/controllers/users/profiles_controller_test.rb

  • Step 1: Failing test
require "test_helper"

class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
  test "anyone can view a public profile" do
    get user_profile_url(username: users(:admin).username)
    assert_response :success
    assert_select "h1", text: /@#{users(:admin).username}/
  end

  test "opted-out users still have a profile but it's marked private" do
    users(:admin).update!(leaderboard_visible: false)
    get user_profile_url(username: users(:admin).username)
    assert_response :success
    assert_select "[data-private]"
  end

  test "missing username returns 404" do
    get user_profile_url(username: "no-such-user")
    assert_response :not_found
  end
end
  • Step 2: Add route (outside authenticated :user block, public)

In config/routes.rb (top-level, not authenticated):

get "/@:username", to: "users/profiles#show", as: :user_profile, constraints: { username: User::USERNAME_REGEX }
  • Step 3: Controller
class Users::ProfilesController < ApplicationController
  skip_before_action :authenticate_user!
  before_action :set_profile_user

  def show
    authorize @profile_user, policy_class: Users::ProfilePolicy
    @level     = @profile_user.gamification_profile&.level || 1
    @total_xp  = @profile_user.gamification_profile&.total_xp || 0
    @vision_public_sections = @profile_user.respond_to?(:vision) && @profile_user.vision ? @profile_user.vision.public_sections : {}
  end

  private

  def set_profile_user
    @profile_user = User.find_by!(username: params[:username])
  rescue ActiveRecord::RecordNotFound
    head :not_found
  end
end
  • Step 4: Policy
class Users::ProfilePolicy < ApplicationPolicy
  def show? = true
end
  • Step 5: View

app/views/users/profiles/show.html.erb renders:

  • Avatar, @username, level, total XP.
  • If leaderboard_visible == false, render the page with data-private and only the username + a small "Private profile" notice.
  • Else, render Vision public sections (placeholder partial that the Vision plan replaces).

  • Step 6: Tests pass + commit
bin/rails test test/controllers/users/profiles_controller_test.rb
git commit -am "feat: public user profile at /@:username"

Files:

  • Modify: app/views/shared/_sidebar.html.erb
  • Modify: app/views/shared/_navbar.html.erb
  • Modify: app/views/shared/_footer_nav.html.erb
  • Modify: app/helpers/application_helper.rb

  • Step 1: Add Leaderboard link to all three navigation surfaces

Mirror the existing pattern (icon + label + active-state class).

  • Step 2: Add leaderboard icon case to application_helper.rb#sidebar_icon

Use a podium / chart-bar SVG with currentColor.

  • Step 3: Commit
git commit -am "feat: leaderboard nav links"

Task 13: Rate Limiting

Files:

  • Create or Modify: config/initializers/rack_attack.rb
  • Modify: Gemfile (only if rack-attack not yet present)

  • Step 1: Ensure rack-attack is in Gemfile

If absent: add gem "rack-attack" and bundle install. (Verify the gem is permitted by AGENTS.md before adding; the team allowed gem list does not currently include rack-attack — if it's not allowed, drop a TODO comment in the initializer and rely on application-layer throttling via solid_cache instead.)

  • Step 2: Throttle leaderboard reads
Rack::Attack.throttle("leaderboard/ip", limit: 60, period: 1.minute) do |req|
  req.ip if req.path.start_with?("/leaderboard")
end
  • Step 3: Commit
git commit -am "feat: rate limit leaderboard requests"

Task 14: Final Verification

  • Step 1: Run targeted suites
bin/rails test test/controllers/leaderboard_controller_test.rb \
               test/controllers/users/profiles_controller_test.rb \
               test/models/leaderboard

Expected: all GREEN.

  • Step 2: Run full CI
bin/ci

Expected: GREEN (rubocop, brakeman, bundler-audit, tests).

  • Step 3: Manual smoke test (optional, only if dev server is up)
bin/dev
# visit /leaderboard, switch tabs, verify countdown, click a row to land on /@username
  • Step 4: Final commit / push

The branch should now contain the leaderboard feature end-to-end. Open the PR with a checklist that mirrors the phases in project-plan.md.