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.md patterns (no app/services/; namespaced model classes under app/models/<domain>/).
  • Tests: Minitest IntegrationTest + ModelTest. Fixtures already exist for users (users(:admin), users(:member)).
  • Use t.json not t.jsonb (SQLite).
  • Pagy uses limit:, not items:.
  • 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 create action

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 create to 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 to CALCULATORS)
  • Modify: config/routes.rb (add resources :<rs>, only: :index under namespace :admin)
  • Create: test/models/analytics/<r>_calculator_test.rb

Steps per resource:

  • Write the calculator (subclass Analytics::BaseCalculator per Task 2.4's shape). Customize compute per the resource's signal/extras spec.
  • Write one test asserting the calculator produces the expected total_count against fixtures.
  • Append the class to Analytics::CALCULATORS.
  • Add the route.
  • Write the 5-line controller (subclass Admin::BaseManagementController, define resource_class and optionally ransackable_scope with includes(:user) to avoid N+1).
  • Write _filters.html.erb using the filter shell with resource-specific fields.
  • Write _table.html.erb with 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 in HabitCalculator.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 via exchange_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:

  1. Signup with ?utm_source=newsletter → user row populated. ✅ (Task 1.5)
  2. /admin/users filters by source. ✅ (Task 1.7 + 3.4)
  3. analytics:backfill populates rows since first signup. ✅ (Task 4.18)
  4. Daily job scheduled at 00:05 BRT. ✅ (Task 2.6)
  5. Every /admin/<resource> page renders header/filters/table + Analytics tab. ✅ (Tasks 3.x + 4.1–4.17)
  6. /admin/dashboard shows KPI grid + user-growth chart. ✅ (Task 5.1)
  7. /admin/leaderboard orders users by XP. ✅ (Task 5.3)
  8. Tests pass. ✅ (Task 5.4)

Notes for the implementing engineer

  • TDD rhythm: every calculator gets at least one model test asserting total_count and 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/users test (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's compute method.
  • N+1 in admin tables: always set ransackable_scope with the appropriate includes(...) chain so the table partial doesn't issue per-row user lookups.
  • Don't introduce app/services/ — calculators are namespaced model classes under app/models/analytics/, per AGENTS.md.
  • Commit cadence: commit after each green test cluster. Avoid amend; create new commits when fixing.