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 viasolid_cache. - Security: Pundit on every action, allowlisted username regex, Rack::Attack throttle, public profiles render only opted-in fields,
leaderboard_visible = falseusers excluded from every cross-user query.
File Structure
- Modify:
db/schema.rb(via migrations)- Add
username,leaderboard_visibletousers. - Add
(created_at)index toxp_transactions.
- Add
- Modify:
app/models/user.rbvalidates :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 forleaderboard_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_usersmigration
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_usernamesmigration
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_usersmigration
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
Usermodel
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)(whoseleaderboard_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 :userblock, 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 withdata-privateand 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"
Task 12: Sidebar / Navbar / Footer Links
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
Leaderboardlink to all three navigation surfaces
Mirror the existing pattern (icon + label + active-state class).
- Step 2: Add
leaderboardicon case toapplication_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-attackis 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.