Admin Analytics, Source Tracking & DRY Management Views — 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 the five waves of work defined in docs/superpowers/specs/2026-05-25-admin-analytics-and-source-tracking-design.md: signup-source attribution, a per-day analytics store, a reusable admin management view pattern, 17 resource management views, and admin Dashboard + Leaderboard pages.
Architecture: A new daily_metrics table written by per-resource Analytics::*Calculator classes via a nightly job. A new Admin::BaseManagementController + 4 shared partials drive every resource admin page. Signup attribution is captured to a signed cookie on landing pages and copied to users.signup_* columns on registration.
Tech Stack: Rails 8, Minitest, SQLite, SolidQueue (recurring.yml), Hotwire (Turbo + Stimulus), Tailwind, Pagy, Ransack, ApexCharts, Pretender.
Conventions:
- All work follows
AGENTS.mdpatterns (noapp/services/; namespaced model classes underapp/models/<domain>/). - Tests: Minitest IntegrationTest + ModelTest. Fixtures already exist for
users(users(:admin),users(:member)). - Use
t.jsonnott.jsonb(SQLite). - Pagy uses
limit:, notitems:. - Commit after every passing test cluster.
Wave 1 — Signup Source Tracking
Task 1.1 — Migration: add attribution columns to users
Files:
-
Create:
db/migrate/<timestamp>_add_signup_attribution_to_users.rb -
Step 1: Generate the migration
Run: bin/rails g migration AddSignupAttributionToUsers signup_utm_source:string signup_utm_medium:string signup_utm_campaign:string signup_referrer_domain:string signup_affiliate:references
- Step 2: Edit the generated migration to add indexes and the foreign key target
Replace the migration body with:
class AddSignupAttributionToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :signup_utm_source, :string
add_column :users, :signup_utm_medium, :string
add_column :users, :signup_utm_campaign, :string
add_column :users, :signup_referrer_domain, :string
add_reference :users, :signup_affiliate, foreign_key: { to_table: :affiliates }, null: true
add_index :users, :signup_utm_source
add_index :users, :signup_utm_campaign
add_index :users, :signup_referrer_domain
end
end
- Step 3: Run the migration
Run: bin/rails db:migrate
Expected: 5 columns + 1 reference added; schema.rb updated.
- Step 4: Commit
git add db/migrate db/schema.rb
git commit -m "Add signup attribution columns to users"
Task 1.2 — AttributionCapture concern
Files:
-
Create:
app/controllers/concerns/attribution_capture.rb -
Step 1: Write the concern
module AttributionCapture
extend ActiveSupport::Concern
COOKIE_KEY = :signup_attribution
included do
before_action :capture_attribution
end
private
def capture_attribution
return if cookies.signed[COOKIE_KEY].present?
payload = {
utm_source: params[:utm_source].presence,
utm_medium: params[:utm_medium].presence,
utm_campaign: params[:utm_campaign].presence,
referrer: extract_referrer_domain,
affiliate: params[:ref].presence || cookies[:affiliate_code]
}.compact
return if payload.empty?
cookies.signed[COOKIE_KEY] = {
value: payload,
expires: 30.days.from_now,
httponly: true
}
end
def extract_referrer_domain
return nil if request.referrer.blank?
URI.parse(request.referrer).host
rescue URI::InvalidURIError
nil
end
end
- Step 2: Commit
git add app/controllers/concerns/attribution_capture.rb
git commit -m "Add AttributionCapture concern for signup attribution"
Task 1.3 — Mount the concern on landing controllers
Files:
- Modify:
app/controllers/static_controller.rb -
Modify:
app/controllers/users/registrations_controller.rb - Step 1: Include the concern in StaticController
At the top of the class (after skip_before_action), add:
include AttributionCapture
- Step 2: Include the concern in Users::RegistrationsController
At the top of the class, add:
include AttributionCapture
(So that if someone lands directly on /users/sign_up?utm_source=... the cookie is still captured.)
- Step 3: Commit
git add app/controllers/static_controller.rb app/controllers/users/registrations_controller.rb
git commit -m "Mount AttributionCapture on landing controllers"
Task 1.4 — Test the concern
Files:
-
Create:
test/integration/attribution_capture_test.rb -
Step 1: Write the integration test
require "test_helper"
class AttributionCaptureTest < ActionDispatch::IntegrationTest
test "captures UTM params from landing URL into signed cookie" do
get "/?utm_source=newsletter&utm_medium=email&utm_campaign=may2026"
assert_response :success
jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
payload = jar.signed[:signup_attribution]
assert_equal "newsletter", payload["utm_source"]
assert_equal "email", payload["utm_medium"]
assert_equal "may2026", payload["utm_campaign"]
end
test "does not overwrite existing attribution cookie" do
get "/?utm_source=first"
get "/?utm_source=second"
jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
payload = jar.signed[:signup_attribution]
assert_equal "first", payload["utm_source"]
end
test "does nothing when no attribution params present" do
get "/"
assert_nil cookies[:signup_attribution]
end
end
- Step 2: Run the test
Run: bin/rails test test/integration/attribution_capture_test.rb
Expected: 3 passing.
- Step 3: Commit
git add test/integration/attribution_capture_test.rb
git commit -m "Test AttributionCapture concern"
Task 1.5 — Persist attribution at registration
Files:
-
Modify:
app/controllers/users/registrations_controller.rb -
Step 1: Open the file and find the existing
createaction
Run: bin/rails routes | grep user_registration — confirm POST /users maps to users/registrations#create.
If create is not overridden in the file, override it. Otherwise, add the post-save block.
- Step 2: Override
createto persist attribution
def create
super do |user|
next unless user.persisted?
persist_signup_attribution(user)
end
end
private
def persist_signup_attribution(user)
payload = cookies.signed[AttributionCapture::COOKIE_KEY] || {}
return if payload.blank?
user.update_columns(
signup_utm_source: payload["utm_source"],
signup_utm_medium: payload["utm_medium"],
signup_utm_campaign: payload["utm_campaign"],
signup_referrer_domain: payload["referrer"],
signup_affiliate_id: payload["affiliate"] && Affiliate.find_by(code: payload["affiliate"])&.id
)
cookies.delete(AttributionCapture::COOKIE_KEY)
end
- Step 3: Write a test
Append to test/integration/attribution_capture_test.rb:
test "registration persists captured attribution to the user row" do
get "/?utm_source=newsletter&utm_campaign=may2026"
assert_difference -> { User.count }, 1 do
post user_registration_path, params: {
user: {
email: "[email protected]",
password: "password123",
password_confirmation: "password123",
username: "newuser_#{SecureRandom.hex(3)}"
}
}
end
user = User.find_by(email: "[email protected]")
assert_equal "newsletter", user.signup_utm_source
assert_equal "may2026", user.signup_utm_campaign
end
- Step 4: Run all attribution tests
Run: bin/rails test test/integration/attribution_capture_test.rb
Expected: 4 passing.
- Step 5: Commit
git add app/controllers/users/registrations_controller.rb test/integration/attribution_capture_test.rb
git commit -m "Persist signup attribution to user on registration"
Task 1.6 — Ransack permitlist on User
Files:
-
Modify:
app/models/user.rb -
Step 1: Add new attributes to the ransackable_attributes whitelist
Find the existing ransackable_attributes method (or def self.ransackable_attributes). Add these five attribute names to the returned array: "signup_utm_source", "signup_utm_medium", "signup_utm_campaign", "signup_referrer_domain", "signup_affiliate_id".
If no such method exists, search for it: grep -n "ransackable_attributes" app/models/user.rb. If still none, add:
def self.ransackable_attributes(_auth_object = nil)
super + %w[signup_utm_source signup_utm_medium signup_utm_campaign signup_referrer_domain signup_affiliate_id]
end
- Step 2: Commit
git add app/models/user.rb
git commit -m "Permit signup_* attributes in Ransack queries"
Task 1.7 — Surface source on /admin/users
Files:
- Modify:
app/views/admin/users/index.html.erb -
Modify:
app/views/admin/users/_user_row.html.erb - Step 1: Add UTM source filter to the existing filter form
In index.html.erb, inside the <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> block, add a new column for source:
<div>
<%= f.label :signup_utm_source_eq, "Signup Source", class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" %>
<%= f.text_field :signup_utm_source_eq,
placeholder: "e.g. newsletter, google",
value: params[:q]&.dig(:signup_utm_source_eq),
class: "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" %>
</div>
Change the grid from md:grid-cols-3 to md:grid-cols-4.
- Step 2: Add a "Source" column to
_user_row.html.erb
Find the <tr> rendering the row. Add this <td> after the existing "Type" column:
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
<%= user.signup_utm_source.presence ||
user.signup_referrer_domain.presence ||
user.provider.presence ||
"direct" %>
</td>
Also add a <th> for "Source" in the corresponding index.html.erb table header.
- Step 3: Manually verify
Run: bin/rails server and visit /admin/users. Confirm the new filter and column render. Filter by signup_utm_source_eq=newsletter and confirm the filter applies.
- Step 4: Commit
git add app/views/admin/users/
git commit -m "Show and filter signup source on /admin/users"
Wave 2 — Daily Analytics Infrastructure
Task 2.1 — daily_metrics migration
Files:
-
Create:
db/migrate/<timestamp>_create_daily_metrics.rb -
Step 1: Generate migration
Run: bin/rails g migration CreateDailyMetrics
- Step 2: Replace body
class CreateDailyMetrics < ActiveRecord::Migration[8.0]
def change
create_table :daily_metrics do |t|
t.date :date, null: false
t.string :resource_type, null: false
t.integer :total_count, null: false, default: 0
t.integer :created_count, null: false, default: 0
t.integer :deleted_count, null: false, default: 0
t.integer :active_users, null: false, default: 0
t.json :extras, null: false, default: {}
t.timestamps
end
add_index :daily_metrics, [:date, :resource_type], unique: true
add_index :daily_metrics, :resource_type
end
end
- Step 3: Run migration and commit
bin/rails db:migrate
git add db/migrate db/schema.rb
git commit -m "Create daily_metrics table"
Task 2.2 — DailyMetric model
Files:
- Create:
app/models/daily_metric.rb - Create:
test/models/daily_metric_test.rb -
Create:
test/fixtures/daily_metrics.yml - Step 1: Write the model
class DailyMetric < ApplicationRecord
scope :recent, ->(days = 90) { where(date: (Date.current - days)..Date.current) }
scope :for, ->(resource) { where(resource_type: resource.is_a?(Class) ? resource.name : resource.to_s) }
scope :ordered, -> { order(:date) }
def self.latest_total(resource)
self.for(resource).order(date: :desc).limit(1).pick(:total_count) || 0
end
def self.delta(resource, days:)
self.for(resource).where(date: (Date.current - days)..Date.current).sum(:created_count)
end
def self.ransackable_attributes(_auth = nil)
%w[date resource_type total_count created_count deleted_count active_users]
end
end
- Step 2: Add an empty fixture file
Create test/fixtures/daily_metrics.yml with just:
# Intentionally empty — calculator tests create rows inline.
- Step 3: Write the model test
require "test_helper"
class DailyMetricTest < ActiveSupport::TestCase
test ".latest_total returns 0 when no rows" do
assert_equal 0, DailyMetric.latest_total("Task")
end
test ".latest_total returns most recent row's total_count" do
DailyMetric.create!(date: 2.days.ago.to_date, resource_type: "Task", total_count: 10)
DailyMetric.create!(date: 1.day.ago.to_date, resource_type: "Task", total_count: 20)
assert_equal 20, DailyMetric.latest_total("Task")
end
test ".delta sums created_count over the window" do
DailyMetric.create!(date: 6.days.ago.to_date, resource_type: "Task", created_count: 3)
DailyMetric.create!(date: 2.days.ago.to_date, resource_type: "Task", created_count: 5)
DailyMetric.create!(date: 10.days.ago.to_date, resource_type: "Task", created_count: 99)
assert_equal 8, DailyMetric.delta("Task", days: 7)
end
test ".for accepts a class or string" do
DailyMetric.create!(date: Date.current, resource_type: "Task", total_count: 1)
assert_equal 1, DailyMetric.for("Task").count
end
end
- Step 4: Run the test
Run: bin/rails test test/models/daily_metric_test.rb
Expected: 4 passing.
- Step 5: Commit
git add app/models/daily_metric.rb test/models/daily_metric_test.rb test/fixtures/daily_metrics.yml
git commit -m "Add DailyMetric model with recent/for/delta scopes"
Task 2.3 — Analytics::BaseCalculator
Files:
- Create:
app/models/analytics.rb - Create:
app/models/analytics/base_calculator.rb -
Create:
test/models/analytics/base_calculator_test.rb - Step 1: Module namespace file
app/models/analytics.rb:
module Analytics
CALCULATORS = []
end
(We append to this constant as we add calculators.)
- Step 2: Base calculator
app/models/analytics/base_calculator.rb:
class Analytics::BaseCalculator
def initialize(date)
@date = date.to_date
end
def call
attrs = compute
now = Time.current
DailyMetric.upsert(
{
date: @date,
resource_type: resource_type,
total_count: attrs.fetch(:total_count, 0),
created_count: attrs.fetch(:created_count, 0),
deleted_count: attrs.fetch(:deleted_count, 0),
active_users: attrs.fetch(:active_users, 0),
extras: attrs.fetch(:extras, {}),
created_at: now,
updated_at: now
},
unique_by: [:date, :resource_type]
)
end
private
def resource_type
raise NotImplementedError, "#{self.class} must define #resource_type"
end
def compute
raise NotImplementedError, "#{self.class} must define #compute returning a Hash"
end
def day_range
@[email protected]_of_day
end
end
- Step 3: Test
require "test_helper"
class Analytics::BaseCalculatorTest < ActiveSupport::TestCase
class DummyCalculator < Analytics::BaseCalculator
def resource_type = "Dummy"
def compute = { total_count: 42, created_count: 3, extras: { foo: "bar" } }
end
test "#call upserts a DailyMetric row with computed values" do
DummyCalculator.new(Date.current).call
row = DailyMetric.find_by!(date: Date.current, resource_type: "Dummy")
assert_equal 42, row.total_count
assert_equal 3, row.created_count
assert_equal "bar", row.extras["foo"]
end
test "#call is idempotent (re-running overwrites)" do
DummyCalculator.new(Date.current).call
klass = Class.new(Analytics::BaseCalculator) do
def resource_type = "Dummy"
def compute = { total_count: 100, created_count: 0 }
end
klass.new(Date.current).call
assert_equal 100, DailyMetric.find_by!(date: Date.current, resource_type: "Dummy").total_count
end
test "base raises NotImplementedError without subclass" do
assert_raises(NotImplementedError) { Analytics::BaseCalculator.new(Date.current).call }
end
end
- Step 4: Run + commit
bin/rails test test/models/analytics/base_calculator_test.rb
git add app/models/analytics app/models/analytics.rb test/models/analytics
git commit -m "Add Analytics::BaseCalculator with idempotent upsert"
Expected: 3 passing.
Task 2.4 — Analytics::UserCalculator (first concrete calculator)
Files:
- Create:
app/models/analytics/user_calculator.rb -
Create:
test/models/analytics/user_calculator_test.rb - Step 1: Write the calculator
class Analytics::UserCalculator < Analytics::BaseCalculator
def resource_type = "User"
def compute
eod = @date.end_of_day
scope = User.where("created_at <= ?", eod)
{
total_count: scope.count,
created_count: User.where(created_at: day_range).count,
deleted_count: 0,
active_users: User.where(last_sign_in_at: day_range).distinct.count,
extras: {
subscribed_count: scope.joins(:pay_customers).joins("INNER JOIN pay_subscriptions ON pay_subscriptions.customer_id = pay_customers.id").where(pay_subscriptions: { status: "active" }).distinct.count,
by_utm_source: User.where(created_at: day_range).group(:signup_utm_source).count.transform_keys { |k| k || "direct" }
}
}
end
end
- Step 2: Register it
In app/models/analytics.rb:
module Analytics
CALCULATORS = [
UserCalculator
].freeze
end
- Step 3: Test
require "test_helper"
class Analytics::UserCalculatorTest < ActiveSupport::TestCase
test "computes total and created_count for given date" do
travel_to Date.new(2026, 1, 15) do
Analytics::UserCalculator.new(Date.current).call
row = DailyMetric.find_by!(date: Date.current, resource_type: "User")
assert row.total_count >= User.count - 1 # accounts for fixtures
assert_kind_of Hash, row.extras
assert row.extras.key?("by_utm_source")
end
end
end
- Step 4: Run + commit
bin/rails test test/models/analytics/user_calculator_test.rb
git add app/models/analytics test/models/analytics
git commit -m "Add UserCalculator and register it"
Task 2.5 — Daily job
Files:
- Create:
app/jobs/analytics/calculate_daily_metrics_job.rb -
Create:
test/jobs/analytics/calculate_daily_metrics_job_test.rb - Step 1: Write the job
class Analytics::CalculateDailyMetricsJob < ApplicationJob
queue_as :default
def perform(date = nil)
date = (date.is_a?(String) ? Date.parse(date) : date) || Date.yesterday
Analytics::CALCULATORS.each do |calculator|
calculator.new(date).call
rescue => e
Rails.logger.error("[Analytics] #{calculator.name} failed for #{date}: #{e.class}: #{e.message}")
Rails.error.report(e, context: { calculator: calculator.name, date: date.to_s })
end
end
end
- Step 2: Test that one failure doesn't abort the others
require "test_helper"
class Analytics::CalculateDailyMetricsJobTest < ActiveJob::TestCase
class Boom < Analytics::BaseCalculator
def resource_type = "Boom"
def compute = raise "kaboom"
end
class Ok < Analytics::BaseCalculator
def resource_type = "Ok"
def compute = { total_count: 1 }
end
test "continues to next calculator after one raises" do
with_calculators([Boom, Ok]) do
Analytics::CalculateDailyMetricsJob.new.perform(Date.current)
end
assert DailyMetric.exists?(date: Date.current, resource_type: "Ok"),
"Ok calculator should have run even though Boom raised"
end
private
# Temporarily swap Analytics::CALCULATORS without touching the production list.
def with_calculators(list)
original = Analytics::CALCULATORS
Analytics.send(:remove_const, :CALCULATORS)
Analytics.const_set(:CALCULATORS, list)
yield
ensure
Analytics.send(:remove_const, :CALCULATORS)
Analytics.const_set(:CALCULATORS, original)
end
end
- Step 3: Run + commit
bin/rails test test/jobs/analytics/calculate_daily_metrics_job_test.rb
git add app/jobs/analytics test/jobs/analytics
git commit -m "Add CalculateDailyMetricsJob with per-calculator error isolation"
Task 2.6 — Schedule the daily job
Files:
-
Modify:
config/recurring.yml -
Step 1: Append to recurring.yml
# Daily aggregate metrics snapshot for admin analytics
analytics_daily_metrics:
class: Analytics::CalculateDailyMetricsJob
schedule: every day at 0:05 America/Sao_Paulo
- Step 2: Commit
git add config/recurring.yml
git commit -m "Schedule daily analytics calculation at 00:05 BRT"
Task 2.7 — Backfill rake task
Files:
-
Create:
lib/tasks/analytics.rake -
Step 1: Write the task
namespace :analytics do
desc "Backfill daily_metrics from the earliest user.created_at to yesterday"
task backfill: :environment do
from = User.minimum(:created_at)&.to_date
if from.nil?
puts "No users yet — nothing to backfill."
next
end
range = (from..Date.yesterday)
puts "Backfilling #{range.size} days × #{Analytics::CALCULATORS.size} calculators..."
range.each do |date|
Analytics::CalculateDailyMetricsJob.new.perform(date)
print "."
end
puts " done."
end
end
- Step 2: Dry-run in development
Run: bin/rails analytics:backfill
Expected: prints dots, completes without errors. Then verify: DailyMetric.count should be > 0 in the rails console.
- Step 3: Commit
git add lib/tasks/analytics.rake
git commit -m "Add analytics:backfill rake task"
Wave 3 — DRY Admin Management Pattern
Task 3.1 — Admin::BaseManagementController
Files:
-
Create:
app/controllers/admin/base_management_controller.rb -
Step 1: Write it
class Admin::BaseManagementController < Admin::BaseController
before_action :load_collection, only: :index
def index
render template: "admin/management/index"
end
private
def resource_class
raise NotImplementedError, "#{self.class} must define #resource_class"
end
def resource_label
resource_class.model_name.human(count: 2)
end
def resource_type_key
resource_class.name
end
def ransackable_scope
resource_class.all
end
def per_page
25
end
def load_collection
@q = ransackable_scope.ransack(params[:q])
@pagy, @records = pagy(@q.result(distinct: true).order(created_at: :desc), limit: per_page)
@daily_metrics = DailyMetric.for(resource_type_key).recent(90).ordered
end
helper_method :resource_label, :resource_type_key
end
- Step 2: Commit
git add app/controllers/admin/base_management_controller.rb
git commit -m "Add Admin::BaseManagementController"
Task 3.2 — admin_tabs_controller.js (Stimulus)
Files:
-
Create:
app/javascript/controllers/admin_tabs_controller.js -
Step 1: Write the controller
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["listPanel", "analyticsPanel", "listBtn", "analyticsBtn"]
static values = { tab: { type: String, default: "list" } }
connect() {
const params = new URLSearchParams(window.location.search)
const initial = params.get("tab") === "analytics" ? "analytics" : "list"
this.tabValue = initial
this.applyTab(initial)
}
switchTab({ params: { tab } }) {
this.tabValue = tab
this.applyTab(tab)
const url = new URL(window.location)
if (tab === "list") url.searchParams.delete("tab")
else url.searchParams.set("tab", tab)
window.history.replaceState({}, "", url)
}
applyTab(tab) {
this.listPanelTarget.classList.toggle("hidden", tab !== "list")
this.analyticsPanelTarget.classList.toggle("hidden", tab !== "analytics")
const activeClass = "bg-blue-500 text-white"
const inactiveClass = "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
if (this.hasListBtnTarget) {
this.listBtnTarget.className = `px-4 py-2 rounded-xl text-[13px] font-semibold transition-colors ${tab === "list" ? activeClass : inactiveClass}`
}
if (this.hasAnalyticsBtnTarget) {
this.analyticsBtnTarget.className = `px-4 py-2 rounded-xl text-[13px] font-semibold transition-colors ${tab === "analytics" ? activeClass : inactiveClass}`
}
}
}
- Step 2: Commit
git add app/javascript/controllers/admin_tabs_controller.js
git commit -m "Add admin_tabs Stimulus controller for List/Analytics tabs"
Task 3.3 — Shared partials and index template
Files:
- Create:
app/views/admin/management/index.html.erb - Create:
app/views/admin/management/_header.html.erb - Create:
app/views/admin/management/_tabs.html.erb - Create:
app/views/admin/management/_analytics.html.erb -
Create:
app/views/admin/management/_filter_shell.html.erb - Step 1:
_header.html.erb
<%# locals: title:, subtitle: nil, total: nil %>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-[20px] sm:text-[22px] font-bold text-gray-900 dark:text-white"><%= title %></h1>
<% if subtitle.present? %>
<p class="text-[13px] text-gray-500 dark:text-gray-400 mt-0.5"><%= subtitle %></p>
<% end %>
</div>
<% if total %>
<div class="text-[12px] text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-white/5 px-3 py-1 rounded-full">
<%= t("admin.management.total", count: total, default: "Total: %{count}") %>
</div>
<% end %>
<%= yield :header_actions if content_for?(:header_actions) %>
</div>
- Step 2:
_tabs.html.erb
<div class="flex rounded-xl overflow-hidden p-0.5 bg-gray-200/60 dark:bg-[#1e2736] w-fit">
<button data-admin-tabs-target="listBtn"
data-action="click->admin-tabs#switchTab"
data-admin-tabs-tab-param="list"
class="px-4 py-2 rounded-xl text-[13px] font-semibold transition-colors bg-blue-500 text-white">
<%= t("admin.management.tab.list", default: "List") %>
</button>
<button data-admin-tabs-target="analyticsBtn"
data-action="click->admin-tabs#switchTab"
data-admin-tabs-tab-param="analytics"
class="px-4 py-2 rounded-xl text-[13px] font-semibold transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5">
<%= t("admin.management.tab.analytics", default: "Analytics") %>
</button>
</div>
- Step 3:
_analytics.html.erb
<%# locals: resource_type:, daily_metrics: %>
<% latest = daily_metrics.last %>
<% total = latest&.total_count || 0 %>
<% active = latest&.active_users || 0 %>
<% last_7 = daily_metrics.where(date: 7.days.ago..).sum(:created_count) %>
<% last_30 = daily_metrics.where(date: 30.days.ago..).sum(:created_count) %>
<% chart_data = daily_metrics.pluck(:date, :created_count, :total_count) %>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<% [["Total", total], ["Last 7d", last_7], ["Last 30d", last_30], ["Active users", active]].each do |label, value| %>
<div class="rounded-xl bg-white dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-white/10 p-4">
<div class="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400"><%= label %></div>
<div class="text-[24px] font-bold text-gray-900 dark:text-white mt-1"><%= number_with_delimiter(value) %></div>
</div>
<% end %>
</div>
<div class="rounded-xl bg-white dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-white/10 p-4 mt-4"
data-controller="chart"
data-chart-type-value="line"
data-chart-series-value="<%= [
{ name: 'Created/day', data: chart_data.map { |d, c, _| [d.to_s, c] } },
{ name: 'Total', data: chart_data.map { |d, _, t| [d.to_s, t] } }
].to_json %>">
<div data-chart-target="container" style="min-height: 260px;"></div>
</div>
<% if latest&.extras.present? %>
<details class="rounded-xl bg-white dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-white/10 p-4 mt-4">
<summary class="text-[13px] font-semibold cursor-pointer text-gray-700 dark:text-gray-300">Resource-specific (latest snapshot)</summary>
<dl class="mt-3 grid grid-cols-2 gap-2 text-[12px]">
<% latest.extras.each do |k, v| %>
<div class="flex justify-between gap-3 border-t border-gray-100 dark:border-white/5 pt-2">
<dt class="text-gray-500"><%= k %></dt>
<dd class="font-mono text-gray-900 dark:text-white truncate"><%= v.is_a?(Hash) ? v.to_json : v %></dd>
</div>
<% end %>
</dl>
</details>
<% end %>
- Step 4:
_filter_shell.html.erb
<%# locals: q:, &block %>
<div class="bg-white dark:bg-gray-800/60 border border-gray-200 dark:border-white/10 rounded-xl p-4">
<%= search_form_for q, method: :get, local: true, class: "space-y-3" do |f| %>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<%= f.label :created_at_gteq, "Created after", class: "block text-[12px] font-medium text-gray-700 dark:text-gray-300 mb-1" %>
<%= f.date_field :created_at_gteq,
class: "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-[13px]" %>
</div>
<div>
<%= f.label :created_at_lteq, "Created before", class: "block text-[12px] font-medium text-gray-700 dark:text-gray-300 mb-1" %>
<%= f.date_field :created_at_lteq,
class: "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-[13px]" %>
</div>
<% if block_given? %>
<%= capture(f, &block) %>
<% end %>
</div>
<div class="flex items-center gap-2">
<%= f.submit "Filter", class: "px-3 py-2 text-[13px] font-semibold text-white bg-blue-600 rounded-md hover:bg-blue-700" %>
<%= link_to "Reset", url_for(controller: controller_name, action: :index), class: "px-3 py-2 text-[13px] font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-white/5 rounded-md" %>
</div>
<% end %>
</div>
- Step 5:
index.html.erb
<% content_for :title, resource_label %>
<div data-controller="admin-tabs" class="space-y-5 max-w-7xl mx-auto">
<%= render "admin/management/header",
title: resource_label,
subtitle: t("admin.management.subtitle", resource: resource_label, default: nil),
total: @pagy.count %>
<%= render "admin/management/tabs" %>
<div data-admin-tabs-target="listPanel" class="space-y-4">
<%= render "admin/management/#{controller_name}/filters", q: @q %>
<%= render "admin/management/#{controller_name}/table", records: @records, pagy: @pagy %>
</div>
<div data-admin-tabs-target="analyticsPanel" class="hidden">
<%= render "admin/management/analytics",
resource_type: resource_type_key,
daily_metrics: @daily_metrics %>
</div>
</div>
- Step 6: Add i18n keys
In config/locales/en.yml, add under en::
admin:
management:
total: "Total: %{count}"
tab:
list: "List"
analytics: "Analytics"
And the matching pt-BR.yml:
pt-BR:
admin:
management:
total: "Total: %{count}"
tab:
list: "Lista"
analytics: "Analytics"
- Step 7: Commit
git add app/views/admin/management config/locales/en.yml config/locales/pt-BR.yml
git commit -m "Add shared admin management partials (header, tabs, filter shell, analytics)"
Task 3.4 — Port /admin/users to BaseManagementController (smoke test)
Files:
- Modify:
app/controllers/admin/users_controller.rb - Create:
app/views/admin/management/users/_filters.html.erb -
Create:
app/views/admin/management/users/_table.html.erb - Step 1: Make UsersController inherit from BaseManagementController, keep existing modal actions
Replace the index action and add a resource_class declaration. Keep the rest of the file (show/edit/update/destroy/impersonate/confirm) untouched.
class Admin::UsersController < Admin::BaseManagementController
before_action :set_user, only: [ :show, :edit, :update, :destroy, :impersonate, :confirm ]
# index is provided by BaseManagementController
# ... existing show/edit/update/destroy/impersonate/confirm methods stay unchanged ...
private
def resource_class
User
end
def ransackable_scope
User.all
end
# ... existing set_user, user_params, etc. stay unchanged ...
end
Remove the old def index body entirely.
- Step 2: Move existing filter form into the new partial
Extract the filter form from app/views/admin/users/index.html.erb into app/views/admin/management/users/_filters.html.erb. Use the shell:
<%# locals: q: %>
<%= render "admin/management/filter_shell", q: q do |f| %>
<div>
<%= f.label :email_cont, "Email", class: "block text-[12px] font-medium mb-1" %>
<%= f.search_field :email_cont, placeholder: "Search by email…",
class: "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-[13px]" %>
</div>
<div>
<%= f.label :admin_eq, "User type", class: "block text-[12px] font-medium mb-1" %>
<%= f.select :admin_eq, options_for_select([["All", ""], ["Administrators", true], ["Members", false]], params[:q]&.dig(:admin_eq)),
{ prompt: false },
{ class: "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-[13px]" } %>
</div>
<div>
<%= f.label :signup_utm_source_eq, "Source", class: "block text-[12px] font-medium mb-1" %>
<%= f.text_field :signup_utm_source_eq, placeholder: "e.g. newsletter",
value: params[:q]&.dig(:signup_utm_source_eq),
class: "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-[13px]" %>
</div>
<% end %>
- Step 3: Move the table into the new partial
app/views/admin/management/users/_table.html.erb:
<%# locals: records:, pagy: %>
<div class="bg-white dark:bg-gray-800/60 border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-[13px]">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400 uppercase text-[11px]">User</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400 uppercase text-[11px]">Type</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400 uppercase text-[11px]">Source</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400 uppercase text-[11px]">Created</th>
<th class="px-4 py-2 text-right font-medium text-gray-500 dark:text-gray-400 uppercase text-[11px]">Actions</th>
</tr>
</thead>
<tbody>
<% records.each do |user| %>
<%= render "admin/users/user_row", user: user %>
<% end %>
</tbody>
</table>
</div>
<div class="p-3 border-t border-gray-200 dark:border-white/10">
<%== pagy_nav(pagy) %>
</div>
</div>
(The existing _user_row partial is reused; just confirm it produces a full <tr>...</tr>.)
- Step 4: Delete or replace the old
app/views/admin/users/index.html.erb
Since the new shared template is rendered automatically by BaseManagementController, the old file is no longer used. Delete it:
git rm app/views/admin/users/index.html.erb
- Step 5: Manually verify and run tests
Run: bin/rails server and visit /admin/users — confirm filters work, tab switching works (the Analytics tab will show empty data until backfill is run; that's expected).
Run: bin/rails test test/controllers/admin/users_controller_test.rb
Expected: passing.
- Step 6: Commit
git add app/controllers/admin/users_controller.rb app/views/admin/management/users
git commit -m "Port /admin/users to BaseManagementController + shared partials"
Wave 4 — Resource Management Views
Each resource follows an identical pattern. Below is the per-resource template followed by the specifications for each of the 17 resources. Loop the template once per resource.
Per-resource template (apply once per row in §4.X below)
For resource <R> with model <Model>, completion signal <Signal>, and extras keys <Extras>:
Files:
- Create:
app/models/analytics/<r>_calculator.rb - Create:
app/controllers/admin/<rs>_controller.rb - Create:
app/views/admin/management/<rs>/_filters.html.erb - Create:
app/views/admin/management/<rs>/_table.html.erb - Modify:
app/models/analytics.rb(append toCALCULATORS) - Modify:
config/routes.rb(addresources :<rs>, only: :indexundernamespace :admin) - Create:
test/models/analytics/<r>_calculator_test.rb
Steps per resource:
- Write the calculator (subclass
Analytics::BaseCalculatorper Task 2.4's shape). Customizecomputeper the resource's signal/extras spec. - Write one test asserting the calculator produces the expected
total_countagainst fixtures. - Append the class to
Analytics::CALCULATORS. - Add the route.
- Write the 5-line controller (subclass
Admin::BaseManagementController, defineresource_classand optionallyransackable_scopewithincludes(:user)to avoid N+1). - Write
_filters.html.erbusing the filter shell with resource-specific fields. - Write
_table.html.erbwith columns appropriate for the resource (always include user email, created_at, and any "headline" field). - Run the calculator test + manually verify the page renders at
/admin/<rs>. - Commit with message
"Add admin management view for <R>".
Calculator scaffolding (use this as a starting point for every resource)
class Analytics::<R>Calculator < Analytics::BaseCalculator
def resource_type = "<Model>"
def compute
eod = @date.end_of_day
{
total_count: <Model>.where("created_at <= ?", eod).count,
created_count: <Model>.where(created_at: day_range).count,
deleted_count: 0, # override if model has a soft-delete column
active_users: <Model>.where(created_at: day_range).joins(<user_path>).distinct.count("users.id"),
extras: compute_extras(eod)
}
end
private
def compute_extras(eod)
{
# resource-specific keys from the spec catalog
}
end
end
<user_path> depends on the model:
- Direct:
:user(e.g.Account,Expense,Habit,Goal,Note,Birthday,Countdown,MarketList,BalanceRegistry,Debt,Investment,Sport,Vision,ConstructionProject,FocusSession) - Through todo_board:
todo_board: :user(e.g.TodoCard) - Through habit:
habit: :user(e.g.HabitCompletion— not needed; we capture this inHabitCalculator.extras) - Through sport:
sport: :user(e.g.ActivityLog)
Controller scaffolding
class Admin::<Rs>Controller < Admin::BaseManagementController
private
def resource_class
<Model>
end
def ransackable_scope
<Model>.includes(:user) # or appropriate eager-loads to avoid N+1 in the table
end
end
Filter partial scaffolding
<%# locals: q: %>
<%= render "admin/management/filter_shell", q: q do |f| %>
<div>
<%= f.label :user_email_cont, "User email", class: "block text-[12px] font-medium mb-1" %>
<%= f.search_field :user_email_cont, placeholder: "user@…",
class: "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-[13px]" %>
</div>
<%# Add resource-specific filter fields here %>
<% end %>
(Requires the model to expose :user_email via Ransack association — see step 4.0 below for the one-time setup.)
Table partial scaffolding
<%# locals: records:, pagy: %>
<div class="bg-white dark:bg-gray-800/60 border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-[13px]">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th class="px-4 py-2 text-left text-[11px] uppercase text-gray-500">User</th>
<th class="px-4 py-2 text-left text-[11px] uppercase text-gray-500">Headline</th>
<th class="px-4 py-2 text-left text-[11px] uppercase text-gray-500">Created</th>
</tr>
</thead>
<tbody>
<% records.each do |record| %>
<tr class="border-t border-gray-100 dark:border-white/5">
<td class="px-4 py-2"><%= record.user&.email %></td>
<td class="px-4 py-2"><%= record.try(:title) || record.try(:name) || record.id %></td>
<td class="px-4 py-2 text-gray-500"><%= l(record.created_at, format: :short) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="p-3 border-t border-gray-200 dark:border-white/10">
<%== pagy_nav(pagy) %>
</div>
</div>
(Customize headline column per resource.)
Task 4.0 — Add :user_email to Ransackable associations
Files:
-
Modify: each user-owned model whose admin view needs email filter
-
Step 1: For each of these models, override
ransackable_associations
Models: Account, BalanceRegistry, Expense, Debt, Investment, Habit, Goal, Sport, ActivityLog, Vision, Note, Birthday, MarketList, Countdown, FocusSession, ConstructionProject, TodoCard.
Add the appropriate associations to the ransackable list. Example for Account:
def self.ransackable_associations(_auth = nil)
%w[user]
end
def self.ransackable_attributes(_auth = nil)
super + %w[created_at]
end
If a model already has these methods, append (don't replace) the entries. For TodoCard, use todo_board instead of user.
- Step 2: Commit
git add app/models
git commit -m "Permit user and created_at filters in Ransack across admin-managed models"
Task 4.1 — Accounts
Resource: Account (Finance). Direct belongs_to :user.
- Calculator — extras:
total_balance_brl: Account.sum(:balance_in_brl)(use the model's existing helper if any; else convert viaexchange_rate). Headline:account.name.
Path: /admin/accounts. Add: resources :accounts, only: :index under namespace :admin.
Apply the per-resource template. Commit: "Add admin management view for Accounts".
Task 4.2 — Account Registries
Resource: BalanceRegistry (Finance). Direct belongs_to :user. Extras: unique_recorders: BalanceRegistry.where(created_at: day_range).distinct.count(:user_id). Headline: l(registry.date, format: :short).
Path: /admin/account_registries. Apply template. Commit.
Task 4.3 — Expenses
Resource: Expense (Finance). Extras: sum_amount_brl, top_category: Expense.where(created_at: day_range).group(:category).count.max_by { |_, v| v }&.first. Headline: expense.description || expense.category.
Path: /admin/expenses. Filter add: category_eq text field. Apply template. Commit.
Task 4.4 — Debts
Resource: Debt (Finance). Completion: paid_off_at != nil. Extras: sum_remaining_brl, paid_off_today: Debt.where(paid_off_at: day_range).count. Headline: debt.name.
Path: /admin/debts. Apply template. Commit.
Task 4.5 — Investments
Resource: Investment (Finance). Extras: sum_current_value_brl. Headline: investment.name.
Path: /admin/investments. Apply template. Commit.
Task 4.6 — Habits
Resource: Habit (Personal). Tracks habit definitions; completions are captured in extras.
Extras (day_range aware):
{
completions_today: HabitCompletion.where(date: @date, completed: true).count,
users_with_completion_today: HabitCompletion.where(date: @date, completed: true).joins(:habit).distinct.count("habits.user_id"),
avg_streak: nil # leave nil unless cheap to compute; can populate later
}
Headline: habit.name. Path: /admin/habits. Apply template. Commit.
Task 4.7 — Tasks (TodoCard)
Resource: TodoCard (Personal). Eager-load todo_board: :user. Completion: archived_at set that day.
Extras:
{
archived_today: TodoCard.where(archived_at: day_range).count,
with_due_date: TodoCard.where("created_at <= ?", eod).where.not(due_date: nil).count,
overdue: TodoCard.where("created_at <= ?", eod).where("due_date < ?", @date).where(archived: false).count
}
active_users: joins(todo_board: :user).distinct.count("users.id").
Headline: card.title || card.id. Path: /admin/tasks. Apply template (use todo_board.user.email in the table). Commit.
Task 4.8 — Goals
Resource: Goal (Personal). Completion: status == "completed" (verify the actual column name and value during implementation; adapt the calculator).
Extras:
{
completed_today: Goal.where(updated_at: day_range, status: "completed").count,
avg_progress_pct: Goal.where("created_at <= ?", eod).where.not(status: "completed").average(:progress)&.round(2)
}
(If progress column doesn't exist, drop that key — the spec defers per-resource adaptation here.)
Headline: goal.name. Path: /admin/goals. Apply template. Commit.
Task 4.9 — Sports
Resource: Sport (Personal). Extras: with_recent_log: Sport.joins(:activity_logs).where(activity_logs: { date: (eod - 7.days).to_date..@date }).distinct.count. Headline: sport.name.
Path: /admin/sports. Apply template. Commit.
Task 4.10 — Activities
Resource: ActivityLog (Personal). User path: joins(sport: :user) if ActivityLog is linked through Sport, else :user. Verify during implementation.
Extras:
{
sum_duration_minutes: ActivityLog.where(date: @date).sum(:duration_minutes),
unique_sports: ActivityLog.where(date: @date).distinct.count(:sport_id)
}
Headline: activity.activity_type or sport name. Path: /admin/activities. Apply template. Commit.
Task 4.11 — Vision
Resource: Vision (Personal). One per user. Total = number of users with a vision.
Extras:
{
avg_completion_pct: Vision.where("created_at <= ?", eod).average(:completion_pct)&.round(2),
users_with_vision: Vision.where("created_at <= ?", eod).distinct.count(:user_id)
}
(Verify completion_pct column name; adapt if different.)
Headline: "#{vision.user.email} — #{vision.completion_pct}%". Path: /admin/vision. Apply template. Commit.
Task 4.12 — Notes
Resource: Note (Tools). Completion: archived_at.
Extras:
{
pinned_count: Note.where("created_at <= ?", eod).where(pinned: true).count,
archived_today: Note.where(archived_at: day_range).count
}
Headline: note.title. Path: /admin/notes. Apply template. Commit.
Task 4.13 — Construction
Resource: ConstructionProject (Tools).
Extras:
{
sum_phase_count: ConstructionPhase.joins(:construction_project).where(construction_projects: { user_id: User.select(:id) }).where("construction_phases.created_at <= ?", eod).count,
sum_expense_amount_brl: ConstructionExpense.where(created_at: ..eod).sum(:amount)
}
Headline: project.name. Path: /admin/construction_projects. Apply template. Commit.
Task 4.14 — Birthdays
Resource: Birthday (Tools).
Extras:
{
upcoming_30d: Birthday.where("DATE(date) BETWEEN ? AND ?", @date, @date + 30.days).count
}
Headline: birthday.name. Path: /admin/birthdays. Apply template. Commit.
Task 4.15 — Market Lists
Resource: MarketList (Tools).
Extras:
{
with_items: MarketList.joins(:market_list_items).where("market_lists.created_at <= ?", eod).distinct.count,
total_items: MarketListItem.joins(:market_list).where("market_list_items.created_at <= ?", eod).count
}
Headline: list.name. Path: /admin/market_lists. Apply template. Commit.
Task 4.16 — Timer (Focus Sessions)
Resource: FocusSession (Tools). Completion: ended_at set.
Extras:
{
sessions_today: FocusSession.where(created_at: day_range, ended_at: ..nil).count,
sum_minutes_today: FocusSession.where(ended_at: day_range).sum(:duration_minutes),
unique_users_focused: FocusSession.where(ended_at: day_range).distinct.count(:user_id)
}
(Verify column names — model may use started_at/ended_at and store duration differently.)
Headline: "#{session.user.email} — #{session.duration_minutes}m". Path: /admin/focus_sessions. Apply template. Commit.
Task 4.17 — Countdowns
Resource: Countdown (Tools). Completion: completed_at set.
Extras:
{
completed_today: Countdown.where(completed_at: day_range).count,
upcoming_7d: Countdown.where("created_at <= ?", eod).where(target_date: @date..(@date + 7.days)).count
}
Headline: countdown.title. Path: /admin/countdowns. Apply template. Commit.
Task 4.18 — Re-run backfill with full calculator set
- Step 1: Run
bin/rails analytics:backfill
Expected: completes without errors. Verify DailyMetric.distinct.count(:resource_type) == 18 (User + 17 resources).
- Step 2: Spot-check a few rows
Run: bin/rails runner 'puts DailyMetric.where(resource_type: "TodoCard").order(:date).last(3).map(&:attributes)'
- Step 3: Commit (no code change, but tag the milestone)
git commit --allow-empty -m "Backfill: all 18 calculators populated"
Wave 5 — Admin Dashboard & Leaderboard
Task 5.1 — Refactor Admin::DashboardController#index
Files:
- Modify:
app/controllers/admin/dashboard_controller.rb -
Modify:
app/views/admin/dashboard/index.html.erb - Step 1: Rewrite controller
class Admin::DashboardController < Admin::BaseController
DASHBOARD_RESOURCES = %w[User TodoCard Habit Expense Goal Investment Note FocusSession].freeze
def index
@kpis = DASHBOARD_RESOURCES.map do |type|
{
resource_type: type,
label: type_label(type),
total: DailyMetric.latest_total(type),
last_7: DailyMetric.delta(type, days: 7),
last_30: DailyMetric.delta(type, days: 30),
spark: DailyMetric.for(type).recent(30).ordered.pluck(:date, :total_count)
}
end
@user_growth = DailyMetric.for("User").recent(90).ordered.pluck(:date, :created_count)
end
private
def type_label(type)
type.constantize.model_name.human(count: 2)
rescue NameError
type
end
end
- Step 2: Rewrite the index view
app/views/admin/dashboard/index.html.erb:
<% content_for :title, "Admin Dashboard" %>
<div class="space-y-6 max-w-7xl mx-auto">
<%= render "admin/management/header",
title: "Admin Dashboard",
subtitle: "System-wide totals and growth",
total: nil %>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<% @kpis.each do |k| %>
<div class="rounded-xl bg-white dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-white/10 p-4">
<div class="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400"><%= k[:label] %></div>
<div class="text-[24px] font-bold text-gray-900 dark:text-white mt-1"><%= number_with_delimiter(k[:total]) %></div>
<div class="flex gap-3 text-[11px] text-gray-500 mt-1">
<span>+<%= k[:last_7] %> 7d</span>
<span>+<%= k[:last_30] %> 30d</span>
</div>
</div>
<% end %>
</div>
<div class="rounded-xl bg-white dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-white/10 p-4"
data-controller="chart"
data-chart-type-value="line"
data-chart-series-value="<%= [{ name: 'New users / day', data: @user_growth.map { |d, c| [d.to_s, c] } }].to_json %>">
<h2 class="text-[14px] font-semibold mb-3">User growth (last 90 days)</h2>
<div data-chart-target="container" style="min-height: 300px;"></div>
</div>
</div>
- Step 3: Commit
git add app/controllers/admin/dashboard_controller.rb app/views/admin/dashboard/index.html.erb
git commit -m "Repurpose /admin/dashboard with KPI grid + user growth chart"
Task 5.2 — Test the dashboard
Files:
-
Modify:
test/controllers/admin/dashboard_controller_test.rb -
Step 1: Write test
require "test_helper"
class Admin::DashboardControllerTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
setup do
sign_in users(:admin)
DailyMetric.create!(date: Date.current, resource_type: "User", total_count: 100, created_count: 5)
DailyMetric.create!(date: Date.current, resource_type: "TodoCard", total_count: 250, created_count: 12)
end
test "index renders KPI cards" do
get admin_dashboard_url
assert_response :success
assert_match "100", response.body # User total
assert_match "250", response.body # TodoCard total
end
test "non-admin is rejected" do
sign_in users(:member)
get admin_dashboard_url
assert_redirected_to root_path
end
end
- Step 2: Run + commit
bin/rails test test/controllers/admin/dashboard_controller_test.rb
git add test/controllers/admin/dashboard_controller_test.rb
git commit -m "Test admin dashboard KPI rendering"
Task 5.3 — Admin::LeaderboardsController
Files:
- Create:
app/controllers/admin/leaderboards_controller.rb - Create:
app/views/admin/leaderboards/show.html.erb - Modify:
config/routes.rb -
Create:
test/controllers/admin/leaderboards_controller_test.rb - Step 1: Add the route
Inside namespace :admin do in config/routes.rb:
get "leaderboard", to: "leaderboards#show"
- Step 2: Controller
class Admin::LeaderboardsController < Admin::BaseController
def show
@q = User.left_joins(:gamification_profile).ransack(params[:q])
@pagy, @users = pagy(
@q.result.select("users.*, COALESCE(gamification_profiles.xp, 0) AS xp_total")
.order(Arel.sql("xp_total DESC")),
limit: 50
)
end
end
- Step 3: View
<% content_for :title, "Leaderboard" %>
<div class="space-y-5 max-w-7xl mx-auto">
<%= render "admin/management/header",
title: "Leaderboard",
subtitle: "Users ranked by XP",
total: @pagy.count %>
<div class="bg-white dark:bg-gray-800/60 border border-gray-200 dark:border-white/10 rounded-xl p-4">
<%= search_form_for @q, method: :get, local: true, class: "grid grid-cols-1 md:grid-cols-3 gap-3" do |f| %>
<%= f.search_field :email_cont, placeholder: "Email contains…",
class: "px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-[13px]" %>
<%= f.date_field :created_at_gteq,
class: "px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-[13px]" %>
<%= f.submit "Filter", class: "px-3 py-2 text-[13px] font-semibold text-white bg-blue-600 rounded-md hover:bg-blue-700" %>
<% end %>
</div>
<div class="bg-white dark:bg-gray-800/60 border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden">
<table class="w-full text-[13px]">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th class="px-4 py-2 text-left text-[11px] uppercase text-gray-500">Rank</th>
<th class="px-4 py-2 text-left text-[11px] uppercase text-gray-500">Email</th>
<th class="px-4 py-2 text-left text-[11px] uppercase text-gray-500">Level</th>
<th class="px-4 py-2 text-right text-[11px] uppercase text-gray-500">XP</th>
<th class="px-4 py-2 text-left text-[11px] uppercase text-gray-500">Last sign-in</th>
<th class="px-4 py-2 text-left text-[11px] uppercase text-gray-500">Source</th>
</tr>
</thead>
<tbody>
<% @users.each_with_index do |user, i| %>
<% xp = user.try(:xp_total) || 0 %>
<tr class="border-t border-gray-100 dark:border-white/5">
<td class="px-4 py-2 font-mono"><%= @pagy.offset + i + 1 %></td>
<td class="px-4 py-2"><%= user.email %></td>
<td class="px-4 py-2"><%= Gamification::LevelCalculator.new(xp).level %></td>
<td class="px-4 py-2 text-right font-mono"><%= number_with_delimiter(xp) %></td>
<td class="px-4 py-2 text-gray-500"><%= user.last_sign_in_at ? l(user.last_sign_in_at, format: :short) : "—" %></td>
<td class="px-4 py-2 text-gray-500"><%= user.signup_utm_source.presence || "direct" %></td>
</tr>
<% end %>
</tbody>
</table>
<div class="p-3 border-t border-gray-200 dark:border-white/10"><%== pagy_nav(@pagy) %></div>
</div>
</div>
(Verify Gamification::LevelCalculator.new(xp).level is the correct API by inspecting app/models/gamification/level_calculator.rb. Adapt if its constructor or method differs.)
- Step 4: Test
require "test_helper"
class Admin::LeaderboardsControllerTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
setup { sign_in users(:admin) }
test "show renders 200" do
get admin_leaderboard_url
assert_response :success
end
test "non-admin rejected" do
sign_in users(:member)
get admin_leaderboard_url
assert_redirected_to root_path
end
end
- Step 5: Run + commit
bin/rails test test/controllers/admin/leaderboards_controller_test.rb
git add app/controllers/admin/leaderboards_controller.rb app/views/admin/leaderboards config/routes.rb test/controllers/admin/leaderboards_controller_test.rb
git commit -m "Add admin leaderboard at /admin/leaderboard"
Task 5.4 — Final integration smoke test
- Step 1: Boot the server and click through
Run: bin/rails server
Verify the following pages render and the tab switcher works:
/admin/dashboard/admin/leaderboard/admin/users(List + Analytics tabs)-
/admin/tasks,/admin/habits,/admin/expenses,/admin/goals,/admin/notes,/admin/focus_sessions(sample spot-check) - Step 2: Run the whole test suite
Run: bin/rails test
Expected: passing.
- Step 3: Tag the milestone
git commit --allow-empty -m "Wave 5 complete: dashboard and leaderboard shipped"
git tag wave-5-complete
Acceptance check
When all tasks are complete, validate against §9 of the spec:
- Signup with
?utm_source=newsletter→ user row populated. ✅ (Task 1.5) /admin/usersfilters by source. ✅ (Task 1.7 + 3.4)analytics:backfillpopulates rows since first signup. ✅ (Task 4.18)- Daily job scheduled at 00:05 BRT. ✅ (Task 2.6)
- Every
/admin/<resource>page renders header/filters/table + Analytics tab. ✅ (Tasks 3.x + 4.1–4.17) /admin/dashboardshows KPI grid + user-growth chart. ✅ (Task 5.1)/admin/leaderboardorders users by XP. ✅ (Task 5.3)- Tests pass. ✅ (Task 5.4)
Notes for the implementing engineer
- TDD rhythm: every calculator gets at least one model test asserting
total_countand one extras assertion. The job + controller tests in Waves 2, 3, 5 are mandatory. The 17 resource controllers don't need individual controller tests beyond the smoke check in Task 5.4 — the BaseManagementController is the unit being tested via the ported/admin/userstest (Task 3.4). - Per-resource schema unknowns: the spec defers per-resource column name verification to implementation. When in doubt, run
bin/rails runner 'puts Model.column_names'and adapt the calculator'scomputemethod. - N+1 in admin tables: always set
ransackable_scopewith the appropriateincludes(...)chain so the table partial doesn't issue per-row user lookups. - Don't introduce
app/services/— calculators are namespaced model classes underapp/models/analytics/, perAGENTS.md. - Commit cadence: commit after each green test cluster. Avoid amend; create new commits when fixing.