From ade031ce649fa05c2a906ad8ccbc807530fd0b6a Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 01:10:09 -0400 Subject: [PATCH 01/12] feat(admin): add Leaderboard page ranking top earners per month Adds an admin-only ActiveAdmin page listing the top X earners for each month, styled as a ledger. Earnings are the aggregate of a contributor's ContributorPayouts across all of their ledgers (a contributor has one ledger per enterprise), bucketed on invoice_passes.start_of_month to match the canonical dating used by ContributorPayout#effective_on_for_display. Trueups are excluded. A Trueup is what tops a leadership role up to the average of the top earners, so counting it would let the roles being topped up appear at the top of the board they are benchmarked against. Because Trueups live in their own table rather than as a type column on a shared ledger table, the exclusion is structural: we never sum them. X defaults to 5 and is overridable via ?limit=, clamped to 1..50 so a hostile or fat-fingered param cannot blow up the page. The global AuthorizationAdapter admits project leads to most pages, so the page additionally gates on current_admin_user.is_admin? in a before_action, and hides itself from the menu for non-admins. Verified against the restored production dataset: April 2026 reproduces the finance team's independently-maintained figures to the cent. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/leaderboard.rb | 82 ++++++++++++ lib/stacks/leaderboard.rb | 105 +++++++++++++++ test/integration/admin_leaderboard_test.rb | 101 ++++++++++++++ test/lib/stacks/leaderboard_test.rb | 149 +++++++++++++++++++++ 4 files changed, 437 insertions(+) create mode 100644 app/admin/leaderboard.rb create mode 100644 lib/stacks/leaderboard.rb create mode 100644 test/integration/admin_leaderboard_test.rb create mode 100644 test/lib/stacks/leaderboard_test.rb diff --git a/app/admin/leaderboard.rb b/app/admin/leaderboard.rb new file mode 100644 index 00000000..efa668b9 --- /dev/null +++ b/app/admin/leaderboard.rb @@ -0,0 +1,82 @@ +ActiveAdmin.register_page "Leaderboard" do + menu label: "Leaderboard", priority: 20, if: proc { current_admin_user.is_admin? } + + # The global AuthorizationAdapter lets project leads through for most pages, + # so gate this one explicitly: earnings across the whole collective are + # admin-only. + controller do + before_action :require_admin! + + private + + def require_admin! + unless current_admin_user&.is_admin? + redirect_to admin_root_path, alert: "Admins only." + end + end + end + + content title: "Leaderboard" do + limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) + months = Stacks::Leaderboard.call(limit: limit) + + rule = "1px solid #e4dcc8" + ledger_bg = "#fffdf7" + header_bg = "#f4ecda" + ink = "#3a3222" + muted = "#8a7f66" + numerals = "font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums;" + + div style: "max-width: 780px; color: #{ink};" do + div style: "margin-bottom: 1.5em;" do + para style: "margin: 0 0 0.4em 0; color: #{muted};" do + "Top #{limit} earners per month, by aggregate contributor payouts across all ledgers. " \ + "Trueups are excluded — they top leadership roles up to these averages, so counting " \ + "them would double back on the benchmark." + end + div style: "color: #{muted};" do + span "Show top: " + [3, 5, 10, 20].each do |n| + selected = (n == limit) + span style: "margin-right: 0.5em;" do + if selected + span n.to_s, style: "font-weight: 700; color: #{ink};" + else + link_to n.to_s, admin_leaderboard_path(limit: n) + end + end + end + end + end + + if months.empty? + div style: "padding: 1em; background: #{ledger_bg}; border: #{rule};" do + para "No contributor payouts recorded yet." + end + end + + months.each do |group| + div style: "margin-bottom: 1.5em; background: #{ledger_bg}; border: #{rule};" do + div style: "display: flex; justify-content: space-between; align-items: baseline; " \ + "padding: 0.6em 0.9em; background: #{header_bg}; border-bottom: #{rule};" do + span group.start_of_month.strftime("%B %Y"), style: "font-weight: 700; letter-spacing: 0.02em;" + span number_to_currency(group.total), style: "font-weight: 700; #{numerals}" + end + + table style: "width: 100%; border-collapse: collapse;" do + group.entries.each do |entry| + tr style: "border-bottom: #{rule};" do + td entry.rank.to_s, + style: "width: 2.5em; padding: 0.55em 0.9em; color: #{muted}; #{numerals}" + td entry.display_name, + style: "padding: 0.55em 0.9em;" + td number_to_currency(entry.amount), + style: "padding: 0.55em 0.9em; text-align: right; white-space: nowrap; #{numerals}" + end + end + end + end + end + end + end +end diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb new file mode 100644 index 00000000..43d04ca9 --- /dev/null +++ b/lib/stacks/leaderboard.rb @@ -0,0 +1,105 @@ +# Ranks contributors by how much they actually earned in each month. +# +# "Earnings" here means ContributorPayout only. Trueups are deliberately +# excluded: a Trueup is the mechanism that tops a leadership role up to the +# average of the top earners, so counting it would let the very roles being +# topped up appear at the top of the board they're benchmarked against. +# Because Trueups live in their own table (rather than as a type column on a +# shared ledger table), excluding them is structural — we simply never sum +# them — and no filtering predicate is required. +# +# A contributor has one Ledger per Enterprise, so payouts are grouped by +# ledgers.contributor_id to aggregate "across all their contributor ledgers". +# Months are bucketed on invoice_passes.start_of_month, matching the canonical +# dating used by ContributorPayout#effective_on_for_display. +module Stacks + class Leaderboard + DEFAULT_LIMIT = 5 + MIN_LIMIT = 1 + MAX_LIMIT = 50 + + Entry = Struct.new(:rank, :contributor_id, :display_name, :amount, keyword_init: true) + MonthGroup = Struct.new(:start_of_month, :entries, :total, keyword_init: true) + + # Coerces an untrusted ?limit= param into a sane integer. + def self.sanitize_limit(raw) + value = Integer(raw.to_s.strip, exception: false) + return DEFAULT_LIMIT if value.nil? + value.clamp(MIN_LIMIT, MAX_LIMIT) + end + + def self.call(limit: DEFAULT_LIMIT) + new(limit: limit).call + end + + def initialize(limit: DEFAULT_LIMIT) + @limit = limit + end + + # Returns [MonthGroup] ordered most-recent month first. + def call + grouped = earnings_by_month_and_contributor + .select { |_key, amount| amount.to_d.positive? } + .group_by { |(month, _contributor_id), _amount| month } + + ranked_by_month = grouped.transform_values do |pairs| + pairs + .sort_by { |(_month, _contributor_id), amount| -amount.to_d } + .first(@limit) + end + + names = display_names_for( + ranked_by_month.values.flatten(1).map { |(_month, contributor_id), _amount| contributor_id }.uniq + ) + + ranked_by_month + .sort_by { |month, _pairs| month } + .reverse + .map do |month, pairs| + entries = pairs.each_with_index.map do |((_month, contributor_id), amount), index| + Entry.new( + rank: index + 1, + contributor_id: contributor_id, + display_name: names.fetch(contributor_id, "Contributor ##{contributor_id}"), + amount: amount.to_d + ) + end + + MonthGroup.new( + start_of_month: month, + entries: entries, + total: entries.sum(&:amount) + ) + end + end + + private + + # => { [start_of_month, contributor_id] => summed_amount } + # + # ContributorPayout is acts_as_paranoid, so its default scope already + # excludes soft-deleted rows. + def earnings_by_month_and_contributor + ContributorPayout + .joins(:ledger, invoice_tracker: :invoice_pass) + .group("invoice_passes.start_of_month", "ledgers.contributor_id") + .sum("contributor_payouts.amount") + end + + def display_names_for(contributor_ids) + return {} if contributor_ids.empty? + + # Contributor has a default_scope that force-joins forecast_person and + # orders by email; unscoped keeps this lookup predictable. + Contributor + .unscoped + .where(id: contributor_ids) + .includes(:forecast_person) + .each_with_object({}) do |contributor, memo| + person = contributor.forecast_person + full_name = [person&.first_name, person&.last_name].map(&:presence).compact.join(" ") + memo[contributor.id] = full_name.presence || person&.email || "Contributor ##{contributor.id}" + end + end + end +end diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb new file mode 100644 index 00000000..0e3c14a3 --- /dev/null +++ b/test/integration/admin_leaderboard_test.rb @@ -0,0 +1,101 @@ +require 'test_helper' + +class AdminLeaderboardTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + setup do + @admin = AdminUser.create!( + email: "admin-#{SecureRandom.hex(4)}@example.com", + password: 'password12345', + password_confirmation: 'password12345', + roles: ['admin'] + ) + + @month = Date.new(2096, 5, 1) + @pass = InvoicePass.create!(start_of_month: @month) + client = ForecastClient.create!( + forecast_id: rand(1..2_000_000_000), + name: "Leaderboard Client #{SecureRandom.hex(2)}" + ) + @tracker = InvoiceTracker.create!( + forecast_client: client, + invoice_pass: @pass, + qbo_account: qbo_accounts(:one) + ) + end + + def contributor_with_payout(email, amount) + person = ForecastPerson.create!( + forecast_id: rand(1..2_000_000_000), + email: email, + data: {} + ) + contributor = Contributor.create!(forecast_person: person) + ContributorPayout.new( + ledger: Ledger.find_or_create_for(enterprise: enterprises(:sanctuary), contributor: contributor), + invoice_tracker: @tracker, + created_by: @admin, + amount: amount + ).save!(validate: false) + contributor + end + + test 'renders the ledger with the month, ranked earners and amounts' do + contributor_with_payout('alpha@example.com', 900) + contributor_with_payout('beta@example.com', 400) + sign_in @admin + + get '/admin/leaderboard' + + assert_response :success + assert_includes response.body, 'May 2096', 'shows the month heading' + assert_includes response.body, 'alpha@example.com' + assert_includes response.body, 'beta@example.com' + assert_includes response.body, '$900.00' + assert_includes response.body, '$1,300.00', 'shows the month total' + assert_includes response.body, 'Trueups are excluded' + end + + test 'defaults to the top 5 and honors ?limit=' do + 7.times { |i| contributor_with_payout("earner#{i}@example.com", (i + 1) * 100) } + sign_in @admin + + get '/admin/leaderboard' + assert_response :success + # Highest five are 700..300; the 200 and 100 earners fall outside the default. + assert_includes response.body, 'earner6@example.com' + assert_includes response.body, 'earner2@example.com' + refute_includes response.body, 'earner1@example.com', 'defaults to top 5' + + get '/admin/leaderboard?limit=7' + assert_response :success + assert_includes response.body, 'earner0@example.com', 'limit=7 widens the board' + end + + test 'clamps an out-of-range limit instead of erroring' do + contributor_with_payout('solo@example.com', 100) + sign_in @admin + + get '/admin/leaderboard?limit=99999' + assert_response :success + + get '/admin/leaderboard?limit=bogus' + assert_response :success + assert_includes response.body, 'solo@example.com' + end + + test 'non-admins are redirected away' do + non_admin = AdminUser.create!( + email: "plain-#{SecureRandom.hex(4)}@example.com", + password: 'password12345', + password_confirmation: 'password12345', + roles: [] + ) + sign_in non_admin + + get '/admin/leaderboard' + + assert_response :redirect + refute_includes(response.body.to_s, 'alpha@example.com') + end +end diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb new file mode 100644 index 00000000..c1228c54 --- /dev/null +++ b/test/lib/stacks/leaderboard_test.rb @@ -0,0 +1,149 @@ +require "test_helper" + +class Stacks::LeaderboardTest < ActiveSupport::TestCase + def setup + @admin = AdminUser.create!( + email: "leaderboard-#{SecureRandom.hex(3)}@sanctuary.computer", + password: "password" + ) + @enterprise = enterprises(:sanctuary) + @other_enterprise = enterprises(:one) + @qbo_account = qbo_accounts(:one) + + # Far-future month so this suite never collides with other tests or with + # the unique index on invoice_passes.start_of_month. + @month = Date.new(2097, 3, 1) + @pass = InvoicePass.create!(start_of_month: @month) + @client = ForecastClient.create!( + forecast_id: rand(1..2_000_000_000), + name: "Leaderboard Client #{SecureRandom.hex(2)}" + ) + @tracker = InvoiceTracker.create!( + forecast_client: @client, + invoice_pass: @pass, + qbo_account: @qbo_account + ) + end + + def make_contributor(email) + person = ForecastPerson.create!( + forecast_id: rand(1..2_000_000_000), + email: email, + data: {} + ) + Contributor.create!(forecast_person: person) + end + + def ledger_for(contributor, enterprise = @enterprise) + Ledger.find_or_create_for(enterprise: enterprise, contributor: contributor) + end + + # save(validate: false) sidesteps the 70%-of-invoice and new-deal domain + # validations, which are irrelevant to (and would obstruct) aggregation tests. + def payout!(contributor, amount, enterprise: @enterprise) + ContributorPayout.new( + ledger: ledger_for(contributor, enterprise), + invoice_tracker: @tracker, + created_by: @admin, + amount: amount + ).save!(validate: false) + end + + def month_group + Stacks::Leaderboard.call(limit: 5).find { |g| g.start_of_month == @month } + end + + test "ranks contributors by summed payouts, descending, honoring the limit" do + top = make_contributor("top@example.com") + middle = make_contributor("middle@example.com") + bottom = make_contributor("bottom@example.com") + + payout!(middle, 300) + payout!(top, 500) + payout!(bottom, 100) + + group = Stacks::Leaderboard.call(limit: 2).find { |g| g.start_of_month == @month } + + assert_equal 2, group.entries.size, "limit should cap the number of entries" + assert_equal ["top@example.com", "middle@example.com"], group.entries.map(&:display_name) + assert_equal [1, 2], group.entries.map(&:rank) + assert_equal BigDecimal("500"), group.entries.first.amount + assert_equal BigDecimal("800"), group.total, "total reflects only the listed entries" + end + + test "excludes Trueups from earnings" do + real_earner = make_contributor("earner@example.com") + topped_up = make_contributor("toppedup@example.com") + + payout!(real_earner, 1_000) + payout!(topped_up, 10) + + # A large Trueup must not lift this contributor up the board. + Trueup.new( + ledger: ledger_for(topped_up), + invoice_pass: @pass, + amount: 5_000 + ).save!(validate: false) + + group = month_group + + assert_equal "earner@example.com", group.entries.first.display_name, + "the Trueup recipient must not outrank a genuine earner" + + topped_up_entry = group.entries.find { |e| e.display_name == "toppedup@example.com" } + assert_equal BigDecimal("10"), topped_up_entry.amount, + "only the payout should count, not the Trueup" + assert_equal BigDecimal("1010"), group.total + end + + test "sums a contributor's payouts across all of their ledgers" do + contributor = make_contributor("multi@example.com") + + payout!(contributor, 100, enterprise: @enterprise) + payout!(contributor, 250, enterprise: @other_enterprise) + + group = month_group + entry = group.entries.find { |e| e.display_name == "multi@example.com" } + + assert_equal 1, group.entries.count { |e| e.display_name == "multi@example.com" }, + "a contributor appears once, aggregated across ledgers" + assert_equal BigDecimal("350"), entry.amount + end + + test "omits contributors with no positive earnings" do + earner = make_contributor("positive@example.com") + zeroed = make_contributor("zero@example.com") + + payout!(earner, 100) + payout!(zeroed, 0) + + group = month_group + + assert_equal ["positive@example.com"], group.entries.map(&:display_name) + end + + test "prefers a full name over the email when one is present" do + person = ForecastPerson.create!( + forecast_id: rand(1..2_000_000_000), + email: "named@example.com", + first_name: "Ada", + last_name: "Lovelace", + data: {} + ) + contributor = Contributor.create!(forecast_person: person) + payout!(contributor, 42) + + assert_equal "Ada Lovelace", month_group.entries.first.display_name + end + + test "sanitize_limit defaults, clamps, and rejects junk" do + assert_equal 5, Stacks::Leaderboard.sanitize_limit(nil) + assert_equal 5, Stacks::Leaderboard.sanitize_limit("") + assert_equal 5, Stacks::Leaderboard.sanitize_limit("not-a-number") + assert_equal 3, Stacks::Leaderboard.sanitize_limit("3") + assert_equal 10, Stacks::Leaderboard.sanitize_limit(10) + assert_equal 1, Stacks::Leaderboard.sanitize_limit("0"), "clamps below the minimum" + assert_equal 1, Stacks::Leaderboard.sanitize_limit("-4"), "clamps negatives" + assert_equal 50, Stacks::Leaderboard.sanitize_limit("999"), "clamps above the maximum" + end +end From 79ccdd94f95906a5327c92d0a44a68ceb30c5285 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 19 Jul 2026 23:57:03 -0700 Subject: [PATCH 02/12] refactor(admin): restyle Leaderboard to match the Money page; show monthly average MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps the hand-rolled inline "ledger paper" styling for the same markup the Money (payable QBO bills) screen uses, so the two read as one system: a .title_bar per group with the heading on the left and a .pill.complete on the right, over a standard .index_table.index table with even/odd rows and .text-right amounts. The Arbre block becomes an ERB partial, matching how the other content-heavy register_pages (e.g. OKR Explorer) render. The right-hand pill now shows the AVERAGE earned across the listed top-N contributors rather than the month's total — this is the "average top-N earner" figure the leadership compensation model multiplies against. The total moves to a footer row on the table, so no information is lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/leaderboard.rb | 63 ++--------------- .../admin/leaderboard/_leaderboard.html.erb | 67 +++++++++++++++++++ lib/stacks/leaderboard.rb | 11 ++- test/integration/admin_leaderboard_test.rb | 3 + test/lib/stacks/leaderboard_test.rb | 13 ++++ 5 files changed, 96 insertions(+), 61 deletions(-) create mode 100644 app/views/admin/leaderboard/_leaderboard.html.erb diff --git a/app/admin/leaderboard.rb b/app/admin/leaderboard.rb index efa668b9..659ab224 100644 --- a/app/admin/leaderboard.rb +++ b/app/admin/leaderboard.rb @@ -18,65 +18,10 @@ def require_admin! content title: "Leaderboard" do limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) - months = Stacks::Leaderboard.call(limit: limit) - rule = "1px solid #e4dcc8" - ledger_bg = "#fffdf7" - header_bg = "#f4ecda" - ink = "#3a3222" - muted = "#8a7f66" - numerals = "font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums;" - - div style: "max-width: 780px; color: #{ink};" do - div style: "margin-bottom: 1.5em;" do - para style: "margin: 0 0 0.4em 0; color: #{muted};" do - "Top #{limit} earners per month, by aggregate contributor payouts across all ledgers. " \ - "Trueups are excluded — they top leadership roles up to these averages, so counting " \ - "them would double back on the benchmark." - end - div style: "color: #{muted};" do - span "Show top: " - [3, 5, 10, 20].each do |n| - selected = (n == limit) - span style: "margin-right: 0.5em;" do - if selected - span n.to_s, style: "font-weight: 700; color: #{ink};" - else - link_to n.to_s, admin_leaderboard_path(limit: n) - end - end - end - end - end - - if months.empty? - div style: "padding: 1em; background: #{ledger_bg}; border: #{rule};" do - para "No contributor payouts recorded yet." - end - end - - months.each do |group| - div style: "margin-bottom: 1.5em; background: #{ledger_bg}; border: #{rule};" do - div style: "display: flex; justify-content: space-between; align-items: baseline; " \ - "padding: 0.6em 0.9em; background: #{header_bg}; border-bottom: #{rule};" do - span group.start_of_month.strftime("%B %Y"), style: "font-weight: 700; letter-spacing: 0.02em;" - span number_to_currency(group.total), style: "font-weight: 700; #{numerals}" - end - - table style: "width: 100%; border-collapse: collapse;" do - group.entries.each do |entry| - tr style: "border-bottom: #{rule};" do - td entry.rank.to_s, - style: "width: 2.5em; padding: 0.55em 0.9em; color: #{muted}; #{numerals}" - td entry.display_name, - style: "padding: 0.55em 0.9em;" - td number_to_currency(entry.amount), - style: "padding: 0.55em 0.9em; text-align: right; white-space: nowrap; #{numerals}" - end - end - end - end - end - end + render(partial: "leaderboard", locals: { + limit: limit, + months: Stacks::Leaderboard.call(limit: limit), + }) end end diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb new file mode 100644 index 00000000..8ef12cbb --- /dev/null +++ b/app/views/admin/leaderboard/_leaderboard.html.erb @@ -0,0 +1,67 @@ +<%# Top X earners per month, styled to match the Money (payable QBO bills) + screen: a title_bar per group with the heading on the left and a pill on + the right, over a standard .index_table.index table. %> + +
+

+ Top <%= limit %> earners per month, by aggregate contributor payouts across all ledgers. + Trueups are excluded — they top leadership roles up to these averages, so counting them + would double back on the benchmark. +

+ Show top: + <% [3, 5, 10, 20].each do |n| %> + <% if n == limit %> + <%= n %> + <% else %> + <%= link_to n, admin_leaderboard_path(limit: n), style: "margin-right: 8px;" %> + <% end %> + <% end %> +
+ +<% if months.empty? %> +
+
+
+

No contributor payouts recorded yet.

+
+
+
+<% else %> + <% months.each do |group| %> +
+
+

<%= group.start_of_month.strftime("%B %Y") %>

+
+
+ + <%= number_to_currency(group.average) %> + avg of top <%= group.entries.size %> + +
+
+ + + + + + + + + + + <% group.entries.each_with_index do |entry, index| %> + "> + + + + + <% end %> + "> + + + + + +
#ContributorEarnings
<%= entry.rank %><%= entry.display_name %><%= number_to_currency(entry.amount) %>
Total<%= number_to_currency(group.total) %>
+ <% end %> +<% end %> diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb index 43d04ca9..9864b023 100644 --- a/lib/stacks/leaderboard.rb +++ b/lib/stacks/leaderboard.rb @@ -19,7 +19,11 @@ class Leaderboard MAX_LIMIT = 50 Entry = Struct.new(:rank, :contributor_id, :display_name, :amount, keyword_init: true) - MonthGroup = Struct.new(:start_of_month, :entries, :total, keyword_init: true) + + # `average` is the mean across the listed entries only (not the whole + # collective) — i.e. the "average top-N earner", which is the figure the + # leadership compensation model multiplies against. + MonthGroup = Struct.new(:start_of_month, :entries, :total, :average, keyword_init: true) # Coerces an untrusted ?limit= param into a sane integer. def self.sanitize_limit(raw) @@ -65,10 +69,13 @@ def call ) end + total = entries.sum(&:amount) + MonthGroup.new( start_of_month: month, entries: entries, - total: entries.sum(&:amount) + total: total, + average: entries.empty? ? BigDecimal(0) : (total / entries.size) ) end end diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index 0e3c14a3..23f79f4a 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -53,7 +53,10 @@ def contributor_with_payout(email, amount) assert_includes response.body, 'beta@example.com' assert_includes response.body, '$900.00' assert_includes response.body, '$1,300.00', 'shows the month total' + assert_includes response.body, '$650.00', 'shows the average of the listed earners' + assert_includes response.body, 'avg of top 2' assert_includes response.body, 'Trueups are excluded' + assert_includes response.body, 'index_table', 'uses the shared ActiveAdmin table styling' end test 'defaults to the top 5 and honors ?limit=' do diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb index c1228c54..7ea56797 100644 --- a/test/lib/stacks/leaderboard_test.rb +++ b/test/lib/stacks/leaderboard_test.rb @@ -69,6 +69,19 @@ def month_group assert_equal [1, 2], group.entries.map(&:rank) assert_equal BigDecimal("500"), group.entries.first.amount assert_equal BigDecimal("800"), group.total, "total reflects only the listed entries" + assert_equal BigDecimal("400"), group.average, "average is across the listed entries only" + end + + test "average is the mean of the listed entries, not the whole collective" do + make_contributor("a@example.com").tap { |c| payout!(c, 900) } + make_contributor("b@example.com").tap { |c| payout!(c, 300) } + # A third earner that falls outside a limit of 2 must not drag the average. + make_contributor("c@example.com").tap { |c| payout!(c, 30) } + + group = Stacks::Leaderboard.call(limit: 2).find { |g| g.start_of_month == @month } + + assert_equal BigDecimal("1200"), group.total + assert_equal BigDecimal("600"), group.average end test "excludes Trueups from earnings" do From 38a9b990f22e58f150aaea49cf32dc4d849d7d89 Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 00:00:48 -0700 Subject: [PATCH 03/12] feat(admin): link Leaderboard contributors; pill limit toggles; drop header copy - Each contributor name now links through to their contributor page via link_to admin_contributor_path, matching the Money screen. Entry already carries contributor_id, so this costs no extra queries. - The limit toggles become the same nag-pill tab bar the Money page uses, with .complete marking the active limit. - Removes the explanatory paragraph above the table. The reasoning for excluding Trueups lives in the code comments and the PR, not the chrome. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/leaderboard/_leaderboard.html.erb | 19 +++++++------------ test/integration/admin_leaderboard_test.rb | 7 +++++-- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb index 8ef12cbb..8120634d 100644 --- a/app/views/admin/leaderboard/_leaderboard.html.erb +++ b/app/views/admin/leaderboard/_leaderboard.html.erb @@ -2,18 +2,13 @@ screen: a title_bar per group with the heading on the left and a pill on the right, over a standard .index_table.index table. %> -
-

- Top <%= limit %> earners per month, by aggregate contributor payouts across all ledgers. - Trueups are excluded — they top leadership roles up to these averages, so counting them - would double back on the benchmark. -

- Show top: +<%# Limit toggles, styled as the same nag-pill tab bar the Money page uses. %> +
<% [3, 5, 10, 20].each do |n| %> - <% if n == limit %> - <%= n %> - <% else %> - <%= link_to n, admin_leaderboard_path(limit: n), style: "margin-right: 8px;" %> + <%= link_to admin_leaderboard_path(limit: n), style: "margin-right: 6px" do %> +

+ <%= n %> +

<% end %> <% end %>
@@ -52,7 +47,7 @@ <% group.entries.each_with_index do |entry, index| %> "> <%= entry.rank %> - <%= entry.display_name %> + <%= link_to entry.display_name, admin_contributor_path(entry.contributor_id) %> <%= number_to_currency(entry.amount) %> <% end %> diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index 23f79f4a..8ae030cb 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -41,7 +41,7 @@ def contributor_with_payout(email, amount) end test 'renders the ledger with the month, ranked earners and amounts' do - contributor_with_payout('alpha@example.com', 900) + alpha = contributor_with_payout('alpha@example.com', 900) contributor_with_payout('beta@example.com', 400) sign_in @admin @@ -55,8 +55,11 @@ def contributor_with_payout(email, amount) assert_includes response.body, '$1,300.00', 'shows the month total' assert_includes response.body, '$650.00', 'shows the average of the listed earners' assert_includes response.body, 'avg of top 2' - assert_includes response.body, 'Trueups are excluded' assert_includes response.body, 'index_table', 'uses the shared ActiveAdmin table styling' + assert_includes response.body, 'nag pill complete', 'marks the active limit toggle' + assert_includes response.body, + %(alpha@example.com), + 'links each contributor through to their contributor page' end test 'defaults to the top 5 and honors ?limit=' do From 9b57f695facc13aa1599357090812141753b4dea Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 00:06:10 -0700 Subject: [PATCH 04/12] feat(admin): CSV export for Leaderboard; nest under Money, admins only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Export CSV" action to the Leaderboard, rendered top-right in a .title_bar the way the Money page renders "Sync all to QBO". The export is a page_action, so it runs through the same require_admin! before_action as the page itself — a non-admin cannot pull the data by hitting the URL directly. Stacks::Leaderboard.to_csv emits one row per ranked contributor with the month's average and total repeated on each row, so the file pivots cleanly. Amounts are plain decimals rather than currency strings so spreadsheets read them as numbers. The export honors ?limit=, and the filename records both the limit and the export date. The page now sits under the Money tab and is hidden from the nav for anyone who isn't an admin. Menu visibility is presentation, not access control, so the before_action remains the actual gate; the menu proc uses &. so it stays falsy rather than raising for a logged-out visitor. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/leaderboard.rb | 19 +++++++- .../admin/leaderboard/_leaderboard.html.erb | 8 ++++ lib/stacks/leaderboard.rb | 35 ++++++++++++++ test/integration/admin_leaderboard_test.rb | 47 +++++++++++++++++++ test/lib/stacks/leaderboard_test.rb | 41 ++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/app/admin/leaderboard.rb b/app/admin/leaderboard.rb index 659ab224..214bf024 100644 --- a/app/admin/leaderboard.rb +++ b/app/admin/leaderboard.rb @@ -1,9 +1,16 @@ ActiveAdmin.register_page "Leaderboard" do - menu label: "Leaderboard", priority: 20, if: proc { current_admin_user.is_admin? } + # Nested under the Money tab, and hidden from the nav for anyone who isn't + # an admin. Menu visibility alone isn't access control — require_admin! + # below is what actually blocks a hand-typed URL. + menu label: "Leaderboard", + parent: "Money", + priority: 20, + if: proc { current_admin_user&.is_admin? } # The global AuthorizationAdapter lets project leads through for most pages, # so gate this one explicitly: earnings across the whole collective are - # admin-only. + # admin-only. Applies to every action on this controller, including the CSV + # export. controller do before_action :require_admin! @@ -16,6 +23,14 @@ def require_admin! end end + page_action :export_csv, method: :get do + limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) + + send_data Stacks::Leaderboard.to_csv(limit: limit), + type: "text/csv; charset=utf-8", + disposition: %(attachment; filename="leaderboard-top-#{limit}-#{Date.today.iso8601}.csv") + end + content title: "Leaderboard" do limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb index 8120634d..4ad5db04 100644 --- a/app/views/admin/leaderboard/_leaderboard.html.erb +++ b/app/views/admin/leaderboard/_leaderboard.html.erb @@ -13,6 +13,14 @@ <% end %>
+<%# Export sits top-right, mirroring the Money page's "Sync all to QBO". %> +
+
+
+ <%= link_to "Export CSV", admin_leaderboard_export_csv_path(limit: limit), class: "action_item" %> +
+
+ <% if months.empty? %>
diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb index 9864b023..29df8894 100644 --- a/lib/stacks/leaderboard.rb +++ b/lib/stacks/leaderboard.rb @@ -12,12 +12,23 @@ # ledgers.contributor_id to aggregate "across all their contributor ledgers". # Months are bucketed on invoice_passes.start_of_month, matching the canonical # dating used by ContributorPayout#effective_on_for_display. +require "csv" + module Stacks class Leaderboard DEFAULT_LIMIT = 5 MIN_LIMIT = 1 MAX_LIMIT = 50 + CSV_HEADERS = [ + "Month", + "Rank", + "Contributor", + "Earnings", + "Month Average", + "Month Total", + ].freeze + Entry = Struct.new(:rank, :contributor_id, :display_name, :amount, keyword_init: true) # `average` is the mean across the listed entries only (not the whole @@ -36,6 +47,30 @@ def self.call(limit: DEFAULT_LIMIT) new(limit: limit).call end + # Flat, spreadsheet-friendly rendering: one row per ranked contributor, + # with the month's average and total repeated so the file pivots cleanly. + # Amounts are unformatted decimals (no currency symbols or thousands + # separators) so they land in a spreadsheet as numbers, not text. + def self.to_csv(limit: DEFAULT_LIMIT, months: nil) + groups = months || call(limit: limit) + + CSV.generate do |csv| + csv << CSV_HEADERS + groups.each do |group| + group.entries.each do |entry| + csv << [ + group.start_of_month.strftime("%Y-%m"), + entry.rank, + entry.display_name, + format("%.2f", entry.amount), + format("%.2f", group.average), + format("%.2f", group.total), + ] + end + end + end + end + def initialize(limit: DEFAULT_LIMIT) @limit = limit end diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index 8ae030cb..edfa3cba 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -90,6 +90,53 @@ def contributor_with_payout(email, amount) assert_includes response.body, 'solo@example.com' end + test 'exports CSV as an attachment' do + contributor_with_payout('csvalpha@example.com', 900) + contributor_with_payout('csvbeta@example.com', 300) + sign_in @admin + + get '/admin/leaderboard/export_csv' + + assert_response :success + assert_match %r{text/csv}, response.content_type + assert_match /attachment; filename="leaderboard-top-5-\d{4}-\d{2}-\d{2}\.csv"/, + response.headers['Content-Disposition'] + + rows = CSV.parse(response.body) + assert_equal Stacks::Leaderboard::CSV_HEADERS, rows.first + alpha = rows.find { |r| r[2] == 'csvalpha@example.com' } + assert_equal '900.00', alpha[3] + assert_equal '600.00', alpha[4], 'carries the month average' + end + + test 'CSV export honors ?limit=' do + 3.times { |i| contributor_with_payout("csvlim#{i}@example.com", (i + 1) * 100) } + sign_in @admin + + get '/admin/leaderboard/export_csv?limit=1' + + assert_response :success + body = CSV.parse(response.body).drop(1).select { |r| r[0] == @month.strftime('%Y-%m') } + assert_equal 1, body.size + assert_equal 'csvlim2@example.com', body.first[2] + end + + test 'non-admins cannot export the CSV' do + contributor_with_payout('secret@example.com', 900) + non_admin = AdminUser.create!( + email: "plain-csv-#{SecureRandom.hex(4)}@example.com", + password: 'password12345', + password_confirmation: 'password12345', + roles: [] + ) + sign_in non_admin + + get '/admin/leaderboard/export_csv' + + assert_response :redirect + refute_includes response.body.to_s, 'secret@example.com' + end + test 'non-admins are redirected away' do non_admin = AdminUser.create!( email: "plain-#{SecureRandom.hex(4)}@example.com", diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb index 7ea56797..50c9edf9 100644 --- a/test/lib/stacks/leaderboard_test.rb +++ b/test/lib/stacks/leaderboard_test.rb @@ -149,6 +149,47 @@ def month_group assert_equal "Ada Lovelace", month_group.entries.first.display_name end + test "to_csv emits a header and one row per ranked contributor" do + make_contributor("first@example.com").tap { |c| payout!(c, 900) } + make_contributor("second@example.com").tap { |c| payout!(c, 300) } + + rows = CSV.parse(Stacks::Leaderboard.to_csv(limit: 5)) + + assert_equal Stacks::Leaderboard::CSV_HEADERS, rows.first + row = rows.find { |r| r[2] == "first@example.com" } + assert_equal "2097-03", row[0], "month is ISO-ish for sorting" + assert_equal "1", row[1], "rank" + assert_equal "900.00", row[3], "earnings are plain decimals, not currency strings" + assert_equal "600.00", row[4], "month average repeats on each row" + assert_equal "1200.00", row[5], "month total repeats on each row" + end + + test "to_csv honors the limit" do + 5.times { |i| make_contributor("csv#{i}@example.com").tap { |c| payout!(c, (i + 1) * 100) } } + + rows = CSV.parse(Stacks::Leaderboard.to_csv(limit: 2)) + body = rows.drop(1).select { |r| r[0] == "2097-03" } + + assert_equal 2, body.size + assert_equal ["csv4@example.com", "csv3@example.com"], body.map { |r| r[2] } + end + + test "to_csv escapes names containing commas" do + person = ForecastPerson.create!( + forecast_id: rand(1..2_000_000_000), + email: "comma@example.com", + first_name: "Lovelace, Ada", + last_name: "", + data: {} + ) + payout!(Contributor.create!(forecast_person: person), 100) + + parsed = CSV.parse(Stacks::Leaderboard.to_csv(limit: 5)) + + assert_includes parsed.map { |r| r[2] }, "Lovelace, Ada", + "a comma in a name must survive a CSV round-trip" + end + test "sanitize_limit defaults, clamps, and rejects junk" do assert_equal 5, Stacks::Leaderboard.sanitize_limit(nil) assert_equal 5, Stacks::Leaderboard.sanitize_limit("") From ec4258170bf477e39a8b05072e17b990675d712c Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 15:12:46 -0700 Subject: [PATCH 05/12] refactor(admin): serve Leaderboard CSV the ActiveAdmin way ActiveAdmin's `csv do ... end` DSL lives on ResourceDSL and needs an ActiveRecord-backed collection, so a register_page can't use it (PageDSL exposes only content/page_action/belongs_to). We get the same convention by hand instead of inventing our own: - The index action now responds to .csv, so the download lives at the conventional /admin/leaderboard.csv?limit=N instead of a bespoke /admin/leaderboard/export_csv page_action. - The bespoke "Export CSV" action_item is replaced by the standard #index_footer > .download_links markup ActiveAdmin renders under an index table ("Download: CSV"), so it inherits existing styling and sits where admins already look for it. Access is unchanged in substance: require_admin! still guards every format. Note that AA's own authorize_access! runs first and answers a non-HTML format with 401 rather than a redirect, so the CSV test asserts the security property (never 200, no rows emitted) rather than a specific status code. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/leaderboard.rb | 30 +++++++++++-------- .../admin/leaderboard/_leaderboard.html.erb | 18 ++++++----- test/integration/admin_leaderboard_test.rb | 19 ++++++++---- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/app/admin/leaderboard.rb b/app/admin/leaderboard.rb index 214bf024..124954a2 100644 --- a/app/admin/leaderboard.rb +++ b/app/admin/leaderboard.rb @@ -1,7 +1,7 @@ ActiveAdmin.register_page "Leaderboard" do # Nested under the Money tab, and hidden from the nav for anyone who isn't - # an admin. Menu visibility alone isn't access control — require_admin! - # below is what actually blocks a hand-typed URL. + # an admin. Menu visibility is presentation, not access control — + # require_admin! below is what actually blocks a hand-typed URL. menu label: "Leaderboard", parent: "Money", priority: 20, @@ -9,11 +9,25 @@ # The global AuthorizationAdapter lets project leads through for most pages, # so gate this one explicitly: earnings across the whole collective are - # admin-only. Applies to every action on this controller, including the CSV - # export. + # admin-only. Applies to every format, including the CSV download. controller do before_action :require_admin! + # ActiveAdmin's `csv do ... end` DSL is defined on ResourceDSL and needs an + # ActiveRecord-backed collection, so a register_page can't use it. We get + # the same convention by hand: the index responds to .csv, which keeps the + # familiar /admin/leaderboard.csv URL and lets the standard + # "Download: CSV" footer link work with plain url_for. + def index + return super() unless request.format.csv? + + limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) + + send_data Stacks::Leaderboard.to_csv(limit: limit), + type: "text/csv; charset=utf-8", + disposition: %(attachment; filename="leaderboard-top-#{limit}-#{Date.today.iso8601}.csv") + end + private def require_admin! @@ -23,14 +37,6 @@ def require_admin! end end - page_action :export_csv, method: :get do - limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) - - send_data Stacks::Leaderboard.to_csv(limit: limit), - type: "text/csv; charset=utf-8", - disposition: %(attachment; filename="leaderboard-top-#{limit}-#{Date.today.iso8601}.csv") - end - content title: "Leaderboard" do limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb index 4ad5db04..3d98de41 100644 --- a/app/views/admin/leaderboard/_leaderboard.html.erb +++ b/app/views/admin/leaderboard/_leaderboard.html.erb @@ -13,14 +13,6 @@ <% end %>
-<%# Export sits top-right, mirroring the Money page's "Sync all to QBO". %> -
-
-
- <%= link_to "Export CSV", admin_leaderboard_export_csv_path(limit: limit), class: "action_item" %> -
-
- <% if months.empty? %>
@@ -67,4 +59,14 @@ <% end %> + + <%# Mirrors the #index_footer > .download_links markup ActiveAdmin renders + beneath a standard index table, so the download reads as the convention + it is. Keeps the current ?limit= so you export exactly what you see. %> + <% end %> diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index edfa3cba..6e5d721c 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -60,6 +60,10 @@ def contributor_with_payout(email, amount) assert_includes response.body, %(alpha@example.com), 'links each contributor through to their contributor page' + assert_includes response.body, 'download_links', + 'renders the standard ActiveAdmin download footer' + assert_includes response.body, %(CSV), + 'download link uses the conventional .csv URL and carries the current limit' end test 'defaults to the top 5 and honors ?limit=' do @@ -95,7 +99,7 @@ def contributor_with_payout(email, amount) contributor_with_payout('csvbeta@example.com', 300) sign_in @admin - get '/admin/leaderboard/export_csv' + get '/admin/leaderboard.csv' assert_response :success assert_match %r{text/csv}, response.content_type @@ -113,7 +117,7 @@ def contributor_with_payout(email, amount) 3.times { |i| contributor_with_payout("csvlim#{i}@example.com", (i + 1) * 100) } sign_in @admin - get '/admin/leaderboard/export_csv?limit=1' + get '/admin/leaderboard.csv?limit=1' assert_response :success body = CSV.parse(response.body).drop(1).select { |r| r[0] == @month.strftime('%Y-%m') } @@ -131,10 +135,15 @@ def contributor_with_payout(email, amount) ) sign_in non_admin - get '/admin/leaderboard/export_csv' + get '/admin/leaderboard.csv' - assert_response :redirect - refute_includes response.body.to_s, 'secret@example.com' + # ActiveAdmin's authorize_access! runs first and answers a non-HTML format + # with 401 rather than a redirect. Either way the data must not be served, + # so assert the security property instead of a specific status. + refute_equal 200, response.status, 'must never serve the CSV to a non-admin' + refute_includes response.body.to_s, 'secret@example.com', 'must not leak earnings' + refute_includes response.body.to_s, Stacks::Leaderboard::CSV_HEADERS.join(','), + 'no CSV payload is emitted at all' end test 'non-admins are redirected away' do From 2d340e8aa8b50b73b64dd4a5d96d7a2ee027be63 Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 16:01:33 -0700 Subject: [PATCH 06/12] refactor(leaderboard): drop Month Average / Month Total from the CSV Both are derivable from the exported rows, so repeating them on every line was denormalised noise in a spreadsheet. The CSV is now Month, Rank, Contributor, Earnings. The on-screen pill and table footer still show the average and total, where they're the point. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/stacks/leaderboard.rb | 13 +++++-------- test/integration/admin_leaderboard_test.rb | 3 ++- test/lib/stacks/leaderboard_test.rb | 3 +-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb index 29df8894..017d294a 100644 --- a/lib/stacks/leaderboard.rb +++ b/lib/stacks/leaderboard.rb @@ -25,8 +25,6 @@ class Leaderboard "Rank", "Contributor", "Earnings", - "Month Average", - "Month Total", ].freeze Entry = Struct.new(:rank, :contributor_id, :display_name, :amount, keyword_init: true) @@ -47,10 +45,11 @@ def self.call(limit: DEFAULT_LIMIT) new(limit: limit).call end - # Flat, spreadsheet-friendly rendering: one row per ranked contributor, - # with the month's average and total repeated so the file pivots cleanly. - # Amounts are unformatted decimals (no currency symbols or thousands - # separators) so they land in a spreadsheet as numbers, not text. + # Flat, spreadsheet-friendly rendering: one row per ranked contributor. + # Month average and total are deliberately omitted — they're derivable from + # these rows, so repeating them would just be denormalised noise in a + # spreadsheet. Amounts are unformatted decimals (no currency symbols or + # thousands separators) so they land as numbers, not text. def self.to_csv(limit: DEFAULT_LIMIT, months: nil) groups = months || call(limit: limit) @@ -63,8 +62,6 @@ def self.to_csv(limit: DEFAULT_LIMIT, months: nil) entry.rank, entry.display_name, format("%.2f", entry.amount), - format("%.2f", group.average), - format("%.2f", group.total), ] end end diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index 6e5d721c..36b3ee35 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -110,7 +110,8 @@ def contributor_with_payout(email, amount) assert_equal Stacks::Leaderboard::CSV_HEADERS, rows.first alpha = rows.find { |r| r[2] == 'csvalpha@example.com' } assert_equal '900.00', alpha[3] - assert_equal '600.00', alpha[4], 'carries the month average' + assert_equal %w[Month Rank Contributor Earnings], rows.first, + 'no month average/total columns' end test 'CSV export honors ?limit=' do diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb index 50c9edf9..fd130b04 100644 --- a/test/lib/stacks/leaderboard_test.rb +++ b/test/lib/stacks/leaderboard_test.rb @@ -160,8 +160,7 @@ def month_group assert_equal "2097-03", row[0], "month is ISO-ish for sorting" assert_equal "1", row[1], "rank" assert_equal "900.00", row[3], "earnings are plain decimals, not currency strings" - assert_equal "600.00", row[4], "month average repeats on each row" - assert_equal "1200.00", row[5], "month total repeats on each row" + assert_equal 4, row.size, "no month average/total columns" end test "to_csv honors the limit" do From f70e4db16e68c55154e8f5187eca9c0fbac85973 Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 17:16:15 -0700 Subject: [PATCH 07/12] feat(admin): add MSO Compensation panel above each Leaderboard month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placeholder section (body is TKTKTK) sitting between each month's heading and its table. Uses the same markup ActiveAdmin's Arbre `panel` builder emits — div.panel > h3 + div.panel_contents — so it inherits the standard panel styling from an ERB template, where the Arbre builder isn't available. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/admin/leaderboard/_leaderboard.html.erb | 9 +++++++++ test/integration/admin_leaderboard_test.rb | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb index 3d98de41..b7124108 100644 --- a/app/views/admin/leaderboard/_leaderboard.html.erb +++ b/app/views/admin/leaderboard/_leaderboard.html.erb @@ -35,6 +35,15 @@
+ <%# Same markup ActiveAdmin's Arbre `panel` builder emits, so it picks up + the standard panel styling from an ERB template. %> +
+

MSO Compensation

+
+

TKTKTK

+
+
+ diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index 36b3ee35..c7503d18 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -60,6 +60,10 @@ def contributor_with_payout(email, amount) assert_includes response.body, %(alpha@example.com), 'links each contributor through to their contributor page' + assert_includes response.body, '

MSO Compensation

', + 'each month gets an MSO Compensation panel' + assert_includes response.body, 'panel_contents', + 'the panel uses the standard ActiveAdmin panel markup' assert_includes response.body, 'download_links', 'renders the standard ActiveAdmin download footer' assert_includes response.body, %(CSV), From 029d8b03ba85875baca220e89fe920774688910b Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 17:23:49 -0700 Subject: [PATCH 08/12] feat(admin): render MSO tier compensation from each month's average Fills the MSO Compensation panel with the three leadership tiers, each paying the month's average top-N earner times its multiplier: Heads 0.8x, Chiefs 1x, Founders 1.2x. Tiers live in Stacks::Leaderboard::MSO_TIERS with a mso_compensation helper rather than being hardcoded in the view, so the multipliers are single-sourced and unit-testable. The multiplier label strips a trailing .0 so Chiefs reads "1x" rather than "1.0x". This is the other half of the Trueup exclusion: a Trueup is the payment that lifts one of these roles up to the figure computed here, so counting Trueups as earnings would feed the benchmark back into itself. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/leaderboard/_leaderboard.html.erb | 11 +++++++- lib/stacks/leaderboard.rb | 26 +++++++++++++++++++ test/integration/admin_leaderboard_test.rb | 6 +++++ test/lib/stacks/leaderboard_test.rb | 14 ++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb index b7124108..cb325888 100644 --- a/app/views/admin/leaderboard/_leaderboard.html.erb +++ b/app/views/admin/leaderboard/_leaderboard.html.erb @@ -40,7 +40,16 @@

MSO Compensation

-

TKTKTK

+
+ + <% Stacks::Leaderboard.mso_compensation(group.average).each_with_index do |tier, index| %> + "> + + + + <% end %> + +
<%= tier.label %> (<%= tier.multiplier_label %>)<%= number_to_currency(tier.amount) %>
diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb index 017d294a..cc8467ad 100644 --- a/lib/stacks/leaderboard.rb +++ b/lib/stacks/leaderboard.rb @@ -27,6 +27,32 @@ class Leaderboard "Earnings", ].freeze + # Leadership compensation tiers. Each tier earns the month's average + # top-N earner times its multiplier — which is exactly why Trueups are + # excluded from the earnings above: a Trueup is the payment that lifts + # one of these roles up to the figure derived here, so letting it count + # as earnings would feed the benchmark back into itself. + MSO_TIERS = [ + ["Heads", BigDecimal("0.8")], + ["Chiefs", BigDecimal("1")], + ["Founders", BigDecimal("1.2")], + ].freeze + + MsoTier = Struct.new(:label, :multiplier, :multiplier_label, :amount, keyword_init: true) + + # What each leadership tier is paid for a month, given that month's + # average top-N earner. + def self.mso_compensation(average) + MSO_TIERS.map do |label, multiplier| + MsoTier.new( + label: label, + multiplier: multiplier, + multiplier_label: "#{multiplier.to_s("F").sub(/\.0+\z/, "")}x", + amount: average * multiplier + ) + end + end + Entry = Struct.new(:rank, :contributor_id, :display_name, :amount, keyword_init: true) # `average` is the mean across the listed entries only (not the whole diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index c7503d18..e93caf1b 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -64,6 +64,12 @@ def contributor_with_payout(email, amount) 'each month gets an MSO Compensation panel' assert_includes response.body, 'panel_contents', 'the panel uses the standard ActiveAdmin panel markup' + # Month average is $650.00 (900 + 400, over 2 listed earners). + assert_includes response.body, 'Heads (0.8x)' + assert_includes response.body, '$520.00', 'Heads = 650 x 0.8' + assert_includes response.body, 'Chiefs (1x)' + assert_includes response.body, 'Founders (1.2x)' + assert_includes response.body, '$780.00', 'Founders = 650 x 1.2' assert_includes response.body, 'download_links', 'renders the standard ActiveAdmin download footer' assert_includes response.body, %(CSV), diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb index fd130b04..e5b7af77 100644 --- a/test/lib/stacks/leaderboard_test.rb +++ b/test/lib/stacks/leaderboard_test.rb @@ -189,6 +189,20 @@ def month_group "a comma in a name must survive a CSV round-trip" end + test "mso_compensation scales the month average by each tier multiplier" do + tiers = Stacks::Leaderboard.mso_compensation(BigDecimal("1000")) + + assert_equal ["Heads", "Chiefs", "Founders"], tiers.map(&:label) + assert_equal ["0.8x", "1x", "1.2x"], tiers.map(&:multiplier_label), + "1x renders without a trailing .0" + assert_equal [BigDecimal("800"), BigDecimal("1000"), BigDecimal("1200")], + tiers.map(&:amount) + end + + test "mso_compensation handles a zero average" do + assert_equal [0, 0, 0], Stacks::Leaderboard.mso_compensation(BigDecimal(0)).map { |t| t.amount.to_i } + end + test "sanitize_limit defaults, clamps, and rejects junk" do assert_equal 5, Stacks::Leaderboard.sanitize_limit(nil) assert_equal 5, Stacks::Leaderboard.sanitize_limit("") From d8f4430c72295c7db53d70ff5c125fd3eabc8439 Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 17:30:02 -0700 Subject: [PATCH 09/12] feat(leaderboard): move Heads tier to 0.75x Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/stacks/leaderboard.rb | 2 +- test/integration/admin_leaderboard_test.rb | 4 ++-- test/lib/stacks/leaderboard_test.rb | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb index cc8467ad..cd103caf 100644 --- a/lib/stacks/leaderboard.rb +++ b/lib/stacks/leaderboard.rb @@ -33,7 +33,7 @@ class Leaderboard # one of these roles up to the figure derived here, so letting it count # as earnings would feed the benchmark back into itself. MSO_TIERS = [ - ["Heads", BigDecimal("0.8")], + ["Heads", BigDecimal("0.75")], ["Chiefs", BigDecimal("1")], ["Founders", BigDecimal("1.2")], ].freeze diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index e93caf1b..aa0280af 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -65,8 +65,8 @@ def contributor_with_payout(email, amount) assert_includes response.body, 'panel_contents', 'the panel uses the standard ActiveAdmin panel markup' # Month average is $650.00 (900 + 400, over 2 listed earners). - assert_includes response.body, 'Heads (0.8x)' - assert_includes response.body, '$520.00', 'Heads = 650 x 0.8' + assert_includes response.body, 'Heads (0.75x)' + assert_includes response.body, '$487.50', 'Heads = 650 x 0.75' assert_includes response.body, 'Chiefs (1x)' assert_includes response.body, 'Founders (1.2x)' assert_includes response.body, '$780.00', 'Founders = 650 x 1.2' diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb index e5b7af77..a354ecfd 100644 --- a/test/lib/stacks/leaderboard_test.rb +++ b/test/lib/stacks/leaderboard_test.rb @@ -193,9 +193,9 @@ def month_group tiers = Stacks::Leaderboard.mso_compensation(BigDecimal("1000")) assert_equal ["Heads", "Chiefs", "Founders"], tiers.map(&:label) - assert_equal ["0.8x", "1x", "1.2x"], tiers.map(&:multiplier_label), + assert_equal ["0.75x", "1x", "1.2x"], tiers.map(&:multiplier_label), "1x renders without a trailing .0" - assert_equal [BigDecimal("800"), BigDecimal("1000"), BigDecimal("1200")], + assert_equal [BigDecimal("750"), BigDecimal("1000"), BigDecimal("1200")], tiers.map(&:amount) end From 7ed733a887606b572f1bfef89124bd4745ec742e Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 18:10:56 -0700 Subject: [PATCH 10/12] feat(leaderboard): add SVP tier at 0.85x, drop Heads to 0.7x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVPs sit exactly halfway between Heads and Chiefs — (0.7 + 1.0) / 2 — which a test now asserts against the constant rather than the literal, so the midpoint relationship survives any future change to either neighbour. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/stacks/leaderboard.rb | 4 +++- test/integration/admin_leaderboard_test.rb | 6 ++++-- test/lib/stacks/leaderboard_test.rb | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb index cd103caf..8350c095 100644 --- a/lib/stacks/leaderboard.rb +++ b/lib/stacks/leaderboard.rb @@ -32,8 +32,10 @@ class Leaderboard # excluded from the earnings above: a Trueup is the payment that lifts # one of these roles up to the figure derived here, so letting it count # as earnings would feed the benchmark back into itself. + # SVPs sit exactly halfway between Heads and Chiefs: (0.7 + 1.0) / 2. MSO_TIERS = [ - ["Heads", BigDecimal("0.75")], + ["Heads", BigDecimal("0.7")], + ["SVPs", BigDecimal("0.85")], ["Chiefs", BigDecimal("1")], ["Founders", BigDecimal("1.2")], ].freeze diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index aa0280af..739dba46 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -65,8 +65,10 @@ def contributor_with_payout(email, amount) assert_includes response.body, 'panel_contents', 'the panel uses the standard ActiveAdmin panel markup' # Month average is $650.00 (900 + 400, over 2 listed earners). - assert_includes response.body, 'Heads (0.75x)' - assert_includes response.body, '$487.50', 'Heads = 650 x 0.75' + assert_includes response.body, 'Heads (0.7x)' + assert_includes response.body, '$455.00', 'Heads = 650 x 0.7' + assert_includes response.body, 'SVPs (0.85x)' + assert_includes response.body, '$552.50', 'SVPs = 650 x 0.85' assert_includes response.body, 'Chiefs (1x)' assert_includes response.body, 'Founders (1.2x)' assert_includes response.body, '$780.00', 'Founders = 650 x 1.2' diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb index a354ecfd..8adcdb12 100644 --- a/test/lib/stacks/leaderboard_test.rb +++ b/test/lib/stacks/leaderboard_test.rb @@ -192,15 +192,23 @@ def month_group test "mso_compensation scales the month average by each tier multiplier" do tiers = Stacks::Leaderboard.mso_compensation(BigDecimal("1000")) - assert_equal ["Heads", "Chiefs", "Founders"], tiers.map(&:label) - assert_equal ["0.75x", "1x", "1.2x"], tiers.map(&:multiplier_label), + assert_equal ["Heads", "SVPs", "Chiefs", "Founders"], tiers.map(&:label) + assert_equal ["0.7x", "0.85x", "1x", "1.2x"], tiers.map(&:multiplier_label), "1x renders without a trailing .0" - assert_equal [BigDecimal("750"), BigDecimal("1000"), BigDecimal("1200")], + assert_equal [BigDecimal("700"), BigDecimal("850"), BigDecimal("1000"), BigDecimal("1200")], tiers.map(&:amount) end + test "SVPs sit exactly halfway between Heads and Chiefs" do + by_label = Stacks::Leaderboard::MSO_TIERS.to_h + midpoint = (by_label.fetch("Heads") + by_label.fetch("Chiefs")) / 2 + + assert_equal midpoint, by_label.fetch("SVPs") + end + test "mso_compensation handles a zero average" do - assert_equal [0, 0, 0], Stacks::Leaderboard.mso_compensation(BigDecimal(0)).map { |t| t.amount.to_i } + amounts = Stacks::Leaderboard.mso_compensation(BigDecimal(0)).map { |t| t.amount.to_i } + assert_equal [0] * Stacks::Leaderboard::MSO_TIERS.size, amounts end test "sanitize_limit defaults, clamps, and rejects junk" do From 8edbbc7c16cf41ec4559f2ec191833c499a9a52e Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 18:19:39 -0700 Subject: [PATCH 11/12] feat(leaderboard): rename Founders to Principals, set it to 1.15x Renames the top MSO tier and moves it to 1.15x so the ladder steps evenly: 0.7 / 0.85 / 1.0 / 1.15, a uniform +0.15 between neighbours. The step is named (MSO_TIER_STEP) and asserted across the whole ladder, so a future change to any single tier that breaks the even spacing fails loudly. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/stacks/leaderboard.rb | 6 ++++-- test/integration/admin_leaderboard_test.rb | 4 ++-- test/lib/stacks/leaderboard_test.rb | 14 +++++++------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb index 8350c095..913e78b4 100644 --- a/lib/stacks/leaderboard.rb +++ b/lib/stacks/leaderboard.rb @@ -32,14 +32,16 @@ class Leaderboard # excluded from the earnings above: a Trueup is the payment that lifts # one of these roles up to the figure derived here, so letting it count # as earnings would feed the benchmark back into itself. - # SVPs sit exactly halfway between Heads and Chiefs: (0.7 + 1.0) / 2. + # A uniform ladder: every tier is +0.15 above the one below it. MSO_TIERS = [ ["Heads", BigDecimal("0.7")], ["SVPs", BigDecimal("0.85")], ["Chiefs", BigDecimal("1")], - ["Founders", BigDecimal("1.2")], + ["Principals", BigDecimal("1.15")], ].freeze + MSO_TIER_STEP = BigDecimal("0.15") + MsoTier = Struct.new(:label, :multiplier, :multiplier_label, :amount, keyword_init: true) # What each leadership tier is paid for a month, given that month's diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index 739dba46..2efd5ea5 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -70,8 +70,8 @@ def contributor_with_payout(email, amount) assert_includes response.body, 'SVPs (0.85x)' assert_includes response.body, '$552.50', 'SVPs = 650 x 0.85' assert_includes response.body, 'Chiefs (1x)' - assert_includes response.body, 'Founders (1.2x)' - assert_includes response.body, '$780.00', 'Founders = 650 x 1.2' + assert_includes response.body, 'Principals (1.15x)' + assert_includes response.body, '$747.50', 'Principals = 650 x 1.15' assert_includes response.body, 'download_links', 'renders the standard ActiveAdmin download footer' assert_includes response.body, %(CSV), diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb index 8adcdb12..3f291f98 100644 --- a/test/lib/stacks/leaderboard_test.rb +++ b/test/lib/stacks/leaderboard_test.rb @@ -192,18 +192,18 @@ def month_group test "mso_compensation scales the month average by each tier multiplier" do tiers = Stacks::Leaderboard.mso_compensation(BigDecimal("1000")) - assert_equal ["Heads", "SVPs", "Chiefs", "Founders"], tiers.map(&:label) - assert_equal ["0.7x", "0.85x", "1x", "1.2x"], tiers.map(&:multiplier_label), + assert_equal ["Heads", "SVPs", "Chiefs", "Principals"], tiers.map(&:label) + assert_equal ["0.7x", "0.85x", "1x", "1.15x"], tiers.map(&:multiplier_label), "1x renders without a trailing .0" - assert_equal [BigDecimal("700"), BigDecimal("850"), BigDecimal("1000"), BigDecimal("1200")], + assert_equal [BigDecimal("700"), BigDecimal("850"), BigDecimal("1000"), BigDecimal("1150")], tiers.map(&:amount) end - test "SVPs sit exactly halfway between Heads and Chiefs" do - by_label = Stacks::Leaderboard::MSO_TIERS.to_h - midpoint = (by_label.fetch("Heads") + by_label.fetch("Chiefs")) / 2 + test "the tier ladder steps by a uniform +0.15" do + steps = Stacks::Leaderboard::MSO_TIERS.map(&:last).each_cons(2).map { |a, b| b - a } - assert_equal midpoint, by_label.fetch("SVPs") + assert_equal [Stacks::Leaderboard::MSO_TIER_STEP] * steps.size, steps, + "every tier should sit one even step above the one below it" end test "mso_compensation handles a zero average" do From 6336718f3d857c0b48a31a8804b7fc5adc5ec84c Mon Sep 17 00:00:00 2001 From: hhff Date: Mon, 20 Jul 2026 18:22:55 -0700 Subject: [PATCH 12/12] feat(leaderboard): offer 4 as a limit toggle Adds a top-4 view alongside 3/5/10/20. Top-4 is the basis the original leadership compensation spreadsheet used, so this makes the page directly comparable to it without hand-editing the query string. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/admin/leaderboard/_leaderboard.html.erb | 2 +- test/integration/admin_leaderboard_test.rb | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb index cb325888..b819c368 100644 --- a/app/views/admin/leaderboard/_leaderboard.html.erb +++ b/app/views/admin/leaderboard/_leaderboard.html.erb @@ -4,7 +4,7 @@ <%# Limit toggles, styled as the same nag-pill tab bar the Money page uses. %>
- <% [3, 5, 10, 20].each do |n| %> + <% [3, 4, 5, 10, 20].each do |n| %> <%= link_to admin_leaderboard_path(limit: n), style: "margin-right: 6px" do %>

<%= n %> diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb index 2efd5ea5..d8bb9705 100644 --- a/test/integration/admin_leaderboard_test.rb +++ b/test/integration/admin_leaderboard_test.rb @@ -57,6 +57,10 @@ def contributor_with_payout(email, amount) assert_includes response.body, 'avg of top 2' assert_includes response.body, 'index_table', 'uses the shared ActiveAdmin table styling' assert_includes response.body, 'nag pill complete', 'marks the active limit toggle' + [3, 4, 5, 10, 20].each do |n| + assert_includes response.body, %(href="/admin/leaderboard?limit=#{n}"), + "offers a limit toggle for #{n}" + end assert_includes response.body, %(alpha@example.com), 'links each contributor through to their contributor page'