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
/dashboardis 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
/leaderboardroute. 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]=newsletterreturns 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 (sumcreated_countover 7d), Last 30d (sum 30d), Active users (latestactive_users) - 1 ApexCharts line chart with two series:
created_countand (optionally)total_countover the loaded window - An
extrasaccordion that lists each non-zeroextraskey 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@recordsand@daily_metrics. - View test for
admin/management/_analytics.html.erb: given a fixture ofdaily_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_countfromDailyMetric.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
archivedorcompleted_at. The calculator for each resource adapts to that model's actual schema. - Per-resource filter set. The shared
_filters.html.erbwrapper 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:
- A user landing on
applifehub.com/?utm_source=newsletterand registering hassignup_utm_source = "newsletter"on their User row. /admin/userslets an admin filter by signup_utm_source, signup_referrer_domain, and signup_affiliate_id.- The
analytics:backfillrake task populatesdaily_metricsfor every day since the first user signed up, with one row per (date, resource_type) for all 18 calculators. Analytics::CalculateDailyMetricsJobruns nightly at 00:05 America/Sao_Paulo and writes yesterday's snapshot.- 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 fromdaily_metrics. /admin/dashboardshows the KPI grid (8 resources) and the user-growth chart, with no live aggregation against the source tables./admin/leaderboardshows users ordered by XP with rank/level/XP columns and is filterable.- Tests pass for: source capture, each calculator, the daily job, the BaseManagementController happy path, and the dashboard + leaderboard request specs.