Admin Analytics, Source Tracking & DRY Management Views — Design

Status: Draft for review Date: 2026-05-25 Author: yanfroes (via brainstorming session)

1. Overview

This spec defines five connected pieces of work that together give Lifehub a complete admin operations surface: registration source attribution, a per-day analytics store, a reusable admin management view pattern, management views for all major resources, and admin Dashboard + Leaderboard pages.

The pieces are designed to ship in five sequential waves so each is independently testable and deployable. They share two architectural foundations: a single daily_metrics table that all per-resource calculators write to, and a Admin::BaseManagementController that all resource admin pages inherit from.

Goals

  • Attribute every new user signup to a marketing source (UTM, referrer, or affiliate code) and surface it as a filter on /admin/users.
  • Replace ad-hoc live-aggregation queries in admin views with pre-computed daily snapshots, so any chart loads in O(days-displayed) rather than O(records-in-system).
  • Eliminate per-page admin scaffolding work by providing one template + four shared partials that every management view uses.
  • Ship admin management views for 17 user-owned resources and a User Calculator that drives the dashboard.
  • Provide an admin Dashboard (system totals + growth) and Leaderboard (top users by XP), both reusing the same partials.

Non-goals

  • Per-user analytics dashboards. Analytics is aggregate-only; the existing user-facing /dashboard is not affected.
  • Real-time / streaming metrics. End-of-day batch only.
  • Rollups, partitioning, or archival of daily_metrics. Daily rows stay forever; will be re-evaluated only if the table exceeds ~1M rows (decades away).
  • Replacing the existing user-facing /leaderboard route. The new admin leaderboard is a separate page.

2. Part A — Registration Source Tracking

Schema

One migration:

class AddSignupAttributionToUsers < ActiveRecord::Migration[7.x]
  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 }

    add_index :users, :signup_utm_source
    add_index :users, :signup_utm_campaign
    add_index :users, :signup_referrer_domain
  end
end

All five columns are nullable — pre-existing users stay NULL. Indexes only on the three high-cardinality filter columns; signup_utm_medium is left unindexed (low cardinality, always filtered alongside source).

Capture mechanism

A new concern ConcernsAttributionCapture (mounted on StaticController and Users::RegistrationsController#new):

module AttributionCapture
  extend ActiveSupport::Concern

  included do
    before_action :capture_attribution
  end

  private

  def capture_attribution
    return if cookies.signed[:signup_attribution].present?

    payload = {
      utm_source:   params[:utm_source].presence,
      utm_medium:   params[:utm_medium].presence,
      utm_campaign: params[:utm_campaign].presence,
      referrer:     URI(request.referrer).host rescue nil,
      affiliate:    params[:ref].presence || cookies[:affiliate_code]
    }.compact

    return if payload.empty?

    cookies.signed[:signup_attribution] = {
      value: payload,
      expires: 30.days.from_now,
      httponly: true
    }
  end
end

Persistence at registration

Users::RegistrationsController#create (overridden Devise controller) reads the cookie after successful save:

def create
  super do |user|
    next unless user.persisted?

    payload = cookies.signed[:signup_attribution] || {}
    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:    Affiliate.find_by(code: payload["affiliate"])&.id
    )
    cookies.delete(:signup_attribution)
  end
end

Uses update_columns (skip callbacks) to avoid touching updated_at on a brand-new user.

Admin filtering

/admin/users gains Ransack filters for signup_utm_source_eq, signup_utm_campaign_eq, signup_referrer_domain_eq, signup_affiliate_id_eq. Add a "Source" column to the existing user table partial showing the dominant signal (utm_source   referrer_domain   provider   "direct").

Tests

  • Request spec: visit /?utm_source=newsletter&utm_campaign=may2026, sign up, assert User row populated.
  • Request spec: existing cookie not overwritten by a later visit.
  • Request spec: /admin/users?q[signup_utm_source_eq]=newsletter returns only matching users.

3. Part B — Daily Analytics Infrastructure

Schema

class CreateDailyMetrics < ActiveRecord::Migration[7.x]
  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

One row per (date, resource_type). 18 resources × 365 days = ~6.6k rows/year. Table stays small indefinitely.

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)
    for(resource).order(date: :desc).limit(1).pick(:total_count) || 0
  end

  def self.delta(resource, days:)
    for(resource).where(date: (Date.current - days)..Date.current).sum(:created_count)
  end
end

Calculator pattern

# app/models/analytics/base_calculator.rb
class Analytics::BaseCalculator
  def initialize(date)
    @date = date
  end

  def call
    attrs = compute
    DailyMetric.upsert(
      { date: @date, resource_type: resource_type, **attrs, updated_at: Time.current, created_at: Time.current },
      unique_by: [:date, :resource_type]
    )
  end

  private

  def resource_type = raise NotImplementedError
  def compute       = raise NotImplementedError  # returns Hash with total_count, created_count, etc.

  def day_range = @[email protected]_of_day
end

One concrete example

# app/models/analytics/task_calculator.rb
class Analytics::TaskCalculator < Analytics::BaseCalculator
  def resource_type = "TodoCard"

  def compute
    eod  = @date.end_of_day
    {
      total_count:   TodoCard.where("created_at <= ?", eod).where(archived: false).count,
      created_count: TodoCard.where(created_at: day_range).count,
      deleted_count: TodoCard.where(archived_at: day_range).count,
      active_users:  TodoCard.where(created_at: day_range).joins(todo_board: :user).distinct.count("users.id"),
      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
      }
    }
  end
end

All other calculators follow the same shape — only resource_type and compute differ.

Naming convention. Calculator class names use the friendly resource label (TaskCalculator), but the resource_type stored in daily_metrics is the ActiveRecord model class name ("TodoCard"). This keeps the calculator code readable while letting the admin controller derive its filter key directly from resource_class.name.

Registry

# app/models/analytics.rb
module Analytics
  CALCULATORS = [
    AccountCalculator, BalanceRegistryCalculator, ExpenseCalculator,
    DebtCalculator, InvestmentCalculator,
    HabitCalculator, TaskCalculator, GoalCalculator, SportCalculator,
    ActivityLogCalculator, VisionCalculator,
    NoteCalculator, ConstructionProjectCalculator, BirthdayCalculator,
    MarketListCalculator, FocusSessionCalculator, CountdownCalculator,
    UserCalculator
  ].freeze
end

Daily job

# app/jobs/analytics/calculate_daily_metrics_job.rb
class Analytics::CalculateDailyMetricsJob < ApplicationJob
  queue_as :default

  def perform(date = nil)
    date ||= Date.yesterday
    Analytics::CALCULATORS.each do |calculator|
      calculator.new(date).call
    rescue => e
      Rails.logger.error("[Analytics] #{calculator.name} failed for #{date}: #{e.message}")
      Rails.error.report(e, context: { calculator: calculator.name, date: date })
    end
  end
end

A calculator failure does not abort the others — each is independent.

Scheduling

Append to config/recurring.yml:

production:
  daily_metrics:
    class: Analytics::CalculateDailyMetricsJob
    schedule: "every day at 00:05 America/Sao_Paulo"

Backfill

# lib/tasks/analytics.rake
namespace :analytics do
  desc "Backfill daily_metrics from the system's start to yesterday"
  task backfill: :environment do
    from = User.minimum(:created_at)&.to_date || Date.current
    (from..Date.yesterday).each do |date|
      print "."
      Analytics::CalculateDailyMetricsJob.new.perform(date)
    end
    puts " done."
  end
end

Idempotent — re-running it overwrites existing rows via the unique index upsert.

Tests

  • Unit test per calculator with a fixture-loaded date, asserting computed columns.
  • Job test: stub two calculators, raise from one, assert the other still completes.
  • Backfill test: run on a 3-day range, assert correct row count.

4. Part C — DRY Admin Management Pattern

Base controller

# app/controllers/admin/base_management_controller.rb
class Admin::BaseManagementController < Admin::BaseController
  before_action :load_collection, only: :index

  def index
    render template: "admin/management/index"
  end

  private

  # Subclasses must implement:
  def resource_class    = raise NotImplementedError

  # Subclasses may override:
  def resource_label    = resource_class.model_name.human(count: 2)
  def resource_type_key = resource_class.name
  def ransackable_scope = resource_class.all
  def per_page          = 25

  def load_collection
    @q             = ransackable_scope.ransack(params[:q])
    @pagy, @records = pagy(@q.result(distinct: true).order(created_at: :desc), items: per_page)
    @daily_metrics  = DailyMetric.for(resource_type_key).recent(90).ordered
  end

  helper_method :resource_label, :resource_type_key
end

Subclasses are 5 lines:

class Admin::TasksController < Admin::BaseManagementController
  private
  def resource_class    = TodoCard
  def ransackable_scope = TodoCard.includes(todo_board: :user)
end

Shared template — app/views/admin/management/index.html.erb

<% content_for :title, resource_label %>

<div data-controller="admin-tabs"
     data-admin-tabs-tab-value="<%= params[:tab].presence || 'list' %>"
     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: ""),
             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>

The four shared partials

_header.html.erb

Locals: title, subtitle, total. Renders title + subtitle + a small "Total: N" chip. Provides a yield :header_actions slot for per-page buttons.

_tabs.html.erb

Two buttons "List" / "Analytics" with data-action="click->admin-tabs#switchTab". Active state from currentTab.

_analytics.html.erb

Locals: resource_type, daily_metrics. Renders:

  • 4 KPI cards: Total (latest row's total_count), Last 7d (sum created_count over 7d), Last 30d (sum 30d), Active users (latest active_users)
  • 1 ApexCharts line chart with two series: created_count and (optionally) total_count over the loaded window
  • An extras accordion that lists each non-zero extras key from the latest row

The chart is rendered by the existing chart_controller.js Stimulus controller (already in the codebase per pin_all_from).

Per-resource filter wrapper

Each resource has its own _filters.html.erb under app/views/admin/management/<controller>/filters.html.erb. The wrapper template provides the search_form_for @q shell + a date-range block + a user-email block; the resource-specific fields are inserted via yield or a small per-resource fragment.

Per-resource files

For each of the 17 resources:

app/controllers/admin/<resource>_controller.rb        # ~5 lines, inherits BaseManagementController
app/views/admin/management/<resource>/_filters.html.erb  # resource-specific filter fields
app/views/admin/management/<resource>/_table.html.erb    # thead + row partial
app/models/analytics/<resource>_calculator.rb         # subclass of BaseCalculator

Stimulus — admin_tabs_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["listPanel", "analyticsPanel", "listBtn", "analyticsBtn"]
  static values  = { tab: { type: String, default: "list" } }

  connect() {
    this.applyTab(this.tabValue)
  }

  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")
    // Update button styles by toggling a data attribute the buttons watch via CSS
  }
}

Routes

namespace :admin do
  # ...existing routes...
  resources :tasks,               only: :index
  resources :habits,              only: :index
  resources :goals,               only: :index
  resources :expenses,            only: :index
  resources :debts,               only: :index
  resources :investments,         only: :index
  resources :accounts,            only: :index
  resources :account_registries,  only: :index
  resources :sports,              only: :index
  resources :activities,          only: :index
  resources :vision,              only: :index
  resources :notes,               only: :index
  resources :birthdays,           only: :index
  resources :market_lists,        only: :index
  resources :countdowns,          only: :index
  resources :focus_sessions,      only: :index
  resources :construction_projects, only: :index

  get "leaderboard", to: "leaderboards#show"
end

Validation: port /admin/users to the new pattern

The existing Admin::UsersController is refactored to inherit from BaseManagementController as a smoke test of the abstraction. The existing modal-based show/edit actions stay intact (not part of this spec); only index switches templates.

Tests

  • Controller test for Admin::TasksController: index returns 200, populates @records and @daily_metrics.
  • View test for admin/management/_analytics.html.erb: given a fixture of daily_metrics, asserts the KPI cards render the right numbers.
  • Stimulus test (Playwright): switch tabs, assert URL updates and panel visibility swaps.

5. Part D — Resource Catalog

The full list, including which model backs each, the completion signal, and the extras keys. Calculators implement these; per-resource controllers/views display them.

Section Resource Model Completion signal extras keys
Finance Accounts Account total_balance_brl, accounts_with_history
Finance Account Registries BalanceRegistry unique_recorders
Finance Expenses Expense sum_amount_brl, top_category
Finance Debts Debt paid_off_at set sum_remaining_brl, paid_off_today
Finance Investments Investment sum_current_value_brl
Personal Habits Habit HabitCompletion(completed: true) completions_today, users_with_completion_today, avg_streak
Personal Tasks TodoCard archived_at that day archived_today, with_due_date, overdue
Personal Goals Goal status == "completed" completed_today, avg_progress_pct
Personal Sports Sport with_recent_log (logged in last 7d)
Personal Activities ActivityLog sum_duration_minutes, unique_sports
Personal Vision Vision completion_pct per record avg_completion_pct, users_with_vision
Tools Notes Note archived_at set pinned_count, archived_today
Tools Construction ConstructionProject sum_phase_count, sum_expense_amount_brl
Tools Birthdays Birthday upcoming_30d
Tools Market Lists MarketList with_items, total_items
Tools Timer FocusSession ended_at set sessions_today, sum_minutes_today, unique_users_focused
Tools Countdowns Countdown completed_at set completed_today, upcoming_7d
Users (dashboard only) User subscribed_count, trial_count, by_utm_source (hash)

For "checked" variants (Checked Habits, Checked Tasks): these live as extras keys on the parent resource, not separate calculators. The admin Habits view shows completions_today and the Habits analytics chart can plot it alongside created_count.

If any model's column names don't match the assumptions above (e.g. Goal#status may be an enum with different values), the calculator implementation adapts during Wave 4 — that is implementation detail, not a spec change.


6. Part E — Admin Dashboard & Leaderboard

Admin Dashboard — /admin/dashboard

Repurposes the existing Admin::DashboardController#index. Two sections:

KPI grid (top) — one card per major resource type (Users, Tasks, Habits, Expenses, Goals, Investments, Notes, Focus Sessions). Each card:

  • Total (latest total_count from DailyMetric.for(...).latest_total)
  • Last 7d (DailyMetric.delta(..., days: 7))
  • Last 30d (DailyMetric.delta(..., days: 30))
  • 30-day spark line (ApexCharts mini chart)

Implementation: one query — DailyMetric.recent(30).order(:date) — grouped in Ruby by resource_type. Avoids N queries.

User growth chart (bottom) — 90-day line chart from UserCalculator created_count, with a stacked-area option keyed by extras.by_utm_source so the admin can see signups attributed to each marketing source.

Admin Leaderboard — /admin/leaderboard

# app/controllers/admin/leaderboards_controller.rb
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("xp_total DESC")
    )
  end
end

Columns: rank, email, username, level (computed via Gamification::LevelCalculator), XP, last sign in, subscription status, signup source.

Filters: email contains, level range (translated to XP threshold in a small helper), created_at_gteq, subscription state.

Reuses admin/management/_header.html.erb and a custom table partial. No _analytics tab on this page — leaderboard is current state, not historical.

Tests

  • Dashboard request spec: with fixture daily_metrics, assert each KPI card shows the right number.
  • Leaderboard request spec: order users by XP, assert top-N order; filter by level range, assert filtered results.

7. Implementation sequence (waves)

The five waves are sequential. Each wave is independently deployable.

Wave Scope Estimate (rough)
1 Source tracking — migration, capture concern, registration patch, /admin/users filter ~0.5 day
2 Analytics infrastructure — daily_metrics, BaseCalculator, daily job, backfill rake, UserCalculator only ~1 day
3 DRY admin pattern — BaseManagementController, 4 shared partials, admin_tabs_controller.js; port /admin/users to it ~1 day
4 All 17 resource calculators + admin controllers/views ~3 days
5 Admin Dashboard + Leaderboard ~1 day

Waves 2 and 3 can be done in either order or in parallel (no shared file conflicts). All other waves depend on those two.


8. Open questions / decisions deferred to implementation

These are intentionally left flexible — they'll be resolved per-resource during Wave 4:

  • Per-resource column naming. Some models may not have archived or completed_at. The calculator for each resource adapts to that model's actual schema.
  • Per-resource filter set. The shared _filters.html.erb wrapper exposes date-range + user filters; resource-specific filters are added during Wave 4 based on each resource's likely admin use case.
  • UserCalculator "by_utm_source" cardinality. If UTM sources proliferate (>20 unique values), the JSON grows. We'll cap it to the top-N at compute time if it ever becomes an issue — not now.

9. Acceptance criteria

The work is complete when:

  1. A user landing on applifehub.com/?utm_source=newsletter and registering has signup_utm_source = "newsletter" on their User row.
  2. /admin/users lets an admin filter by signup_utm_source, signup_referrer_domain, and signup_affiliate_id.
  3. The analytics:backfill rake task populates daily_metrics for every day since the first user signed up, with one row per (date, resource_type) for all 18 calculators.
  4. Analytics::CalculateDailyMetricsJob runs nightly at 00:05 America/Sao_Paulo and writes yesterday's snapshot.
  5. Every URL under /admin/<resource> for the 17 resources listed in §5 renders a page with header, filters, table, and an Analytics tab showing KPI cards + chart, all driven from daily_metrics.
  6. /admin/dashboard shows the KPI grid (8 resources) and the user-growth chart, with no live aggregation against the source tables.
  7. /admin/leaderboard shows users ordered by XP with rank/level/XP columns and is filterable.
  8. Tests pass for: source capture, each calculator, the daily job, the BaseManagementController happy path, and the dashboard + leaderboard request specs.