Skip to content
Merged
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
62 changes: 53 additions & 9 deletions app/controllers/admin/metrics_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Admin::MetricsController < Admin::ApplicationController
COIN_USD_VALUE = 1.0
TOP_COIN_HOLDERS_LIMIT = 100

def index
days = (params[:days].presence || 30).to_i.clamp(7, 180)
Expand Down Expand Up @@ -111,21 +112,17 @@ def index
total_coins_all = tier_breakdown.sum { |t| t[:total_coins] }
avg_coins_per_hour = total_hours_all.positive? ? (total_coins_all / total_hours_all).round(2) : 0

kept_user_ids = User.kept.select(:id)
balance_earned = Project.kept.approved
.where.not(id: ProjectPayout.select(:project_id))
.where(user_id: kept_user_ids)
.includes(:devlogs, :user)
.sum(&:coins_earned) +
ProjectPayout.joins(:project).merge(Project.kept).where(user_id: kept_user_ids).sum(:coins).to_f
balance_adjustments = CoinAdjustment.where(user_id: kept_user_ids).sum(:amount).to_f
balance_spent = Order.where(status: %i[pending approved fulfilled], user_id: kept_user_ids).sum(:coin_cost).to_f
earned_by_user, adjusted_by_user, spent_by_user = coin_totals_by_user
balance_earned = earned_by_user.values.sum
balance_adjustments = adjusted_by_user.values.sum
balance_spent = spent_by_user.values.sum
coins_in_accounts = {
earned: balance_earned.round(2),
adjustments: balance_adjustments.round(2),
spent: balance_spent.round(2),
balance: (balance_earned + balance_adjustments - balance_spent).round(2)
}
top_holders = top_coin_holders(earned_by_user, adjusted_by_user, spent_by_user)

approved_referrals_count = Referral.approved.count
referral_per_unit = Referral::PAYOUT_AMOUNT + Referral::PRIZE_POOL_CONTRIBUTION
Expand Down Expand Up @@ -235,6 +232,7 @@ def index
grand_total: (total_coins_all + referral_economy[:total_coins] + reel_economy[:total_coins]).round(2),
in_accounts: coins_in_accounts
},
top_coin_holders: top_holders,
referral_economy: referral_economy,
reel_economy: reel_economy,
location_distribution: location_distribution,
Expand All @@ -246,6 +244,52 @@ def index

private

def coin_totals_by_user
kept_user_ids = User.kept.select(:id)

earned = Hash.new(0.0)
Project.kept.approved
.where.not(id: ProjectPayout.select(:project_id))
.where(user_id: kept_user_ids)
.includes(:devlogs, :user)
.each { |project| earned[project.user_id] += project.coins_earned }
ProjectPayout.joins(:project).merge(Project.kept)
.where(user_id: kept_user_ids)
.group(:user_id)
.sum(:coins)
.each { |user_id, coins| earned[user_id] += coins.to_f }

adjusted = CoinAdjustment.where(user_id: kept_user_ids).group(:user_id).sum(:amount).transform_values(&:to_f)
spent = Order.where(status: %i[pending approved fulfilled], user_id: kept_user_ids)
.group(:user_id).sum(:coin_cost).transform_values(&:to_f)

[ earned, adjusted, spent ]
end

def top_coin_holders(earned, adjusted, spent, limit: TOP_COIN_HOLDERS_LIMIT)
balances = (earned.keys + adjusted.keys + spent.keys).uniq.to_h do |user_id|
[ user_id, (earned[user_id].to_f + adjusted[user_id].to_f - spent[user_id].to_f).round(2) ]
end

ranked = balances.sort_by { |user_id, balance| [ -balance, user_id ] }.first(limit)
users = User.kept.where(id: ranked.map(&:first)).index_by(&:id)

ranked.filter_map do |user_id, balance|
user = users[user_id]
next unless user

{
id: user.id,
display_name: user.display_name,
avatar: user.avatar,
balance: balance,
earned: earned[user_id].to_f.round(2),
adjusted: adjusted[user_id].to_f.round(2),
spent: spent[user_id].to_f.round(2)
}
end
end

# Review throughput for completed reviews in the given window:
# - avg_active_seconds: heartbeat-measured hands-on review time per review
# - avg_wall_seconds: open → decision wall-clock per review
Expand Down
64 changes: 63 additions & 1 deletion app/javascript/pages/Admin/Metrics/Index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { router } from '@inertiajs/react'
import { Link, router } from '@inertiajs/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/admin/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/admin/ui/table'
import { cn } from '@/components/admin/lib/cn'
Expand Down Expand Up @@ -83,6 +83,16 @@ interface CoinEconomy {
}
}

interface TopCoinHolder {
id: number
display_name: string
avatar: string
balance: number
earned: number
adjusted: number
spent: number
}

interface ReferralEconomy {
count: number
per_unit: number
Expand Down Expand Up @@ -210,6 +220,7 @@ export default function AdminMetricsIndex({
payouts,
tier_breakdown,
coin_economy,
top_coin_holders,
referral_economy,
reel_economy,
location_distribution,
Expand All @@ -227,6 +238,7 @@ export default function AdminMetricsIndex({
payouts: Payouts
tier_breakdown: TierRow[]
coin_economy: CoinEconomy
top_coin_holders: TopCoinHolder[]
referral_economy: ReferralEconomy
reel_economy: ReelEconomy
location_distribution: LocationDistribution
Expand Down Expand Up @@ -605,6 +617,56 @@ export default function AdminMetricsIndex({
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Top coin holders</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
The {top_coin_holders.length} highest current balances (earned + adjustments − spent, pending orders
included).
</p>
{top_coin_holders.length === 0 ? (
<p className="text-xs text-muted-foreground">No balances yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Builder</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead className="text-right">Earned</TableHead>
<TableHead className="text-right">Adjustments</TableHead>
<TableHead className="text-right">Spent</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{top_coin_holders.map((holder, i) => (
<TableRow key={holder.id}>
<TableCell className="text-muted-foreground font-mono tabular-nums">{i + 1}</TableCell>
<TableCell>
<Link
href={`/admin/users/${holder.id}`}
className="flex items-center gap-2 hover:underline min-w-0"
>
<img src={holder.avatar} alt="" className="size-6 rounded-full shrink-0" />
<span className="truncate">{holder.display_name}</span>
</Link>
</TableCell>
<TableCell className="text-right font-semibold tabular-nums">
{holder.balance.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">{holder.earned.toLocaleString()}</TableCell>
<TableCell className="text-right tabular-nums">{holder.adjusted.toLocaleString()}</TableCell>
<TableCell className="text-right tabular-nums">{holder.spent.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Coin rate by tier</CardTitle>
Expand Down
Loading