Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/admin/leaderboard.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
ActiveAdmin.register_page "Leaderboard" do
# Nested under the Money tab, and hidden from the nav for anyone who isn't
# 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,
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. 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!
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])

render(partial: "leaderboard", locals: {
limit: limit,
months: Stacks::Leaderboard.call(limit: limit),
})
end
end
90 changes: 90 additions & 0 deletions app/views/admin/leaderboard/_leaderboard.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<%# 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. %>

<%# Limit toggles, styled as the same nag-pill tab bar the Money page uses. %>
<div style="margin-bottom: 32px;">
<% [3, 4, 5, 10, 20].each do |n| %>
<%= link_to admin_leaderboard_path(limit: n), style: "margin-right: 6px" do %>
<p class="nag pill <%= 'complete' if n == limit %>" style="margin-bottom: 0px;margin-right: 6px;position: relative;">
<%= n %>
</p>
<% end %>
<% end %>
</div>

<% if months.empty? %>
<div class="dashboard-modules table index_table index">
<div class="dashboard-module">
<div class="module-body factoid-parent">
<p style="margin: 20px 0;"><em>No contributor payouts recorded yet.</em></p>
</div>
</div>
</div>
<% else %>
<% months.each do |group| %>
<div class="title_bar" id="title_bar" style="padding: 40px 0px 20px 0px;">
<div id="titlebar_left">
<h2 id="page_title"><%= group.start_of_month.strftime("%B %Y") %></h2>
</div>
<div id="titlebar_right">
<span class="pill complete">
<%= number_to_currency(group.average) %>
<span class="split">avg of top <%= group.entries.size %></span>
</span>
</div>
</div>

<%# Same markup ActiveAdmin's Arbre `panel` builder emits, so it picks up
the standard panel styling from an ERB template. %>
<div class="panel">
<h3>MSO Compensation</h3>
<div class="panel_contents">
<table border="0" cellspacing="0" cellpadding="0" class="index_table index" style="width: 100%;">
<tbody>
<% Stacks::Leaderboard.mso_compensation(group.average).each_with_index do |tier, index| %>
<tr class="<%= index.even? ? "even" : "odd" %>">
<td class="col"><%= tier.label %> (<%= tier.multiplier_label %>)</td>
<td class="col text-right"><%= number_to_currency(tier.amount) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

<table border="0" cellspacing="0" cellpadding="0" class="index_table index" style="table-layout: fixed; width: 100%;">
<thead>
<tr>
<th class="col" style="width: 4em;">#</th>
<th class="col">Contributor</th>
<th class="col text-right">Earnings</th>
</tr>
</thead>
<tbody>
<% group.entries.each_with_index do |entry, index| %>
<tr class="<%= index.even? ? "even" : "odd" %>">
<td class="col"><%= entry.rank %></td>
<td class="col"><%= link_to entry.display_name, admin_contributor_path(entry.contributor_id) %></td>
<td class="col text-right"><%= number_to_currency(entry.amount) %></td>
</tr>
<% end %>
<tr class="<%= group.entries.size.even? ? "even" : "odd" %>">
<td class="col"></td>
<td class="col"><strong>Total</strong></td>
<td class="col text-right"><strong><%= number_to_currency(group.total) %></strong></td>
</tr>
</tbody>
</table>
<% 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. %>
<div id="index_footer">
<div class="download_links">
<span><%= I18n.t("active_admin.download") %></span>
<%= link_to "CSV", admin_leaderboard_path(limit: limit, format: :csv) %>
</div>
</div>
<% end %>
174 changes: 174 additions & 0 deletions lib/stacks/leaderboard.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# 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.
require "csv"

module Stacks
class Leaderboard
DEFAULT_LIMIT = 5
MIN_LIMIT = 1
MAX_LIMIT = 50

CSV_HEADERS = [
"Month",
"Rank",
"Contributor",
"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.
# 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")],
["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
# 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
# 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)
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

# 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)

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),
]
end
end
end
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

total = entries.sum(&:amount)

MonthGroup.new(
start_of_month: month,
entries: entries,
total: total,
average: entries.empty? ? BigDecimal(0) : (total / entries.size)
)
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
Loading